* 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>
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.
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.
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.
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.
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.
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.
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.
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.
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.
Cosmetic fidelity nit from re-review: the fixture used _Callback(...) while
the request pins callback=_preloadCallback. Parser is name-agnostic so
behavior is unchanged; this just makes the fixture mirror slice-1.
Adversarial review of the get_qzone_feeds slice (verdict NEEDS-FIXES):
- Host: hit ic2.qzone.qq.com directly, bypassing the proxy gateway that
slice-1/group-album use. The qzone.qq.com cookie jar only authenticates
against the proxy origin, so the direct call would fail referer/origin.
Now routed through h5.qzone.qq.com/proxy/domain/ic2.qzone.qq.com/...
- Format: requested format=json but the test fed a JSONP body, so the
requested format was never pinned. Aligned to slice-1's
format=jsonp + callback=_preloadCallback; the test now asserts format.
- Pagination: documented honestly that page_num is reliable only for the
first page (deep paging needs a time cursor, deferred) — in the
docstring and the action summary/param desc.
- Clarified the key/feedskey fallback is the per-feed handle, not a cursor.
Second read-only Qzone slice. getQzoneFeeds over ic2.qzone.qq.com's
feeds3_html_more CGI, same cookie/g_tk plumbing + JSONP-tolerant parse +
throw-on-auth-failure contract as get_qzone_msg_list. Surfaces the stable
structured fields (uin/nickname/time/appid/key) and passes the
pre-rendered html blob through verbatim — deep HTML→segment parsing is
left to callers. Paginated via page_num (1-based) + count.
- protocol/web/qzone.ts: getQzoneFeeds + mapFeeds.
- core: QzoneApi.getFeeds (always the bot's own identity).
- onebot: get_qzone_feeds action; regenerated MCP catalog.
- tests: feeds mapping, HTTP url/params, empty-feed→[], auth-throw,
and bridge pageNum/count threading.
Exact CGI field names / pagination cursor to be re-confirmed against a
live capture (same posture as the msglist helper).
Adversarial review of the get_qzone_msg_list slice flagged two real
coverage gaps (verdict was SHIP, no correctness defects):
- protocol: empty space ({code:0,msglist:[]}) must return [] not throw —
the exact distinction the throw-on-auth-failure contract promises.
- core: QzoneApi.getMsgList target_uin defaulting + the >0 guard, the
only place the string(identity.uin)/number(param) boundary is crossed.
First slice of personal QQ-Zone (个人空间) support, an exclusive feature
vs NapCat/SL (both only do group albums). Read-only to prove out the
auth+parse chain before write ops (publish/like/comment).
- protocol/web/qzone.ts: getQzoneMsgList over taotao.qzone.qq.com's
emotion_cgi_msglist_v6 (h5.qzone proxy), reusing the same cookie/g_tk
plumbing as group-album. JSONP-tolerant parse; throws on auth failure
rather than swallowing to [] (mirrors group-signin contract).
- core: QzoneApi bridge class wired into ApiHub.
- onebot: get_qzone_msg_list action (target_uin/pos/num), regenerated
MCP catalog + new 空间 doc category.
- HTTP-layer tests lock URL/params, JSONP stripping, field mapping, and
the throw-on-auth-failure paths (9 cases, all green).
Exact CGI params/response fields to be re-confirmed against a live
capture when extending (same maintenance posture as group-album).