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],