From eb119ecb920f1c46d43b2ac6cea004d0fb766305 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Fri, 30 Oct 2026 12:53:21 +0800 Subject: [PATCH] feat(qzone): add homepage feeds and share cards Read accessible Qzone homepage posts and generate their original share cards. Return sharing notices so callers can confirm visibility before sending through the existing message actions. Include SDK contracts, action discovery, documentation, and regression coverage. Fixes #609 --- packages/core/src/bridge/apis/qzone.ts | 13 ++ .../core/tests/actions/qzone-public.test.ts | 16 ++ packages/mcp/src/generated/catalog.json | 190 ++++++++++++++- packages/mcp/src/generated/catalog.ts | 190 ++++++++++++++- packages/onebot/src/actions/qzone-public.ts | 76 ++++++ .../onebot/tests/qzone-public-actions.test.ts | 14 ++ packages/protocol/src/web/qzone-public.ts | 216 +++++++++++++++++- .../protocol/tests/web/qzone-share.test.ts | 152 ++++++++++++ packages/sdk/README.md | 15 ++ packages/sdk/src/actions.ts | 2 + packages/sdk/src/types/actions.ts | 5 + packages/sdk/src/types/params.ts | 11 + packages/sdk/src/types/results.ts | 8 + .../sdk/tests/fixtures/action-contracts.ts | 6 + packages/sdk/tests/qzone-public.test.ts | 34 ++- 15 files changed, 944 insertions(+), 4 deletions(-) create mode 100644 packages/protocol/tests/web/qzone-share.test.ts diff --git a/packages/core/src/bridge/apis/qzone.ts b/packages/core/src/bridge/apis/qzone.ts index a79cfecc..9fe8e906 100644 --- a/packages/core/src/bridge/apis/qzone.ts +++ b/packages/core/src/bridge/apis/qzone.ts @@ -26,6 +26,9 @@ import { } from '@snowluma/protocol/web/qzone'; import { getQzonePublicCapabilities, + getQzoneHomeFeeds, + getQzoneShareArk, + type QzoneShareArk, getQzonePublicFeeds, getQzonePublicFeedDetail, getQzonePublicComments, @@ -73,6 +76,16 @@ export class QzoneApi { return getQzonePublicFeeds(cookies, this.ctx.identity.uin, cursor); } + async getHomeFeeds(targetUin?: number, cursor?: string): Promise { + const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com'); + return getQzoneHomeFeeds(cookies, this.ctx.identity.uin, targetUin?.toString(), cursor); + } + + async getShareArk(feed: JsonObject): Promise { + const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com'); + return getQzoneShareArk(cookies, feed); + } + async getPublicFeedDetail(feed: JsonObject): Promise { const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com'); return getQzonePublicFeedDetail(cookies, feed); diff --git a/packages/core/tests/actions/qzone-public.test.ts b/packages/core/tests/actions/qzone-public.test.ts index 584cff16..680b9dc7 100644 --- a/packages/core/tests/actions/qzone-public.test.ts +++ b/packages/core/tests/actions/qzone-public.test.ts @@ -21,6 +21,11 @@ function makeApi() { } const cases = [ + { + method: 'getQzoneHomeFeeds', + invoke: (api: QzoneApi) => api.getHomeFeeds(20002, 'cursor'), + args: [cookies, '10001', '20002', 'cursor'], + }, { method: 'getQzonePublicCapabilities', invoke: (api: QzoneApi) => api.getPublicCapabilities(), @@ -96,6 +101,17 @@ const cases = [ describe('apis/qzone public', () => { afterEach(() => vi.restoreAllMocks()); + 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: '所有人可见' }; + const request = vi.spyOn(qzonePublic, 'getQzoneShareArk').mockResolvedValue(card); + expect(await api.getShareArk(feed)).toBe(card); + expect(getCookies).toHaveBeenCalledWith('qzone.qq.com'); + expect(request).toHaveBeenCalledExactlyOnceWith(cookies, feed); + request.mockRejectedValueOnce(new Error('share denied')); + await expect(api.getShareArk(feed)).rejects.toThrow('share denied'); + }); + it.each(cases)( '$method uses current-account cookies and preserves upstream objects', async ({ method, invoke, args }) => { diff --git a/packages/mcp/src/generated/catalog.json b/packages/mcp/src/generated/catalog.json index 23b1ef07..23918d25 100644 --- a/packages/mcp/src/generated/catalog.json +++ b/packages/mcp/src/generated/catalog.json @@ -11442,6 +11442,98 @@ }, "category": "空间" }, + { + "name": "get_qzone_home_feeds", + "aliases": [], + "summary": "读取自己或有权访问的空间主页动态,保留完整分享信息", + "returns": "完整动态及分页状态;访问受空间权限限制,旧说说接口保持不变。", + "returnsSchema": { + "type": "object", + "additionalProperties": true, + "properties": { + "feeds": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + }, + "description": "完整动态,可原样传给 get_qzone_share_ark" + }, + "has_more": { + "type": "boolean" + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "feeds", + "has_more", + "next_cursor" + ] + }, + "examples": [ + { + "summary": "读取自己的主页动态", + "params": {} + }, + { + "summary": "继续读取同一主页", + "params": { + "target_uin": 10001, + "cursor": "" + } + } + ], + "readOnly": true, + "params": [ + { + "name": "target_uin", + "type": "uint", + "required": false, + "role": "user_id", + "schema": { + "type": "integer", + "minimum": 1 + }, + "desc": "主页所属 QQ 号,省略则为当前账号" + }, + { + "name": "cursor", + "type": "string", + "required": false, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + }, + "desc": "上次返回的 next_cursor,仅限同一账号及主页" + } + ], + "invariants": [], + "inputSchema": { + "type": "object", + "properties": { + "target_uin": { + "type": "integer", + "minimum": 1, + "description": "主页所属 QQ 号,省略则为当前账号", + "x-role": "user_id" + }, + "cursor": { + "type": "string", + "minLength": 1, + "maxLength": 65536, + "description": "上次返回的 next_cursor,仅限同一账号及主页" + } + }, + "additionalProperties": true + }, + "category": "空间" + }, { "name": "get_qzone_msg_detail", "aliases": [], @@ -12230,6 +12322,102 @@ }, "category": "空间" }, + { + "name": "get_qzone_share_ark", + "aliases": [], + "summary": "生成空间动态分享卡片,返回原文及分享提示", + "returns": "生成卡片但不发送消息;后续发送仍返回普通消息编号。", + "returnsSchema": { + "type": "object", + "properties": { + "ark": { + "type": "string", + "description": "卡片原文,直接作为 JSON 消息的 data" + }, + "requires_confirmation": { + "type": "boolean", + "description": "上游要求发送前确认分享提示" + }, + "notice": { + "type": [ + "string", + "null" + ], + "description": "上游分享提示;无提示时为 null" + } + }, + "required": [ + "ark", + "requires_confirmation", + "notice" + ] + }, + "examples": [ + { + "summary": "生成卡片后通过 JSON 消息发送", + "params": { + "feed": { + "cell_common": { + "union_id": { + "uf_key": "", + "ugc_id": "", + "feed_id": "", + "ext_ids": [] + }, + "cur_like_key": "", + "domain_type": 3 + }, + "cell_feed_transfer": "", + "cell_user_info": { + "user": { + "uin": "10001", + "uid": "", + "nick": "" + } + }, + "cell_operation": { + "share_outside": { + "share_access_resource": { + "busi_id": "", + "resource_id": "" + } + } + } + } + }, + "notes": "feed 原样取自 get_qzone_home_feeds 或 get_qzone_public_feeds。分享链接可能允许未登录者访问并包含作者 QQ 号;如 requires_confirmation=true,发送前展示 notice 并取得确认。将 ark 原文作为 JSON 消息发送,不要修改卡片内容。" + } + ], + "readOnly": false, + "params": [ + { + "name": "feed", + "type": "object", + "required": true, + "schema": { + "type": "object", + "additionalProperties": true + }, + "desc": "主页或推荐列表返回的完整动态,保留所有原始字段" + } + ], + "invariants": [], + "inputSchema": { + "type": "object", + "properties": { + "feed": { + "type": "object", + "additionalProperties": true, + "description": "主页或推荐列表返回的完整动态,保留所有原始字段" + } + }, + "required": [ + "feed" + ], + "additionalProperties": true + }, + "category": "空间" + }, { "name": "get_recent_contact", "aliases": [], @@ -20765,7 +20953,7 @@ }, { "category": "空间", - "count": 26 + "count": 28 }, { "category": "系统表情", diff --git a/packages/mcp/src/generated/catalog.ts b/packages/mcp/src/generated/catalog.ts index 24a66906..46e38314 100644 --- a/packages/mcp/src/generated/catalog.ts +++ b/packages/mcp/src/generated/catalog.ts @@ -11445,6 +11445,98 @@ export const ACTIONS: CatalogAction[] = [ }, "category": "空间" }, + { + "name": "get_qzone_home_feeds", + "aliases": [], + "summary": "读取自己或有权访问的空间主页动态,保留完整分享信息", + "returns": "完整动态及分页状态;访问受空间权限限制,旧说说接口保持不变。", + "returnsSchema": { + "type": "object", + "additionalProperties": true, + "properties": { + "feeds": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + }, + "description": "完整动态,可原样传给 get_qzone_share_ark" + }, + "has_more": { + "type": "boolean" + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "feeds", + "has_more", + "next_cursor" + ] + }, + "examples": [ + { + "summary": "读取自己的主页动态", + "params": {} + }, + { + "summary": "继续读取同一主页", + "params": { + "target_uin": 10001, + "cursor": "" + } + } + ], + "readOnly": true, + "params": [ + { + "name": "target_uin", + "type": "uint", + "required": false, + "role": "user_id", + "schema": { + "type": "integer", + "minimum": 1 + }, + "desc": "主页所属 QQ 号,省略则为当前账号" + }, + { + "name": "cursor", + "type": "string", + "required": false, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + }, + "desc": "上次返回的 next_cursor,仅限同一账号及主页" + } + ], + "invariants": [], + "inputSchema": { + "type": "object", + "properties": { + "target_uin": { + "type": "integer", + "minimum": 1, + "description": "主页所属 QQ 号,省略则为当前账号", + "x-role": "user_id" + }, + "cursor": { + "type": "string", + "minLength": 1, + "maxLength": 65536, + "description": "上次返回的 next_cursor,仅限同一账号及主页" + } + }, + "additionalProperties": true + }, + "category": "空间" + }, { "name": "get_qzone_msg_detail", "aliases": [], @@ -12233,6 +12325,102 @@ export const ACTIONS: CatalogAction[] = [ }, "category": "空间" }, + { + "name": "get_qzone_share_ark", + "aliases": [], + "summary": "生成空间动态分享卡片,返回原文及分享提示", + "returns": "生成卡片但不发送消息;后续发送仍返回普通消息编号。", + "returnsSchema": { + "type": "object", + "properties": { + "ark": { + "type": "string", + "description": "卡片原文,直接作为 JSON 消息的 data" + }, + "requires_confirmation": { + "type": "boolean", + "description": "上游要求发送前确认分享提示" + }, + "notice": { + "type": [ + "string", + "null" + ], + "description": "上游分享提示;无提示时为 null" + } + }, + "required": [ + "ark", + "requires_confirmation", + "notice" + ] + }, + "examples": [ + { + "summary": "生成卡片后通过 JSON 消息发送", + "params": { + "feed": { + "cell_common": { + "union_id": { + "uf_key": "", + "ugc_id": "", + "feed_id": "", + "ext_ids": [] + }, + "cur_like_key": "", + "domain_type": 3 + }, + "cell_feed_transfer": "", + "cell_user_info": { + "user": { + "uin": "10001", + "uid": "", + "nick": "" + } + }, + "cell_operation": { + "share_outside": { + "share_access_resource": { + "busi_id": "", + "resource_id": "" + } + } + } + } + }, + "notes": "feed 原样取自 get_qzone_home_feeds 或 get_qzone_public_feeds。分享链接可能允许未登录者访问并包含作者 QQ 号;如 requires_confirmation=true,发送前展示 notice 并取得确认。将 ark 原文作为 JSON 消息发送,不要修改卡片内容。" + } + ], + "readOnly": false, + "params": [ + { + "name": "feed", + "type": "object", + "required": true, + "schema": { + "type": "object", + "additionalProperties": true + }, + "desc": "主页或推荐列表返回的完整动态,保留所有原始字段" + } + ], + "invariants": [], + "inputSchema": { + "type": "object", + "properties": { + "feed": { + "type": "object", + "additionalProperties": true, + "description": "主页或推荐列表返回的完整动态,保留所有原始字段" + } + }, + "required": [ + "feed" + ], + "additionalProperties": true + }, + "category": "空间" + }, { "name": "get_recent_contact", "aliases": [], @@ -20769,7 +20957,7 @@ export const CATEGORIES: CatalogCategory[] = [ }, { "category": "空间", - "count": 26 + "count": 28 }, { "category": "系统表情", diff --git a/packages/onebot/src/actions/qzone-public.ts b/packages/onebot/src/actions/qzone-public.ts index b431f091..6ce1feb4 100644 --- a/packages/onebot/src/actions/qzone-public.ts +++ b/packages/onebot/src/actions/qzone-public.ts @@ -43,6 +43,82 @@ const replyExample: JsonObject = { }; export const actions = [ + defineAction({ + name: 'get_qzone_home_feeds', + summary: '读取自己或有权访问的空间主页动态,保留完整分享信息', + readOnly: true, + examples: [ + { summary: '读取自己的主页动态', params: {} }, + { + summary: '继续读取同一主页', + params: { target_uin: 10001, cursor: '' }, + }, + ], + returns: '完整动态及分页状态;访问受空间权限限制,旧说说接口保持不变。', + returnsSchema: { + type: 'object', + additionalProperties: true, + properties: { + feeds: { + type: 'array', + items: objectResult, + description: '完整动态,可原样传给 get_qzone_share_ark', + }, + has_more: { type: 'boolean' }, + next_cursor: { type: ['string', 'null'] }, + }, + required: ['feeds', 'has_more', 'next_cursor'], + }, + params: { + target_uin: f.userId().describe('主页所属 QQ 号,省略则为当前账号').optional(), + cursor: f + .string({ allowEmpty: false, maxLen: 65536 }) + .describe('上次返回的 next_cursor,仅限同一账号及主页') + .optional(), + }, + run: async (p, ctx) => + okResponse(await ctx.bridge.apis.qzone.getHomeFeeds(p.target_uin, p.cursor)), + }), + defineAction({ + name: 'get_qzone_share_ark', + summary: '生成空间动态分享卡片,返回原文及分享提示', + readOnly: false, + examples: [ + { + summary: '生成卡片后通过 JSON 消息发送', + params: { + feed: { + ...feedExample, + cell_feed_transfer: '', + cell_operation: { + share_outside: { + share_access_resource: { + busi_id: '', + resource_id: '', + }, + }, + }, + }, + }, + notes: 'feed 原样取自 get_qzone_home_feeds 或 get_qzone_public_feeds。分享链接可能允许未登录者访问并包含作者 QQ 号;如 requires_confirmation=true,发送前展示 notice 并取得确认。将 ark 原文作为 JSON 消息发送,不要修改卡片内容。', + }, + ], + returns: '生成卡片但不发送消息;后续发送仍返回普通消息编号。', + returnsSchema: { + type: 'object', + properties: { + ark: { type: 'string', description: '卡片原文,直接作为 JSON 消息的 data' }, + requires_confirmation: { + type: 'boolean', + description: '上游要求发送前确认分享提示', + }, + notice: { type: ['string', 'null'], description: '上游分享提示;无提示时为 null' }, + }, + required: ['ark', 'requires_confirmation', 'notice'], + }, + params: { feed: f.object().describe('主页或推荐列表返回的完整动态,保留所有原始字段') }, + run: async (p, ctx) => okResponse(await ctx.bridge.apis.qzone.getShareArk(p.feed)), + }), defineAction({ name: 'get_qzone_public_capabilities', summary: '读取当前账号的空友爱看入口与推权限', diff --git a/packages/onebot/tests/qzone-public-actions.test.ts b/packages/onebot/tests/qzone-public-actions.test.ts index ed7e28df..2c5216fc 100644 --- a/packages/onebot/tests/qzone-public-actions.test.ts +++ b/packages/onebot/tests/qzone-public-actions.test.ts @@ -33,6 +33,20 @@ const cases: { args: unknown[]; readOnly: boolean; }[] = [ + { + action: 'get_qzone_home_feeds', + method: 'getHomeFeeds', + params: { target_uin: '10001', cursor: 'cursor' }, + args: [10001, 'cursor'], + readOnly: true, + }, + { + action: 'get_qzone_share_ark', + method: 'getShareArk', + params: { feed }, + args: [feed], + readOnly: false, + }, { action: 'get_qzone_public_capabilities', method: 'getPublicCapabilities', diff --git a/packages/protocol/src/web/qzone-public.ts b/packages/protocol/src/web/qzone-public.ts index 3983a1fc..551bd69a 100644 --- a/packages/protocol/src/web/qzone-public.ts +++ b/packages/protocol/src/web/qzone-public.ts @@ -9,6 +9,20 @@ export interface QzonePublicFeedsPage extends JsonObject { next_cursor: string | null; } +export interface QzoneShareArk extends JsonObject { + ark: string; + requires_confirmation: boolean; + notice: string | null; +} + +interface HomeFeedCursor { + version: 1; + kind: 'home'; + uin: string; + host_uin: string; + attach_info: string; +} + interface PublicFeedCursor { version: 1; uin: string; @@ -75,7 +89,7 @@ function feedParams(feed: JsonObject): JsonObject { }; } -function encodeCursor(state: PublicFeedCursor): string { +function encodeCursor(state: PublicFeedCursor | HomeFeedCursor): string { const cursor = Buffer.from(JSON.stringify(state)).toString('base64url'); if (cursor.length > 65_536) { @@ -150,6 +164,206 @@ export async function getQzonePublicCapabilities( return result.entrances; } +export async function getQzoneHomeFeeds( + cookies: Record, + selfUin: string, + targetUin = selfUin, + cursor?: string, +): Promise { + let attachInfo = ''; + + if (cursor !== undefined) { + if (!cursor || cursor.length > 65_536 || !/^[A-Za-z0-9_-]+$/.test(cursor)) { + throw new Error('无效的空间主页 cursor'); + } + + const bytes = Buffer.from(cursor, 'base64url'); + const value: unknown = JSON.parse(bytes.toString('utf8')); + + if ( + bytes.toString('base64url') !== cursor || + !isObject(value) || + value.version !== 1 || + value.kind !== 'home' || + value.uin !== selfUin || + value.host_uin !== targetUin || + typeof value.attach_info !== 'string' || + !value.attach_info + ) { + throw new Error('空间主页 cursor 无效或不属于当前账号及主页'); + } + + attachInfo = value.attach_info; + } + + const data = await request(cookies, reader, 'GetMainPageFeeds', '0x9794_2', { + host_uid: '', + host_uin: targetUin, + attach_info: attachInfo, + refresh_attach_info: '', + feed_action_domain_type: 1, + }); + + if (!Array.isArray(data.feeds) || typeof data.has_more !== 'boolean') { + throw new Error('空间主页动态分页响应异常'); + } + + const feeds = data.feeds.map((entry) => { + if (!isObject(entry) || !isObject(entry.feed)) { + throw new Error('空间主页动态响应异常'); + } + + return entry.feed; + }); + let nextCursor: string | null = null; + + if (data.has_more) { + if ( + typeof data.attach_info !== 'string' || + !data.attach_info || + data.attach_info === attachInfo + ) { + throw new Error('空间主页动态分页未推进'); + } + + nextCursor = encodeCursor({ + version: 1, + kind: 'home', + uin: selfUin, + host_uin: targetUin, + attach_info: data.attach_info, + }); + } + + return { ...data, feeds, has_more: data.has_more, next_cursor: nextCursor }; +} + +export async function getQzoneShareArk( + cookies: Record, + feed: JsonObject, +): Promise { + const operation = feed.cell_operation; + const share = isObject(operation) ? operation.share_outside : undefined; + const resource = isObject(share) ? share.share_access_resource : undefined; + const common = feed.cell_common; + const mask = isObject(common) ? common.operate_mask : undefined; + + if ( + mask !== undefined && + (typeof mask !== 'number' || !Number.isSafeInteger(mask) || mask < 0) + ) { + throw new Error('空间动态分享权限数据异常'); + } + + if (typeof mask === 'number' && (mask & (1 << 17)) !== 0) { + const reason = + isObject(share) && typeof share.share_out_button_toast === 'string' + ? share.share_out_button_toast + : '该空间动态暂不支持分享'; + throw new Error(reason); + } + + if ( + !isObject(resource) || + typeof resource.busi_id !== 'string' || + !resource.busi_id || + typeof resource.resource_id !== 'string' || + !resource.resource_id || + typeof feed.cell_feed_transfer !== 'string' || + !feed.cell_feed_transfer + ) { + throw new Error('空间动态缺少分享信息,请传入主页或推荐列表返回的完整动态'); + } + + const data = await request(cookies, 'trpc.qq.share.Service', 'CreateShare', '0x9409_1', { + info: { + busi_id: resource.busi_id, + resource_id: resource.resource_id, + template: 'ark_busi_select', + busi_data: feed.cell_feed_transfer, + plat: 1, + target: 3, + }, + }); + + if (data.err !== undefined) { + if ( + !isObject(data.err) || + (data.err.code !== undefined && typeof data.err.code !== 'number') + ) { + throw new Error('空间分享业务响应异常'); + } + + if (data.err.code !== undefined && data.err.code !== 0) { + const message = typeof data.err.msg === 'string' ? data.err.msg : ''; + throw new Error(`空间分享失败: ${data.err.code} ${message}`.trim()); + } + } + + if (typeof data.template_data !== 'string' || !data.template_data.trim()) { + throw new Error('空间分享未返回卡片'); + } + + const ark: unknown = JSON.parse(data.template_data); + + if (!isObject(ark)) { + throw new Error('空间分享返回的卡片格式异常'); + } + + let requiresConfirmation = false; + let notice: string | null = null; + + if (data.info !== undefined) { + if (!isObject(data.info)) { + throw new Error('空间分享提示格式异常'); + } + + const encoded = data.info.busi_data; + + if (encoded !== undefined && encoded !== '') { + if (typeof encoded !== 'string' || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) { + throw new Error('空间分享提示编码异常'); + } + + const decoded = Buffer.from(encoded, 'base64'); + + if (decoded.toString('base64').replace(/=+$/, '') !== encoded.replace(/=+$/, '')) { + throw new Error('空间分享提示编码异常'); + } + + const permission: unknown = JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode(decoded), + ); + + if (permission !== null) { + if ( + !isObject(permission) || + (permission.need_break !== undefined && + typeof permission.need_break !== 'boolean') + ) { + throw new Error('空间分享提示格式异常'); + } + + requiresConfirmation = permission.need_break === true; + + if ( + isObject(permission.tips) && + typeof permission.tips.content === 'string' && + permission.tips.content + ) { + notice = permission.tips.content; + } + + if (requiresConfirmation && !notice) { + notice = '你的空间有权限,分享出去的页面所有人都可以看。'; + } + } + } + } + + return { ark: data.template_data, requires_confirmation: requiresConfirmation, notice }; +} + export async function getQzonePublicFeeds( cookies: Record, selfUin: string, diff --git a/packages/protocol/tests/web/qzone-share.test.ts b/packages/protocol/tests/web/qzone-share.test.ts new file mode 100644 index 00000000..ee1be4f0 --- /dev/null +++ b/packages/protocol/tests/web/qzone-share.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getQzoneHomeFeeds, getQzoneShareArk } from '../../src/web/qzone-public'; +import { RequestUtil } from '../../src/web/request-util'; + +const cookies = { p_skey: 'test', uin: 'o10001' }; +const feed = { + cell_common: { union_id: { feed_id: 'feed-id' }, operate_mask: 0 }, + cell_operation: { + share_outside: { share_access_resource: { busi_id: 'qzone_feed', resource_id: 'opaque' } }, + }, + cell_feed_transfer: 'original-transfer', + unknown: { keep: true }, +}; +const ark = '{ "app": "com.tencent.tuwen.lua", "config": {"token":"fixture"} }'; +const request = (data: unknown) => + vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue({ retcode: 0, data }); + +afterEach(() => vi.restoreAllMocks()); + +describe('Qzone share card', () => { + it('generates the original card and returns the upstream confirmation notice', async () => { + const req = request({ + template_data: ark, + err: { code: 0 }, + info: { + busi_data: Buffer.from( + JSON.stringify({ need_break: true, tips: { content: '分享后所有人可见' } }), + ).toString('base64'), + }, + }); + await expect(getQzoneShareArk(cookies, feed)).resolves.toEqual({ + ark, + requires_confirmation: true, + notice: '分享后所有人可见', + }); + expect(req).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining('/trpc.qq.share.Service/CreateShare?g_tk='), + 'POST', + { + info: { + busi_id: 'qzone_feed', + resource_id: 'opaque', + template: 'ark_busi_select', + busi_data: 'original-transfer', + plat: 1, + target: 3, + }, + }, + expect.objectContaining({ + 'x-oidb': JSON.stringify({ uint32_command: '0x9409', uint32_service_type: '1' }), + Cookie: 'p_skey=test; uin=o10001', + }), + ); + }); + + it('preserves a valid card without a confirmation notice', async () => { + request({ template_data: ark, err: {} }); + await expect(getQzoneShareArk(cookies, feed)).resolves.toEqual({ + ark, + requires_confirmation: false, + notice: null, + }); + }); + + it('rejects unavailable sharing and incomplete feeds before requesting a card', async () => { + const req = request({ template_data: ark }); + await expect( + getQzoneShareArk(cookies, { ...feed, cell_common: { operate_mask: 1 << 17 } }), + ).rejects.toThrow('不支持分享'); + await expect( + getQzoneShareArk(cookies, { ...feed, cell_feed_transfer: '' }), + ).rejects.toThrow('缺少分享信息'); + await expect(getQzoneShareArk(cookies, {})).rejects.toThrow('缺少分享信息'); + expect(req).not.toHaveBeenCalled(); + }); + + it.each([ + [{ err: { code: 3, msg: '参数错误' }, template_data: ark }, '3 参数错误'], + [{ err: { code: '0' }, template_data: ark }, '业务响应异常'], + [{}, '未返回卡片'], + [{ template_data: '[]' }, '卡片格式异常'], + [{ template_data: ark, info: { busi_data: '!' } }, '提示编码异常'], + [{ template_data: ark, info: { busi_data: Buffer.from('{').toString('base64') } }, 'JSON'], + ])('surfaces rejected or malformed share responses', async (data, error) => { + request(data); + await expect(getQzoneShareArk(cookies, feed)).rejects.toThrow(error); + }); + + it('propagates access errors without a retry', async () => { + const req = request(null).mockResolvedValue({ retcode: 10023, message: '无权限访问' }); + await expect(getQzoneShareArk(cookies, feed)).rejects.toThrow('10023 无权限访问'); + expect(req).toHaveBeenCalledOnce(); + }); +}); + +describe('Qzone homepage feeds', () => { + it('reads own homepage and preserves full cells across explicit pagination', async () => { + const req = request({ feeds: [{ feed }], has_more: true, attach_info: 'next', extra: 1 }); + const first = await getQzoneHomeFeeds(cookies, '10001'); + expect(first).toMatchObject({ feeds: [feed], has_more: true, extra: 1 }); + expect(req.mock.calls[0]![2]).toEqual({ + host_uid: '', + host_uin: '10001', + attach_info: '', + refresh_attach_info: '', + feed_action_domain_type: 1, + }); + req.mockResolvedValueOnce({ retcode: 0, data: { feeds: [], has_more: false } }); + await expect( + getQzoneHomeFeeds(cookies, '10001', undefined, first.next_cursor!), + ).resolves.toEqual({ feeds: [], has_more: false, next_cursor: null }); + expect(req.mock.calls[1]![2]).toMatchObject({ attach_info: 'next' }); + }); + + it('rejects a cursor reused for another account, homepage or stream', async () => { + const req = request({ feeds: [{ feed }], has_more: true, attach_info: 'next' }); + const first = await getQzoneHomeFeeds(cookies, '10001'); + req.mockClear(); + + for (const [self, host, cursor] of [ + ['20002', '10001', first.next_cursor!], + ['10001', '20002', first.next_cursor!], + [ + '10001', + '10001', + Buffer.from( + JSON.stringify({ + version: 1, + uin: '10001', + attach_info: 'next', + refresh_id: 'x', + }), + ).toString('base64url'), + ], + ]) { + await expect(getQzoneHomeFeeds(cookies, self!, host!, cursor!)).rejects.toThrow( + 'cursor', + ); + } + + expect(req).not.toHaveBeenCalled(); + }); + + it('surfaces missing or stalled pagination and permission failures', async () => { + const req = request({ feeds: [], has_more: true }); + await expect(getQzoneHomeFeeds(cookies, '10001')).rejects.toThrow('分页未推进'); + req.mockResolvedValue({ retcode: 10023, message: '无权限访问' }); + await expect(getQzoneHomeFeeds(cookies, '10001', '20002')).rejects.toThrow( + '10023 无权限访问', + ); + }); +}); diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 655a9572..4a74e916 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -183,6 +183,21 @@ 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 消息发送接口即可获得消息编号,继续使用已有撤回及发送事件能力。 + +分享链接可能让未登录者也能访问动态,并包含作者 QQ 号。若 `requires_confirmation` 为 `true`,发送前应展示 `notice` 并取得确认;上游禁止分享或生成失败会返回错误。 + +```ts +const page = await bot.raw('get_qzone_home_feeds', {}); +if (page.feeds[0]) { + const card = await bot.raw('get_qzone_share_ark', { feed: page.feeds[0] }); + // 先按 requires_confirmation / notice 确认分享范围,再由调用方决定是否发送。 + // 发送时使用 [{ type: 'json', data: { data: card.ark } }],保持卡片原文。 +} +``` + 公共空间动作使用推荐域 `3` 的 JSON 接口,与上面的旧空间 CGI 动作分开。先读取 `get_qzone_public_feeds`,再把返回的完整 `feed` 对象传给详情和互动动作,保留其中的身份、传递数据及未知字段;不要用旧说说 `tid` 拼造。 | 动作 | 主要参数 | diff --git a/packages/sdk/src/actions.ts b/packages/sdk/src/actions.ts index 4727fdc4..793b62ec 100644 --- a/packages/sdk/src/actions.ts +++ b/packages/sdk/src/actions.ts @@ -184,6 +184,7 @@ export const SNOWLUMA_ACTIONS = [ 'get_msg_emoji_likes', 'get_qun_album_list', 'get_qzone_feeds', + 'get_qzone_home_feeds', 'get_qzone_msg_detail', 'get_qzone_msg_list', 'get_qzone_profile', @@ -192,6 +193,7 @@ export const SNOWLUMA_ACTIONS = [ 'get_qzone_public_feed_detail', 'get_qzone_public_feeds', 'get_qzone_public_replies', + 'get_qzone_share_ark', 'get_rkey_server', 'get_share_link', 'like_qzone', diff --git a/packages/sdk/src/types/actions.ts b/packages/sdk/src/types/actions.ts index 3d6e5c38..dc0e71bb 100644 --- a/packages/sdk/src/types/actions.ts +++ b/packages/sdk/src/types/actions.ts @@ -103,6 +103,8 @@ import type { GetQzoneMsgListParams, GetQzoneProfileParams, GetQzonePublicFeedsParams, + GetQzoneHomeFeedsParams, + GetQzoneShareArkParams, GetQzonePublicCommentsParams, GetQzonePublicRepliesParams, GroupAlbumCommentParams, @@ -203,6 +205,7 @@ import type { QzonePublishResult, QzoneProfileResult, QzonePublicFeedsPage, + QzoneShareArkResult, QzoneUpdateRightResult, RkeyServerResult, SendFlashMsgResult, @@ -424,6 +427,8 @@ export interface SnowLumaActionMap { get_qzone_profile: { params: GetQzoneProfileParams; data: QzoneProfileResult }; 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_share_ark: { params: GetQzoneShareArkParams; data: QzoneShareArkResult }; get_qzone_public_feed_detail: { params: QzonePublicFeedParams; data: JsonObject }; get_qzone_public_comments: { params: GetQzonePublicCommentsParams; data: JsonObject }; get_qzone_public_replies: { params: GetQzonePublicRepliesParams; data: JsonObject }; diff --git a/packages/sdk/src/types/params.ts b/packages/sdk/src/types/params.ts index 96a9b74d..c17cda46 100644 --- a/packages/sdk/src/types/params.ts +++ b/packages/sdk/src/types/params.ts @@ -627,6 +627,17 @@ export interface GetQzonePublicFeedsParams extends JsonObject { cursor?: string; } +export interface GetQzoneHomeFeedsParams extends JsonObject { + target_uin?: ActionNumberInput; + /** Continuation for the same account and target homepage. */ + cursor?: string; +} + +export interface GetQzoneShareArkParams extends JsonObject { + /** Original feed from get_qzone_home_feeds or get_qzone_public_feeds. */ + feed: JsonObject; +} + export interface QzonePublicFeedParams extends JsonObject { /** The original feed cells returned in get_qzone_public_feeds.feeds. */ feed: JsonObject; diff --git a/packages/sdk/src/types/results.ts b/packages/sdk/src/types/results.ts index 2270593c..e8ce460b 100644 --- a/packages/sdk/src/types/results.ts +++ b/packages/sdk/src/types/results.ts @@ -422,6 +422,14 @@ export interface QzonePublicFeedsPage extends JsonObject { next_cursor: string | null; } +export interface QzoneShareArkResult extends JsonObject { + /** Preserve this string when sending a JSON message. */ + ark: string; + /** Display notice and obtain confirmation before sending when true. */ + requires_confirmation: boolean; + notice: string | null; +} + export interface QzoneFeed extends JsonObject { uin: number; nickname: string; diff --git a/packages/sdk/tests/fixtures/action-contracts.ts b/packages/sdk/tests/fixtures/action-contracts.ts index e21d309c..d1d13ede 100644 --- a/packages/sdk/tests/fixtures/action-contracts.ts +++ b/packages/sdk/tests/fixtures/action-contracts.ts @@ -96,6 +96,8 @@ export const actionParams = { get_qzone_msg_list: { target_uin: '10002', pos: '0', num: '20' }, get_qzone_profile: { target_uin: '10002' }, get_qzone_public_capabilities: {}, + get_qzone_home_feeds: { target_uin: '10001', cursor: 'home-feed-cursor' }, + get_qzone_share_ark: { feed: qzonePublicFeed }, get_qzone_public_comments: { feed: qzonePublicFeed, cursor: 'comments-from-detail' }, get_qzone_public_feed_detail: { feed: qzonePublicFeed }, get_qzone_public_feeds: { cursor: 'public-feed-cursor' }, @@ -229,6 +231,8 @@ export type CoveredAction = | 'get_qzone_msg_list' | 'get_qzone_profile' | 'get_qzone_public_capabilities' + | 'get_qzone_home_feeds' + | 'get_qzone_share_ark' | 'get_qzone_public_comments' | 'get_qzone_public_feed_detail' | 'get_qzone_public_feeds' @@ -464,6 +468,8 @@ 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_share_ark: { ark: '{"app":"com.tencent.tuwen.lua"}', requires_confirmation: false, notice: null }, get_qzone_public_comments: { cell_comment: { comments: [qzonePublicComment] }, attach_info: 'next-comments', diff --git a/packages/sdk/tests/qzone-public.test.ts b/packages/sdk/tests/qzone-public.test.ts index 20518cdf..5bc77294 100644 --- a/packages/sdk/tests/qzone-public.test.ts +++ b/packages/sdk/tests/qzone-public.test.ts @@ -1,5 +1,10 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest'; -import { SnowLumaHttpClient, type JsonObject, type QzonePublicFeedsPage } from '../src'; +import { + SnowLumaHttpClient, + type JsonObject, + type QzonePublicFeedsPage, + type QzoneShareArkResult, +} from '../src'; const feed: JsonObject = { cell_common: { @@ -11,6 +16,33 @@ const feed: JsonObject = { }; describe('public Qzone raw actions', () => { + it('exposes homepage and card types without automatically sending the card', async () => { + const page: QzonePublicFeedsPage = { feeds: [feed], has_more: false, next_cursor: null }; + const card: QzoneShareArkResult = { + ark: '{ "app": "com.tencent.tuwen.lua" }', + requires_confirmation: true, + notice: '所有人可见', + }; + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ status: 'ok', retcode: 0, data: page })), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ status: 'ok', retcode: 0, data: card })), + ); + const client = new SnowLumaHttpClient({ fetch: fetchImpl }); + const homepage = await client.raw('get_qzone_home_feeds', {}); + expectTypeOf(homepage).toEqualTypeOf(); + const result = await client.raw('get_qzone_share_ark', { feed: homepage.feeds[0]! }); + expectTypeOf(result).toEqualTypeOf(); + expect(result).toEqual(card); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(JSON.parse(fetchImpl.mock.calls[1]![1].body)).toEqual({ + action: 'get_qzone_share_ark', + params: { feed }, + }); + }); it('preserves original feed cells and follows an explicit cursor after a short page', async () => { const first: QzonePublicFeedsPage = { feeds: [feed],