Commit Graph
175 Commits
Author SHA1 Message Date
DrSmoothl 810d72fa04 feat(onebot): implement collection list retrieval
Replace the get_collection_list placeholder with authenticated, read-only collection retrieval through the existing bridge API.

Add strict binary envelope validation, bounded pagination, accurate category filtering, preserved 64-bit identifiers, and explicit ticket, transport, service, and malformed-response failures. Cap item counts, page traversal, response bytes, per-request waits, and the total operation time.

Expose the compatible response model through Core, OneBot, the SDK, and the generated MCP catalog. Cover protocol framing, cursor behavior, filtered hasMore semantics, cleanup failures, bridge ticket acquisition, and Action validation with regression tests.

Verified with the live test bot without changing native components or adding any collection write behavior.
2026-07-19 20:22:16 +08:00
DrSmoothl b92c8ad881 feat(protocol): decode online device snapshots
Decode Event 0x210 subtype 349 into typed online-device snapshots, including QQ-compatible low-byte client type normalization and one-entry-per-device-category projection.

Keep replacement snapshots outside system-message deduplication, reject malformed device identities observably, and add wire-level regressions for complete, packed, repeated, and invalid payloads. Live verification covered mobile account switching without requiring native changes.
2026-07-19 19:19:58 +08:00
DrSmoothl e6a69b05a7 feat(onebot): expose group folder upload metadata
Decode the folder modifier metadata returned by group file listings and project it as last upload time, uploader ID, and uploader name for both folder listing actions.

Regenerate the MCP catalog and add protocol, bridge, OneBot projection, and action schema coverage for the new fields.

Closes #248
2026-07-19 18:34:53 +08:00
DrSmoothl f3e0c4e45a fix(onebot): allow clearing the self signature
Accept explicit empty strings from both longNick and long_nick when callers need to clear the bot signature. Missing and non-string values remain rejected before reaching the bridge.

Preserve the QQ-compatible clear representation by omitting the empty profile value, and add action-level and wire-level regression coverage. Live verification confirmed successful clearing and readback on the test bot.

Fixes #247
2026-07-19 18:12:48 +08:00
DrSmoothl 5fe6b31eb6 refactor(ci): generalize native smoke tests
Replace the feature-specific MP3 verification command with a workspace-wide native test entry point.

Keep native regression cases in package-owned test suites so future checks can expand without adding workflow steps.
2026-07-17 18:27:30 +08:00
DrSmoothl b3838d204f fix(protocol): produce valid MP3 records
Use the dedicated audio conversion path for record output so mono QQ voice records produce valid MP3 data.

Add release-target smoke coverage and preserve conversion and cleanup failures for diagnostics.

Fixes #239
2026-07-17 18:17:48 +08:00
DrSmoothl fadc7ce316 feat(onebot): expose bidirectional message history
Add reverse_order to group and private history actions, preserve latest-page behavior without an anchor, and keep local fallback results chronologically ordered.

Key self-sent private messages and reply targets by the conversation peer, reject cross-conversation anchors, and include request context when a server fetch falls back to local history.

Refs #229
2026-07-17 17:45:30 +08:00
DrSmoothl 5885187e7d test(group-notice): cover publishing options and readback
Verify endpoint selection, option validation, image metadata parsing, response merging, and OneBot forwarding. Cover the type-20 new-member path and fail-fast conflict behavior.
2026-07-14 03:39:27 +08:00
DrSmoothl 4d93c727bb fix(protocol): retain C2C peer identities
Carry the stable sender identity through decoded private-message events and persist the UID/UIN pair at the receive boundary.

This keeps later conversation operations independent of stale message rows or fallible profile lookups, with regression coverage for decoding and identity indexing.
2026-07-13 23:01:54 +08:00
DrSmoothl 8cd7d51d80 fix(protocol): preserve friend category metadata 2026-07-13 18:51:11 +08:00
DrSmoothl 01688c7567 fix(protocol): enforce message element contracts end to end
Replace the open message bag with a validated discriminated union and preflight complete messages before uploads or sends. Return BAD_REQUEST for invalid or unsendable segments instead of dropping fields, sending partial content, or silently changing private messages.
2026-07-10 13:47:06 +08:00
DrSmoothl 920f75718a fix(protocol): bound rich message decoding
Reject malformed nested payloads, cap compressed card expansion, and enforce per-message decode budgets. Preserve unknown-wire metadata in diagnostics so unsupported QQ elements remain observable without exhausting memory.
2026-07-10 13:41:19 +08:00
DrSmoothl b7d648ea0f fix(proton): harden generated protobuf decoders
Reject malformed varints, out-of-bounds fields, and invalid groups before they escape their parent frame. Aggregate unknown-field metadata and decode signed int32 values while preserving SnowLuma's existing outbound encoding bytes.
2026-07-10 13:40:53 +08:00
DrSmoothl 35958a7fc2 fix(onebot): use canonical group request flags 2026-07-10 02:42:07 +08:00
DrSmoothl 5e73d69663 fix(test): add groupName to GroupMessage test fixtures (#212 CI)
core's typecheck covers its own tests (protocol/onebot typecheck src
only), so the newly-required GroupMessage.groupName field failed the
Dev Build typecheck on test fixtures that construct group_message
events. Add groupName to every such fixture across core/onebot/protocol
tests.
2026-07-09 19:18:09 +08:00
DrSmoothl 9b470d0412 feat(onebot): add group_name to group message events (#212)
Group message events (message + message_sent) now carry group_name — a
non-standard but common OneBot extension. The name comes from the
message's own wire field (ResponseGrp field 7, accurate even right after
a rename), falling back to the identity group cache, else '' when neither
is available. It's resolved in the group-message decoder, where both the
wire head and the identity cache are already at hand, so the converter
just passes it through (no ConverterContext change).

Verified live (tempserver): real inbound group messages from a non-self
account carry group_name set to the actual group name.
2026-07-09 18:50:54 +08:00
DrSmoothl d9dfe513ee feat(onebot): report forced offline as notice/bot_offline
Register a handler for the StatusService.KickNT push (QQ NT's unified
forced-offline notification — kick / login-elsewhere / risk-control),
decode KickNTResponse and emit bot_offline {user_id, tag, message},
matching NapCat. Command + field layout (f4=tips_title, f3=tips_content)
RE-confirmed against wrapper.linux.node + Lagrange; not yet live-captured.
2026-07-07 15:25:35 +08:00
DrSmoothl d3d1d2512e feat(onebot): report group member card change as notice/group_card
Detect a group member's card change from message traffic (sender card vs
cached roster card) and emit group_card {group_id, user_id, card_new,
card_old}, matching NapCat's parseCardChangedEvent. Fires only on a real
change of a known member; first-contact and unchanged cards are suppressed.
2026-07-07 02:23:17 +08:00
DrSmoothl 007dfaf6e2 fix(group): self-heal member group card from message field 4
A member's cached group card could stay stale for the lifetime of a long-running
bot: nothing refreshes it on a card change (no push), and the member-list refetch
only fires on join/leave/admin events (+60s TTL), never periodically — so a quiet
group freezes at warmup. get_group_msg_history / live events then showed an empty
or outdated sender.card even though the current card was in the message.

Every group message carries the sender's current display name in grp.memberCard
(field 4 = group card if set, else base nickname). The group decoder now prefers
it over the possibly-stale cache when it differs from the base nickname (a real
group card); and packet-pipeline self-heals it back into the in-memory member
cache, gated so it writes only on an actual change. This keeps active members'
cards fresh for all cache consumers off message traffic alone — no extra fetch,
no rate-limit exposure. Verified on-target: a member's card change now reflects
in the next message without a member-list refetch.

Known, accepted edge: a member changing only their base nickname (no card) is
mislabeled as a card until the next member-list refetch corrects it — field 4
alone can't distinguish the two, and the mislabeled value is still the member's
own current name.
2026-07-05 14:20:36 +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
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 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 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 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 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 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 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 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 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
Qiao 127f2cd2d6 feat(flash-transfer): QQ flash transfer (fileset) upload, share, download (#113)
* feat(proto-defs): add flash-transfer OIDB proto types

QQ 闪传(FlashTransfer / fileset)的 protobuf 类型定义:
- FlashFileEntry(0x93d2/0x93d3 文件条目)
- 0x93cf 申请 fileSet / 0x93d0 commit / 0x93db 完成 / 0x93d1 设状态
- 0x93d3 文件集详情 / 0x93d4 下载 URL(含主文件 fileId + 缩略图)
- 0x12a9 sub=100/103/200 prepare/apply/get-download
- sliceupload body + Sha1StateV
- 0x9407 删除 / 0x9427 重命名 / 0x93d7 发送
注册到 oidb-actions/index.ts,package.json 加 exports。

* feat(protocol): add flash-transfer OIDB services + sha1-stream

闪传协议层(OIDB namespace,复用 invokeOidb/OidbCallSpec):
- apply-fileset / commit-file / complete-fileset / set-status(上传流程)
- prepare-upload (0x12a9_100) / apply-upload (0x12a9_103)(sliceupload 凭证)
- get-fileset-detail (0x93d3) / list-filesets (0x93d2) / get-download-url (0x93d4)
- get-flash-download (0x12a9_200,主文件下载直链)
- delete-file (0x9407) / rename-file (0x9427,sub=0+uinForm) / send-flash (0x93d7)
- sha1-stream:SHA1 中间 state 计算器(sliceupload f107.f6 需要,Node crypto 不支持)
- byte-oracle 单测(apply/prepare/get-download-url/send-flash)+ sha1-stream 单测
package.json 加 exports。

* feat(core): add FlashTransferApi facade

FlashTransferApi 编排闪传全流程,复用 OIDB 基础设施(invokeOidb)+
computeHashes/loadBinarySource/fetch:
- createFlashTask:统一走 sub=100/103+sliceupload(含缩略图),不走小文件 PUT
  (PUT 不上报主文件 sha1,fileset 卡在未完成;QQ 客户端小文件也走 sliceupload)
- buildFileId:主文件 appid=14901(进 f14.f1),缩略图 png=14903/jpg=14902
- uploadThumbnail:随机纯色 PNG 占位(手写编码,不引入图像库),关联主文件下载入口
- getFilesetInfo/getFlashFileList/getShareLink/getFlashFileUrl/downloadFileset
- sendFlashMsg(私聊 uid / 群聊 groupId)/ getFilesetIdByCode(网页解析)
- deleteFlashFile / renameFlashFile / listFilesets
注册到 ApiHub(flashTransfer 字段)+ export。

* feat(onebot): add flash-transfer actions

OneBot action(extended.ts)10 个:
create_flash_task / get_fileset_info / get_flash_file_list /
get_flash_file_url / get_share_link / download_fileset /
send_flash_msg(私聊 user_id / 群聊 group_id)/ get_fileset_id /
rename_flash_file / delete_flash_file
MCP catalog(生成文件)同步新增 action 元数据。
extended-actions 接线单测覆盖。

* fix(onebot): align flash file list field name with NapCat

flashFileInfoToJson 的文件大小字段从 file_size 改为 size,对齐 NapCat
get_flash_file_list 响应(NapCat 用 size)。get_fileset_info 的 file_list
复用同一映射,一并生效。便于 NapCat 客户端 drop-in 迁移。

* feat(proto-defs): support multi-file commit in flash-transfer

0x93d0 commit 的 f4 改为 repeated,一个请求携带 fileset 内全部文件条目。
FlashCommitFileInfo 的 f3/f5/f6/f7/f10/f12 用 pb_optional,强制 0 值上 wire
(服务端多文件时严格校验字段存在,省略 0 会导致后续文件不计入 fileset)。

* feat(protocol): parameterize flash-transfer upload for multi-file

CommitFile.Params 改为 entries 数组,serialize 输出 repeated f4,每条 f6=文件序号。
PrepareUpload/ApplyUpload 加 fileIndex + formatCode 参数:filesetWrap.f4 用 fileIndex
(与 commit f6 对齐),f7 用 formatCode(mp4=2 非 26),f9=1。

* feat(core): multi-file flash-transfer upload

createFlashTask 支持多文件共用一个 fileset。关键改动:
- ApplyFileset 多文件时 fileName 用「<首文件名>等N个文件」、fileSize 用总和,
  服务端据此判定 fileset 为多文件,commit 的后续 entry 才被计入
- 一次性 CommitFile 提交所有文件元数据(f4 repeated)
- 两阶段上传:先全部 prepare+apply 注册 fileId,再全部 sliceupload 落盘
- 拆分原 uploadLargeFile 为 prepareAndApply + sliceuploadFile
- fileTypeCode mp4 formatCode 26→2(与 commit f7/filesetWrap.f7 一致)

* feat(onebot): multi-file create_flash_task + list_filesets action

create_flash_task 的 files 参数支持 string 或 string[],多文件共用一个 fileset。
新增 list_filesets action(facade.listFilesets 之前未暴露)。MCP catalog 同步更新
files schema(anyOf string/array)与 list_filesets 条目。

* fix(flash-transfer): multi-file download by fileIndex

0x93d4 响应 f1.f3 是 repeated fileInfo(多文件 fileset 每个文件一条,f6=序号、
f14=主文件 fileId),之前定义成单个导致多文件只能拿到第一个文件。
FlashDownloadEntry.fileInfo 改 pb_repeated,GetDownloadUrl 返回所有文件元信息,
download_fileset 按 fileIndex 选中目标文件走 0x12a9 sub=200 拿直链。

* test(protocol): inline byte-oracle hex fixtures

两个 flash-transfer byte-oracle 测试原先用 readFileSync 读仓库外的
.claude/reverse/scripts/proto/*.hex(本地逆向笔记目录),CI 上不存在导致失败。
把 hex 内容内联成常量,测试自包含。

* fix(flash-transfer): address review feedback

1. get_flash_file_url 接 file_index 参数并在 action 透传(之前漏接,多文件永远返回首文件)
2. getFileDownload 越界 fileIndex 不再静默回退首文件,找不到返回 null
3. postSliceupload status 缺失不再当成功,非 success 一律抛错
4. computeSha1StateV 改 O(n) 增量 update(之前每片 reset 重算整个前缀,O(n²))
5. get_flash_file_url 找不到文件抛错(之前返回空串当成功)
6. create_flash_task files 数组拒绝空字符串
2026-06-21 14:24:51 +08:00
DrSmoothl 9d1e39f25f fix(highway): VP8X WebP canvas dims are little-endian, not big-endian (#112)
detectImageFormat read the VP8X (extended WebP) canvas width/height with a
big-endian readBE24, but the WebP spec stores them as 24-bit LITTLE-endian
(Minus-One). A 1920×1080 image was reported as 8324865×3605505, so the
fileInfo sent to highway was garbage and QQ cropped the thumbnail. VP8
(readLE16) and VP8L (readLE32) were already correct.

- Replace readBE24 with readLE24 in the VP8X branch; readBE24 had no other
  caller, so it's deleted (no dead code left).
- Add the previously-absent detectImageFormat regression suite (12 cases):
  PNG/GIF/BMP/VP8/VP8L/VP8X/JPEG + JPEG DHT-skip + truncated/unknown
  fallbacks. The VP8X cases pin little-endian byte order, including the
  exact bytes from the issue and an asymmetric 256×1 case that a BE misread
  would blow up to 16711681.

Verified TDD red→green: the 3 VP8X cases failed before the one-helper swap,
all 12 pass after.
2026-06-20 20:19:10 +08:00
DrSmoothl b9d8247309 fix(qzone): complete re_feeds param set + correct qzreferrer/topicId honesty
Adversarial review (NEEDS-FIXES) — the high-risk parts (topicId base
hostUin_tid, uin/hostUin not swapped, format=fs, platformid=52) verified
correct against 3 community impls; the gaps:
- Added the re_feeds param family all 3 impls send and the publish sibling
  already sends for this CGI family: feedsType=100, private=0, paramstr=1,
  richtype/richval/isSignIn=''. Tests assert the load-bearing ones.
- qzreferrer used hostUin; all impls use the commenter's own uin → selfUin.
- topicId __1 suffix is impl-dependent (2/3 include it, 1 omits); kept as
  default but the comment now flags the suffix specifically as the
  unverified piece (base shape is confirmed), and the test no longer
  presents it as canonical.
2026-06-20 02:10:12 +08:00
DrSmoothl 95f24ffc4a feat(qzone): comment_qzone — comment on a 说说 via web API
commentQzoneMsg POSTs a form body to taotao.qzone.qq.com's
emotion_cgi_re_feeds (proxied through h5.qzone), keyed by topicId
<hostUin>_<tid>__1, as the bot (uin=self) on target_uin's feed (defaults
to the bot's own space). Returns the new comment id best-effort — the
response field name varies (commentid/commentId) so a missing id with
code 0 is still success. Throws on non-zero code/subcode; rejects empty
tid/content pre-flight. WRITE — rate-limit (风控).

topicId shape + comment-id field are extrapolated from community impls,
pending a live capture (labeled in the helper comment).

- core: QzoneApi.comment; onebot: comment_qzone action; regen catalog.
- tests: re_feeds form lock (topicId/uin/hostUin/content/format=fs),
  commentid + camelCase commentId fallback + missing-id success, empty
  tid/content preflight, non-zero code throw, bridge owner-defaulting.
2026-06-20 02:05:56 +08:00
DrSmoothl 870504ae3a fix(qzone): thread abstime into like, flag unlike endpoint as unverified
Adversarial review (NEEDS-FIXES) of the like/unlike slice:
- Missing abstime: every real dolike impl (QLiker.py, CSDN) sends the
  target feed's post time. Threaded abstime through setQzoneLike →
  QzoneApi.like → like_qzone/unlike_qzone actions (optional, default 0;
  pass the feed's real time for reliability). Test asserts it.
- internal_unlike_app is the conventional paired CGI but NO public bot
  impl exercises unlike, so it's best-guess. Labeled UNVERIFIED/pending a
  live capture in the helper comment, the unlike action summary, and the
  unlike test — instead of pinning it as confirmed truth (avoiding the
  re-codify-a-wrong-constant trap the last 3 slices hit).
- Documented the success-signal (code/subcode) as extrapolated from
  sibling CGIs (dolike may use a succ/fail token instead).

The confirmed mechanics (dolike CGI, opuin=liker, unikey/curkey mood
shape, appid=311, fid=tid) are noted as such.
2026-06-20 02:02:25 +08:00
DrSmoothl d497f1e6c7 feat(qzone): like_qzone / unlike_qzone — like/unlike a 说说 via web API
setQzoneLike POSTs a form body to w.qzone.qq.com's internal_dolike_app
(like) or internal_unlike_app (unlike), proxied through h5.qzone, keyed by
the feed's mood unikey/curkey (http://user.qzone.qq.com/<uin>/mood/<tid>)
and fid=tid, as the bot (opUin). target_uin selects whose 说说 (defaults
to the bot's own); scope is 说说 (mood, appid 311). Throws on non-zero
code/subcode. Rejects empty tid pre-flight. WRITE — rate-limit (风控).

- core: QzoneApi.like(tid, targetUin?, like); onebot: like_qzone +
  unlike_qzone actions; regen catalog.
- tests: dolike vs unlike CGI selection, unikey/curkey/fid/opuin form
  lock, empty-tid preflight, code+subcode throws, and bridge owner-
  defaulting (self vs friend) + like-flag threading.
2026-06-20 01:56:23 +08:00
DrSmoothl 4d75dc9ae3 fix(qzone): delete_v6 uses format=fs, drop bogus json param, harden success check
Adversarial review (NEEDS-FIXES) — two wrong-constant defects (same family
as the publish t1_tid bug) + a destructive-write hardening:
- format=json → format=fs. Every working reference (silica-github/
  qq_zone_delete, zhihu, daimagongfang) uses fs for the delete CGI; the
  tests re-codified the wrong value.
- Removed the invented json=1 body param (no source includes it); test now
  asserts it's absent.
- Also throw on a non-zero subcode (not just code), and documented that the
  code/subcode success signal is EXTRAPOLATED from sibling CGIs pending a
  live capture (public delete impls only check HTTP 2xx). Added subcode-throw
  and code-absent-resolves tests to lock the branch.
2026-06-20 01:52:47 +08:00
DrSmoothl a704f12945 feat(qzone): delete_qzone_msg — delete a 说说 by tid via web API
deleteQzoneMsg POSTs a form-urlencoded tid to taotao.qzone.qq.com's
emotion_cgi_delete_v6 (proxied through h5.qzone), on the bot's own space.
Success has no positive payload beyond code 0, so the contract is
throw-on-non-zero-code (foreign/unknown tid or auth failure); the server
only deletes feeds the hostUin owns. Rejects empty tid pre-flight.

- core: QzoneApi.delete; onebot: delete_qzone_msg action; regen catalog.
- tests: tid form-body/url/headers lock, empty-tid preflight, non-zero
  code throw, and bridge self-space threading.
2026-06-20 01:47:50 +08:00
DrSmoothl 9e21c3a7ef fix(qzone): read real t1_tid/t1_time publish fields — was false-throwing every success
Adversarial review (verdict NEEDS-FIXES, CRITICAL): emotion_cgi_publish_v6's
success envelope names the new feed id `t1_tid` and post time `t1_time`
(a STRING), NOT `tid`/`now`. So every successful publish hit the
'缺少 tid' guard and was reported as a failure (risking caller-retry
duplicate 说说). The tests passed only because they mocked the wrong field
names — they codified the bug.

- Read t1_tid (primary) with tid as a defensive fallback; t1_time||now
  via Number() (t1_time is a string on the wire). Verified against the
  Go reference lib starim00/AnimeAPI EmotionPublishVo.
- Added who=1 to the form body (both working references send it).
- Tests now encode the real contract (t1_tid/t1_time string), plus a
  tid/now fallback case and a no-feed-id throw case.
2026-06-20 01:44:50 +08:00
DrSmoothl 9f2eb37232 feat(qzone): send_qzone_msg — publish a text 说说 via web API
First Qzone WRITE path. publishQzoneMsg POSTs a form-urlencoded body to
taotao.qzone.qq.com's emotion_cgi_publish_v6 (proxied through h5.qzone),
g_tk in the query, on the bot's own space. Returns the server-assigned
tid (the handle for later delete/comment/like) + post time.

- Throws on transport failure, non-zero Qzone code (content rejected /
  rate-limited), or a success body with no tid — never reports a publish
  as succeeded without the feed id. Rejects empty content pre-flight.
- 带图 (image) publishing is a later slice (needs the upload CGI first).
- Action carries a风控/限流 caveat: publishing is an active action and
  high frequency is rate-limited by Qzone, same as sending messages.

- core: QzoneApi.publish; onebot: send_qzone_msg action; regen catalog.
- tests: form-body/url/headers lock, empty-content preflight, non-zero
  code throw, missing-tid throw, and bridge self-space threading.
2026-06-20 01:40:06 +08:00