diff --git a/packages/core/src/bridge/apis/qzone.ts b/packages/core/src/bridge/apis/qzone.ts index 9fe8e906..54e25e5a 100644 --- a/packages/core/src/bridge/apis/qzone.ts +++ b/packages/core/src/bridge/apis/qzone.ts @@ -27,6 +27,8 @@ import { import { getQzonePublicCapabilities, getQzoneHomeFeeds, + getQzoneFeedDetail, + type QzoneFeedDetail, getQzoneShareArk, type QzoneShareArk, getQzonePublicFeeds, @@ -81,6 +83,11 @@ export class QzoneApi { return getQzoneHomeFeeds(cookies, this.ctx.identity.uin, targetUin?.toString(), cursor); } + async getFeedDetail(tid: string): Promise { + const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com'); + return getQzoneFeedDetail(cookies, tid); + } + async getShareArk(feed: JsonObject): Promise { const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com'); return getQzoneShareArk(cookies, feed); diff --git a/packages/core/tests/actions/qzone-public.test.ts b/packages/core/tests/actions/qzone-public.test.ts index 680b9dc7..673951ee 100644 --- a/packages/core/tests/actions/qzone-public.test.ts +++ b/packages/core/tests/actions/qzone-public.test.ts @@ -101,6 +101,31 @@ const cases = [ describe('apis/qzone public', () => { afterEach(() => vi.restoreAllMocks()); + it('reads a post by tid using current-account credentials', async () => { + const { api, getCookies } = makeApi(); + const tid = '1127000000f1536501000000'; + const detail = { feed, attach_info: 'comments-next' }; + const request = vi.spyOn(qzonePublic, 'getQzoneFeedDetail').mockResolvedValue(detail); + + expect(await api.getFeedDetail(tid)).toBe(detail); + expect(getCookies).toHaveBeenCalledExactlyOnceWith('qzone.qq.com'); + expect(request).toHaveBeenCalledExactlyOnceWith(cookies, tid); + }); + + it('propagates feed detail and cookie failures without replay', async () => { + const { api, getCookies } = makeApi(); + const request = vi + .spyOn(qzonePublic, 'getQzoneFeedDetail') + .mockRejectedValue(new Error('denied')); + + await expect(api.getFeedDetail('1127000000f1536501000000')).rejects.toThrow('denied'); + getCookies.mockRejectedValueOnce(new Error('cookie failure')); + await expect(api.getFeedDetail('1127000000f1536501000000')).rejects.toThrow( + 'cookie failure', + ); + expect(request).toHaveBeenCalledOnce(); + }); + it('generates a share card with the account cookies and preserves its notice', async () => { const { api, getCookies } = makeApi(); const card = { ark: '{}', requires_confirmation: true, notice: '所有人可见' }; diff --git a/packages/mcp/src/generated/catalog.json b/packages/mcp/src/generated/catalog.json index 23918d25..34e44618 100644 --- a/packages/mcp/src/generated/catalog.json +++ b/packages/mcp/src/generated/catalog.json @@ -11241,6 +11241,76 @@ }, "category": "群相册" }, + { + "name": "get_qzone_feed_detail", + "aliases": [], + "summary": "按说说 tid 读取完整空间动态,可用于生成分享卡片", + "returns": "完整动态及上游详情字段。feed 保留图片、视频、分享信息和未知字段,可直接用于 get_qzone_share_ark;attach_info 保留上游评论游标,不自动翻页。", + "returnsSchema": { + "type": "object", + "additionalProperties": true, + "properties": { + "feed": { + "type": "object", + "additionalProperties": true, + "description": "去除上游包装后的完整动态,可原样传给 get_qzone_share_ark" + }, + "attach_info": { + "type": [ + "string", + "null" + ], + "description": "上游评论续页游标,未提供时为 null;不保证评论及回复完整" + } + }, + "required": [ + "feed", + "attach_info" + ] + }, + "examples": [ + { + "summary": "按说说编号获取完整动态", + "params": { + "tid": "1127000000f1536501000000" + }, + "notes": "将示例 tid 替换为 get_qzone_msg_list 或 send_qzone_msg 返回的 24 位十六进制说说编号。把结果的 feed 原样传给 get_qzone_share_ark;读取和分享均受空间权限限制。" + } + ], + "readOnly": true, + "params": [ + { + "name": "tid", + "type": "string", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 24 + }, + "desc": "24 位十六进制说说编号" + } + ], + "invariants": [ + "tid must be a 24-character hexadecimal post ID" + ], + "inputSchema": { + "type": "object", + "properties": { + "tid": { + "type": "string", + "minLength": 1, + "maxLength": 24, + "description": "24 位十六进制说说编号" + } + }, + "required": [ + "tid" + ], + "additionalProperties": true + }, + "category": "空间" + }, { "name": "get_qzone_feeds", "aliases": [], @@ -12385,7 +12455,7 @@ } } }, - "notes": "feed 原样取自 get_qzone_home_feeds 或 get_qzone_public_feeds。分享链接可能允许未登录者访问并包含作者 QQ 号;如 requires_confirmation=true,发送前展示 notice 并取得确认。将 ark 原文作为 JSON 消息发送,不要修改卡片内容。" + "notes": "feed 原样取自 get_qzone_feed_detail 的 feed,或 get_qzone_home_feeds / get_qzone_public_feeds 的 feeds 条目。分享链接可能允许未登录者访问并包含作者 QQ 号;如 requires_confirmation=true,发送前展示 notice 并取得确认。将 ark 原文作为 JSON 消息发送,不要修改卡片内容。" } ], "readOnly": false, @@ -12398,7 +12468,7 @@ "type": "object", "additionalProperties": true }, - "desc": "主页或推荐列表返回的完整动态,保留所有原始字段" + "desc": "主页、详情或推荐列表返回的完整动态,保留所有原始字段" } ], "invariants": [], @@ -12408,7 +12478,7 @@ "feed": { "type": "object", "additionalProperties": true, - "description": "主页或推荐列表返回的完整动态,保留所有原始字段" + "description": "主页、详情或推荐列表返回的完整动态,保留所有原始字段" } }, "required": [ @@ -20953,7 +21023,7 @@ }, { "category": "空间", - "count": 28 + "count": 29 }, { "category": "系统表情", diff --git a/packages/mcp/src/generated/catalog.ts b/packages/mcp/src/generated/catalog.ts index 46e38314..e2881c80 100644 --- a/packages/mcp/src/generated/catalog.ts +++ b/packages/mcp/src/generated/catalog.ts @@ -11244,6 +11244,76 @@ export const ACTIONS: CatalogAction[] = [ }, "category": "群相册" }, + { + "name": "get_qzone_feed_detail", + "aliases": [], + "summary": "按说说 tid 读取完整空间动态,可用于生成分享卡片", + "returns": "完整动态及上游详情字段。feed 保留图片、视频、分享信息和未知字段,可直接用于 get_qzone_share_ark;attach_info 保留上游评论游标,不自动翻页。", + "returnsSchema": { + "type": "object", + "additionalProperties": true, + "properties": { + "feed": { + "type": "object", + "additionalProperties": true, + "description": "去除上游包装后的完整动态,可原样传给 get_qzone_share_ark" + }, + "attach_info": { + "type": [ + "string", + "null" + ], + "description": "上游评论续页游标,未提供时为 null;不保证评论及回复完整" + } + }, + "required": [ + "feed", + "attach_info" + ] + }, + "examples": [ + { + "summary": "按说说编号获取完整动态", + "params": { + "tid": "1127000000f1536501000000" + }, + "notes": "将示例 tid 替换为 get_qzone_msg_list 或 send_qzone_msg 返回的 24 位十六进制说说编号。把结果的 feed 原样传给 get_qzone_share_ark;读取和分享均受空间权限限制。" + } + ], + "readOnly": true, + "params": [ + { + "name": "tid", + "type": "string", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 24 + }, + "desc": "24 位十六进制说说编号" + } + ], + "invariants": [ + "tid must be a 24-character hexadecimal post ID" + ], + "inputSchema": { + "type": "object", + "properties": { + "tid": { + "type": "string", + "minLength": 1, + "maxLength": 24, + "description": "24 位十六进制说说编号" + } + }, + "required": [ + "tid" + ], + "additionalProperties": true + }, + "category": "空间" + }, { "name": "get_qzone_feeds", "aliases": [], @@ -12388,7 +12458,7 @@ export const ACTIONS: CatalogAction[] = [ } } }, - "notes": "feed 原样取自 get_qzone_home_feeds 或 get_qzone_public_feeds。分享链接可能允许未登录者访问并包含作者 QQ 号;如 requires_confirmation=true,发送前展示 notice 并取得确认。将 ark 原文作为 JSON 消息发送,不要修改卡片内容。" + "notes": "feed 原样取自 get_qzone_feed_detail 的 feed,或 get_qzone_home_feeds / get_qzone_public_feeds 的 feeds 条目。分享链接可能允许未登录者访问并包含作者 QQ 号;如 requires_confirmation=true,发送前展示 notice 并取得确认。将 ark 原文作为 JSON 消息发送,不要修改卡片内容。" } ], "readOnly": false, @@ -12401,7 +12471,7 @@ export const ACTIONS: CatalogAction[] = [ "type": "object", "additionalProperties": true }, - "desc": "主页或推荐列表返回的完整动态,保留所有原始字段" + "desc": "主页、详情或推荐列表返回的完整动态,保留所有原始字段" } ], "invariants": [], @@ -12411,7 +12481,7 @@ export const ACTIONS: CatalogAction[] = [ "feed": { "type": "object", "additionalProperties": true, - "description": "主页或推荐列表返回的完整动态,保留所有原始字段" + "description": "主页、详情或推荐列表返回的完整动态,保留所有原始字段" } }, "required": [ @@ -20957,7 +21027,7 @@ export const CATEGORIES: CatalogCategory[] = [ }, { "category": "空间", - "count": 28 + "count": 29 }, { "category": "系统表情", diff --git a/packages/onebot/src/actions/qzone-public.ts b/packages/onebot/src/actions/qzone-public.ts index 6ce1feb4..d6f251b7 100644 --- a/packages/onebot/src/actions/qzone-public.ts +++ b/packages/onebot/src/actions/qzone-public.ts @@ -79,6 +79,45 @@ export const actions = [ run: async (p, ctx) => okResponse(await ctx.bridge.apis.qzone.getHomeFeeds(p.target_uin, p.cursor)), }), + defineAction({ + name: 'get_qzone_feed_detail', + summary: '按说说 tid 读取完整空间动态,可用于生成分享卡片', + readOnly: true, + examples: [ + { + summary: '按说说编号获取完整动态', + params: { tid: '1127000000f1536501000000' }, + notes: '将示例 tid 替换为 get_qzone_msg_list 或 send_qzone_msg 返回的 24 位十六进制说说编号。把结果的 feed 原样传给 get_qzone_share_ark;读取和分享均受空间权限限制。', + }, + ], + returns: + '完整动态及上游详情字段。feed 保留图片、视频、分享信息和未知字段,可直接用于 get_qzone_share_ark;attach_info 保留上游评论游标,不自动翻页。', + returnsSchema: { + type: 'object', + additionalProperties: true, + properties: { + feed: { + type: 'object', + additionalProperties: true, + description: '去除上游包装后的完整动态,可原样传给 get_qzone_share_ark', + }, + attach_info: { + type: ['string', 'null'], + description: '上游评论续页游标,未提供时为 null;不保证评论及回复完整', + }, + }, + required: ['feed', 'attach_info'], + }, + params: { + tid: f.string({ allowEmpty: false, maxLen: 24 }).describe('24 位十六进制说说编号'), + }, + rules: (r) => [ + r.rule('tid must be a 24-character hexadecimal post ID', (p) => + /^[0-9a-f]{24}$/i.test(p.tid), + ), + ], + run: async (p, ctx) => okResponse(await ctx.bridge.apis.qzone.getFeedDetail(p.tid)), + }), defineAction({ name: 'get_qzone_share_ark', summary: '生成空间动态分享卡片,返回原文及分享提示', @@ -100,7 +139,7 @@ export const actions = [ }, }, }, - notes: 'feed 原样取自 get_qzone_home_feeds 或 get_qzone_public_feeds。分享链接可能允许未登录者访问并包含作者 QQ 号;如 requires_confirmation=true,发送前展示 notice 并取得确认。将 ark 原文作为 JSON 消息发送,不要修改卡片内容。', + notes: 'feed 原样取自 get_qzone_feed_detail 的 feed,或 get_qzone_home_feeds / get_qzone_public_feeds 的 feeds 条目。分享链接可能允许未登录者访问并包含作者 QQ 号;如 requires_confirmation=true,发送前展示 notice 并取得确认。将 ark 原文作为 JSON 消息发送,不要修改卡片内容。', }, ], returns: '生成卡片但不发送消息;后续发送仍返回普通消息编号。', @@ -116,7 +155,9 @@ export const actions = [ }, required: ['ark', 'requires_confirmation', 'notice'], }, - params: { feed: f.object().describe('主页或推荐列表返回的完整动态,保留所有原始字段') }, + params: { + feed: f.object().describe('主页、详情或推荐列表返回的完整动态,保留所有原始字段'), + }, run: async (p, ctx) => okResponse(await ctx.bridge.apis.qzone.getShareArk(p.feed)), }), defineAction({ diff --git a/packages/onebot/tests/qzone-public-actions.test.ts b/packages/onebot/tests/qzone-public-actions.test.ts index 2c5216fc..9501a489 100644 --- a/packages/onebot/tests/qzone-public-actions.test.ts +++ b/packages/onebot/tests/qzone-public-actions.test.ts @@ -40,6 +40,13 @@ const cases: { args: [10001, 'cursor'], readOnly: true, }, + { + action: 'get_qzone_feed_detail', + method: 'getFeedDetail', + params: { tid: '1127000000f1536501000000' }, + args: ['1127000000f1536501000000'], + readOnly: true, + }, { action: 'get_qzone_share_ark', method: 'getShareArk', @@ -317,6 +324,13 @@ describe('qzone public actions', () => { ); it.each([ + { action: 'get_qzone_feed_detail', params: {} }, + { action: 'get_qzone_feed_detail', params: { tid: '' } }, + { action: 'get_qzone_feed_detail', params: { tid: 'T1' } }, + { action: 'get_qzone_feed_detail', params: { tid: [] } }, + { action: 'get_qzone_feed_detail', params: { tid: 'g'.repeat(24) } }, + { action: 'get_qzone_feed_detail', params: { tid: 'a'.repeat(25) } }, + { action: 'get_qzone_feed_detail', params: { tid: '1127000000f1536501000000\n' } }, { action: 'get_qzone_public_feeds', params: { cursor: '' } }, { action: 'get_qzone_public_feeds', params: { cursor: 'a'.repeat(65537) } }, { action: 'get_qzone_public_feed_detail', params: {} }, diff --git a/packages/proto-defs/package.json b/packages/proto-defs/package.json index 193c09e6..12301de7 100644 --- a/packages/proto-defs/package.json +++ b/packages/proto-defs/package.json @@ -20,6 +20,10 @@ "types": "./src/collection.ts", "default": "./src/collection.ts" }, + "./qzone": { + "types": "./src/qzone.ts", + "default": "./src/qzone.ts" + }, "./dataline": { "types": "./src/dataline.ts", "default": "./src/dataline.ts" diff --git a/packages/proto-defs/src/index.ts b/packages/proto-defs/src/index.ts index c5690100..73057e12 100644 --- a/packages/proto-defs/src/index.ts +++ b/packages/proto-defs/src/index.ts @@ -22,6 +22,7 @@ export type * from './action.js'; export type * from './collection.js'; +export type * from './qzone.js'; export type * from './element.js'; // NOTE: `highway.ts` intentionally NOT re-exported here — see note above. export type * from './longmsg.js'; diff --git a/packages/proto-defs/src/qzone.ts b/packages/proto-defs/src/qzone.ts new file mode 100644 index 00000000..1fb6fe5b --- /dev/null +++ b/packages/proto-defs/src/qzone.ts @@ -0,0 +1,13 @@ +import type { int_64, pb, pb_optional } from '@snowluma/proton'; + +/** Personal-space emotion identity carried in GetFeedDetail's base64 ugc_id. */ +export interface QzoneEmotionUgcId { + appId: pb<1, int_64>; + uin: pb<2, int_64>; + publishTime: pb<3, int_64>; + // These empty fields have explicit presence in the upstream identifier. + field4: pb_optional<4, string>; + field5: pb_optional<5, string>; + field6: pb<6, string>; + tid: pb<8, string>; +} diff --git a/packages/protocol/src/web/qzone-public.ts b/packages/protocol/src/web/qzone-public.ts index b18827dd..ca57a620 100644 --- a/packages/protocol/src/web/qzone-public.ts +++ b/packages/protocol/src/web/qzone-public.ts @@ -1,4 +1,6 @@ import type { JsonObject } from '@snowluma/common/json'; +import type { QzoneEmotionUgcId } from '@snowluma/proto-defs/qzone'; +import { protobuf_encode } from '@snowluma/proton'; import { RequestUtil, cookieToString, getBknFromCookie } from './request-util'; export type QzonePublicContent = string | JsonObject[]; @@ -15,6 +17,13 @@ export interface QzoneShareArk extends JsonObject { notice: string | null; } +export interface QzoneFeedDetail extends JsonObject { + /** Original cells, ready to pass to getQzoneShareArk when sharing is allowed. */ + feed: JsonObject; + /** Upstream comment continuation; null when the server supplies none. */ + attach_info: string | null; +} + interface HomeFeedCursor { version: 1; kind: 'home'; @@ -238,6 +247,63 @@ export async function getQzoneHomeFeeds( return { ...data, feeds, has_more: data.has_more, next_cursor: nextCursor }; } +export async function getQzoneFeedDetail( + cookies: Record, + tid: string, +): Promise { + if (tid.length !== 24 || !/^[0-9a-f]{24}$/i.test(tid)) { + throw new Error('空间说说 tid 必须是 24 位十六进制编号'); + } + + const feedId = tid.toLowerCase(); + const identity = Buffer.from(feedId, 'hex'); + // Personal-space tids carry the author and creation time as little-endian + // uint32 words. Use the actual time rather than guessing an ignored field. + const ugcId = protobuf_encode({ + appId: 311n, + uin: BigInt(identity.readUInt32LE(0)), + publishTime: BigInt(identity.readUInt32LE(4)), + field4: '', + field5: '', + field6: '0', + tid: feedId, + }); + const data = await request(cookies, reader, 'GetFeedDetail', '0x97b1_1', { + union_id: { + feed_id: feedId, + ugc_id: Buffer.from(ugcId).toString('base64'), + adv_id: null, + }, + feed_action_domain_type: 1, + from_page: 1, + }); + const wrapper = data.feed; + + if (!isObject(wrapper) || !isObject(wrapper.feed)) { + throw new Error('qzone GetFeedDetail 响应缺少完整动态 feed.feed'); + } + + const feed = wrapper.feed; + const common = feed.cell_common; + const unionId = isObject(common) ? common.union_id : undefined; + + if ( + !isObject(unionId) || + typeof unionId.feed_id !== 'string' || + unionId.feed_id.toLowerCase() !== feedId + ) { + throw new Error('qzone GetFeedDetail 返回的动态编号与请求不符'); + } + + if (data.attach_info != null && typeof data.attach_info !== 'string') { + throw new Error('qzone GetFeedDetail 评论游标格式异常'); + } + + // Match homepage feeds: remove only the item wrapper, retaining every cell + // (especially the opaque sharing fields) and the remaining response metadata. + return { ...data, feed, attach_info: data.attach_info ?? null }; +} + export async function getQzoneShareArk( cookies: Record, feed: JsonObject, @@ -272,7 +338,7 @@ export async function getQzoneShareArk( typeof feed.cell_feed_transfer !== 'string' || !feed.cell_feed_transfer ) { - throw new Error('空间动态缺少分享信息,请传入主页或推荐列表返回的完整动态'); + throw new Error('空间动态缺少分享信息,请传入主页、详情或推荐列表返回的完整动态'); } const data = await request(cookies, 'trpc.qq.share.Service', 'CreateShare', '0x9409_1', { diff --git a/packages/protocol/tests/web/qzone-feed-detail.test.ts b/packages/protocol/tests/web/qzone-feed-detail.test.ts new file mode 100644 index 00000000..1da2dac9 --- /dev/null +++ b/packages/protocol/tests/web/qzone-feed-detail.test.ts @@ -0,0 +1,179 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getQzoneFeedDetail, getQzoneShareArk } from '../../src/web/qzone-public'; +import { RequestUtil, getBknFromCookie } from '../../src/web/request-util'; + +const cookies = { p_skey: 'fixture', uin: 'o20002' }; +// Synthetic post: author 10001, Unix time 1700000000, sequence word 1. +const tid = '1127000000f1536501000000'; +const ugcId = 'CLcCEJFOGIDiz6oGIgAqADIBMEIYMTEyNzAwMDAwMGYxNTM2NTAxMDAwMDAw'; +const feed = { + cell_common: { domain_type: 1, union_id: { feed_id: tid, ugc_id: ugcId }, operate_mask: 0 }, + cell_summary: { summary: 'Fixture post' }, + cell_pic: { pics: [{ url: 'https://example.com/one.png' }, { future_image: true }] }, + cell_video: { opaque_video: 'preserved' }, + cell_comment: { comments: [{ id: '9007199254740993', unknown: true }] }, + cell_operation: { + share_outside: { + share_access_resource: { busi_id: 'qzone_feed', resource_id: 'resource' }, + }, + }, + cell_feed_transfer: 'original-transfer', + cell_future: { nested: [null, '18446744073709551615', { preserved: true }] }, +}; +const detail = { + feed: { feed }, + attach_info: 'opaque-comment-continuation', + contribute_bar: null, + future_detail_field: { preserved: true }, +}; + +afterEach(() => vi.restoreAllMocks()); + +describe('Qzone feed detail by tid', () => { + it.each([tid, tid.toUpperCase()])( + 'requests the personal-space detail with the encoded identity (%s)', + async (input) => { + const request = vi + .spyOn(RequestUtil, 'HttpGetJson') + .mockResolvedValue({ retcode: 0, data: detail }); + const result = await getQzoneFeedDetail(cookies, input); + + expect(result).toEqual({ ...detail, feed }); + expect(result.feed).toBe(feed); + expect(request).toHaveBeenCalledExactlyOnceWith( + `https://h5.qzone.qq.com/http2rpc/gotrpc/v1/trpc.qzone.feeds_reader.FeedsReader/GetFeedDetail?g_tk=${getBknFromCookie(cookies)}`, + 'POST', + { + union_id: { feed_id: tid, ugc_id: ugcId, adv_id: null }, + feed_action_domain_type: 1, + from_page: 1, + }, + expect.objectContaining({ + Cookie: 'p_skey=fixture; uin=o20002', + 'Content-Type': 'application/json', + 'x-oidb': JSON.stringify({ + uint32_command: '0x97b1', + uint32_service_type: '1', + }), + }), + ); + }, + ); + + it('keeps the author and time unsigned and emits the explicit empty identity fields', async () => { + const highTid = 'ffffffffffffffff01000000'; + const request = vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue({ + retcode: 0, + data: { feed: { feed: { cell_common: { union_id: { feed_id: highTid } } } } }, + }); + + await getQzoneFeedDetail(cookies, highTid); + const body = request.mock.calls[0]![2] as { union_id: { ugc_id: string } }; + // Independent wire vector: app 311; two uint32-max values encoded as + // positive int64; present empty strings in fields 4 and 5; field 8 tid. + expect(Buffer.from(body.union_id.ugc_id, 'base64').toString('hex')).toBe( + '08b70210ffffffff0f18ffffffff0f22002a003201304218' + + '666666666666666666666666666666663031303030303030', + ); + }); + + it.each([undefined, null, ''])( + 'preserves an empty or absent upstream comment cursor (%s)', + async (attach_info) => { + const request = vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue({ + retcode: 0, + data: { ...detail, attach_info }, + }); + + expect((await getQzoneFeedDetail(cookies, tid)).attach_info).toBe(attach_info ?? null); + expect(request).toHaveBeenCalledOnce(); + }, + ); + + it.each(['', 'T1', 'g'.repeat(24), 'a'.repeat(23), 'a'.repeat(25), `${tid}\n`, ` ${tid}`])( + 'rejects unsupported or malformed post IDs before requesting (%j)', + async (input) => { + const request = vi.spyOn(RequestUtil, 'HttpGetJson'); + + await expect(getQzoneFeedDetail(cookies, input)).rejects.toThrow('24 位十六进制'); + expect(request).not.toHaveBeenCalled(); + }, + ); + + it.each([ + {}, + { feed: null }, + { feed: [] }, + { feed: { feed: [] } }, + { feed: { feed: {} } }, + { feed: { feed: { cell_common: { union_id: { feed_id: 'other' } } } } }, + { ...detail, attach_info: {} }, + ])('rejects incomplete, mismatched or malformed details (%#)', async (data) => { + const request = vi + .spyOn(RequestUtil, 'HttpGetJson') + .mockResolvedValue({ retcode: 0, data }); + + await expect(getQzoneFeedDetail(cookies, tid)).rejects.toThrow('GetFeedDetail'); + expect(request).toHaveBeenCalledOnce(); + }); + + it('propagates access and transport failures without a second request', async () => { + const request = vi + .spyOn(RequestUtil, 'HttpGetJson') + .mockResolvedValueOnce({ retcode: 10023, message: '无权限访问' }) + .mockRejectedValueOnce(new Error('network down')); + + await expect(getQzoneFeedDetail(cookies, tid)).rejects.toThrow('10023 无权限访问'); + expect(request).toHaveBeenCalledTimes(1); + await expect(getQzoneFeedDetail(cookies, tid)).rejects.toThrow('network down'); + expect(request).toHaveBeenCalledTimes(2); + }); + + it('passes the returned feed straight to card generation without rebuilding sharing data', async () => { + const ark = '{ "app": "com.tencent.tuwen.lua", "fixture": true }'; + const request = vi + .spyOn(RequestUtil, 'HttpGetJson') + .mockResolvedValueOnce({ retcode: 0, data: detail }) + .mockResolvedValueOnce({ retcode: 0, data: { template_data: ark } }); + const result = await getQzoneFeedDetail(cookies, tid); + + await expect(getQzoneShareArk(cookies, result.feed)).resolves.toEqual({ + ark, + requires_confirmation: false, + notice: null, + }); + expect(request).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('/trpc.qq.share.Service/CreateShare?g_tk='), + 'POST', + { + info: { + busi_id: 'qzone_feed', + resource_id: 'resource', + template: 'ark_busi_select', + busi_data: 'original-transfer', + plat: 1, + target: 3, + }, + }, + expect.any(Object), + ); + }); + + it('can read a share-restricted feed while preserving the share refusal', async () => { + const restrictedFeed = { + ...feed, + cell_common: { ...feed.cell_common, operate_mask: 1 << 17 }, + }; + const request = vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue({ + retcode: 0, + data: { feed: { feed: restrictedFeed } }, + }); + const result = await getQzoneFeedDetail(cookies, tid); + + expect(result.feed).toBe(restrictedFeed); + await expect(getQzoneShareArk(cookies, result.feed)).rejects.toThrow('不支持分享'); + expect(request).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 4a74e916..4f28a22c 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -185,7 +185,17 @@ for (const comment of detail.comments) { `get_qzone_home_feeds` 读取自己的空间主页,可传 `target_uin` 访问有权限的其他主页;`cursor` 使用同一账号、同一主页上次返回的 `next_cursor`。它返回完整动态,旧版 `get_qzone_feeds` 和 `get_qzone_msg_list` 保持原有格式。 -把主页或推荐列表的完整动态传给 `get_qzone_share_ark`,可获得 `{ ark, requires_confirmation, notice }`。此动作会生成分享卡片但不发送消息;将 `ark` 原文交给普通 JSON 消息发送接口即可获得消息编号,继续使用已有撤回及发送事件能力。 +已有说说编号时,可调用 `get_qzone_feed_detail`,传入 `get_qzone_msg_list` 或 `send_qzone_msg` 返回的 24 位十六进制 `tid`。此接口读取普通空间说说,自动从编号取得作者和发布时间,不需要 `target_uin`;访问仍使用当前账号的权限。结果的 `feed` 是单个完整动态对象,保留图片、视频、分享信息和未知字段,无需再取 `.feed.feed`。其余上游详情字段也会保留,`attach_info` 为上游评论游标(未提供时为 `null`);只读取一次详情,不自动翻页,也不保证评论和回复完整。旧的 `get_qzone_msg_detail` 及其 `pos/num` 分页保持不变。 + +```ts +const detail = await bot.raw('get_qzone_feed_detail', { + tid: '1127000000f1536501000000', // 替换为实际说说编号 +}); +const card = await bot.raw('get_qzone_share_ark', { feed: detail.feed }); +// 若 card.requires_confirmation 为 true,发送前展示 card.notice 并取得确认。 +``` + +把详情、主页或推荐列表的完整动态传给 `get_qzone_share_ark`,可获得 `{ ark, requires_confirmation, notice }`。此动作会生成分享卡片但不发送消息;将 `ark` 原文交给普通 JSON 消息发送接口即可获得消息编号,继续使用已有撤回及发送事件能力。能读取不代表允许分享:分享受限或缺少上游分享信息时,分享接口会明确报错。 分享链接可能让未登录者也能访问动态,并包含作者 QQ 号。若 `requires_confirmation` 为 `true`,发送前应展示 `notice` 并取得确认;上游禁止分享或生成失败会返回错误。 diff --git a/packages/sdk/src/actions.ts b/packages/sdk/src/actions.ts index 793b62ec..fb05411b 100644 --- a/packages/sdk/src/actions.ts +++ b/packages/sdk/src/actions.ts @@ -185,6 +185,7 @@ export const SNOWLUMA_ACTIONS = [ 'get_qun_album_list', 'get_qzone_feeds', 'get_qzone_home_feeds', + 'get_qzone_feed_detail', 'get_qzone_msg_detail', 'get_qzone_msg_list', 'get_qzone_profile', diff --git a/packages/sdk/src/types/actions.ts b/packages/sdk/src/types/actions.ts index dc0e71bb..a093d948 100644 --- a/packages/sdk/src/types/actions.ts +++ b/packages/sdk/src/types/actions.ts @@ -104,6 +104,7 @@ import type { GetQzoneProfileParams, GetQzonePublicFeedsParams, GetQzoneHomeFeedsParams, + GetQzoneFeedDetailParams, GetQzoneShareArkParams, GetQzonePublicCommentsParams, GetQzonePublicRepliesParams, @@ -205,6 +206,7 @@ import type { QzonePublishResult, QzoneProfileResult, QzonePublicFeedsPage, + QzoneFeedDetailResult, QzoneShareArkResult, QzoneUpdateRightResult, RkeyServerResult, @@ -428,6 +430,7 @@ export interface SnowLumaActionMap { get_qzone_public_capabilities: { params: JsonObject; data: JsonObject }; get_qzone_public_feeds: { params: GetQzonePublicFeedsParams; data: QzonePublicFeedsPage }; get_qzone_home_feeds: { params: GetQzoneHomeFeedsParams; data: QzonePublicFeedsPage }; + get_qzone_feed_detail: { params: GetQzoneFeedDetailParams; data: QzoneFeedDetailResult }; get_qzone_share_ark: { params: GetQzoneShareArkParams; data: QzoneShareArkResult }; get_qzone_public_feed_detail: { params: QzonePublicFeedParams; data: JsonObject }; get_qzone_public_comments: { params: GetQzonePublicCommentsParams; data: JsonObject }; diff --git a/packages/sdk/src/types/params.ts b/packages/sdk/src/types/params.ts index c17cda46..2b006376 100644 --- a/packages/sdk/src/types/params.ts +++ b/packages/sdk/src/types/params.ts @@ -633,8 +633,13 @@ export interface GetQzoneHomeFeedsParams extends JsonObject { cursor?: string; } +export interface GetQzoneFeedDetailParams extends JsonObject { + /** 24 hexadecimal characters from get_qzone_msg_list or send_qzone_msg. */ + tid: string; +} + export interface GetQzoneShareArkParams extends JsonObject { - /** Original feed from get_qzone_home_feeds or get_qzone_public_feeds. */ + /** Original feed from get_qzone_feed_detail, get_qzone_home_feeds or get_qzone_public_feeds. */ feed: JsonObject; } diff --git a/packages/sdk/src/types/results.ts b/packages/sdk/src/types/results.ts index e8ce460b..f5383a94 100644 --- a/packages/sdk/src/types/results.ts +++ b/packages/sdk/src/types/results.ts @@ -422,6 +422,13 @@ export interface QzonePublicFeedsPage extends JsonObject { next_cursor: string | null; } +export interface QzoneFeedDetailResult extends JsonObject { + /** Original cells, suitable for get_qzone_share_ark when sharing is permitted. */ + feed: JsonObject; + /** Upstream comment continuation; null when none is supplied. No automatic pagination. */ + attach_info: string | null; +} + export interface QzoneShareArkResult extends JsonObject { /** Preserve this string when sending a JSON message. */ ark: string; diff --git a/packages/sdk/tests/fixtures/action-contracts.ts b/packages/sdk/tests/fixtures/action-contracts.ts index d1d13ede..ea3e4b00 100644 --- a/packages/sdk/tests/fixtures/action-contracts.ts +++ b/packages/sdk/tests/fixtures/action-contracts.ts @@ -97,6 +97,7 @@ export const actionParams = { get_qzone_profile: { target_uin: '10002' }, get_qzone_public_capabilities: {}, get_qzone_home_feeds: { target_uin: '10001', cursor: 'home-feed-cursor' }, + get_qzone_feed_detail: { tid: '1127000000f1536501000000' }, get_qzone_share_ark: { feed: qzonePublicFeed }, get_qzone_public_comments: { feed: qzonePublicFeed, cursor: 'comments-from-detail' }, get_qzone_public_feed_detail: { feed: qzonePublicFeed }, @@ -232,6 +233,7 @@ export type CoveredAction = | 'get_qzone_profile' | 'get_qzone_public_capabilities' | 'get_qzone_home_feeds' + | 'get_qzone_feed_detail' | 'get_qzone_share_ark' | 'get_qzone_public_comments' | 'get_qzone_public_feed_detail' @@ -469,6 +471,13 @@ export const actionResults = { get_qzone_profile: { name: 'Space', description: 'Description' }, get_qzone_public_capabilities: { public_qzone_entrance_switch: true, can_upvote: true }, get_qzone_home_feeds: { feeds: [qzonePublicFeed], has_more: false, next_cursor: null }, + get_qzone_feed_detail: { + feed: { + cell_common: { domain_type: 1, union_id: { feed_id: '1127000000f1536501000000' } }, + cell_pic: { future_image_field: true }, + }, + attach_info: 'comments-from-detail', + }, get_qzone_share_ark: { ark: '{"app":"com.tencent.tuwen.lua"}', requires_confirmation: false, notice: null }, get_qzone_public_comments: { cell_comment: { comments: [qzonePublicComment] }, diff --git a/packages/sdk/tests/fixtures/action-types.mts b/packages/sdk/tests/fixtures/action-types.mts index 1ef12609..2eeef4e1 100644 --- a/packages/sdk/tests/fixtures/action-types.mts +++ b/packages/sdk/tests/fixtures/action-types.mts @@ -164,6 +164,15 @@ export const invalidQzoneProfilePatch: ActionParams<'set_qzone_profile'> = { // @ts-expect-error profile fields accept string, number or boolean primitives description: [], }; +const feedDetail = await http.raw('get_qzone_feed_detail', actionParams.get_qzone_feed_detail); +export const fullQzoneFeed: JsonObject = feedDetail.feed; +export const feedDetailCursor: string | null = feedDetail.attach_info; +export const feedDetailCard = await http.raw('get_qzone_share_ark', { feed: feedDetail.feed }); +// @ts-expect-error querying a feed requires a post ID +http.raw('get_qzone_feed_detail', {}); +// @ts-expect-error the post ID is hexadecimal text, not an array +http.raw('get_qzone_feed_detail', { tid: [] }); + const publicPage = await http.raw('get_qzone_public_feeds', {}); export const publicCells: JsonObject[] = publicPage.feeds; export const publicHasMore: boolean = publicPage.has_more;