From c15b2d857d8c0a655f706837c0844a632fe3f87c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=8B=E7=93=9C=E4=B8=80=E5=8D=81=E9=9B=AA?= Date: Sun, 18 Oct 2026 13:58:08 +0800 Subject: [PATCH] chore: prepare v2.0.0 release --- CHANGELOG.md | 19 ++ package.json | 2 +- packages/bridge/package.json | 2 +- packages/common/package.json | 2 +- packages/core/package.json | 2 +- packages/core/src/bridge/apis/qzone.ts | 14 ++ packages/core/tests/actions/qzone.test.ts | 32 +++ packages/mcp/package.json | 2 +- packages/mcp/src/generated/catalog.json | 186 +++++++++++++++++- packages/mcp/src/generated/catalog.ts | 186 +++++++++++++++++- packages/onebot/package.json | 2 +- packages/onebot/src/actions/qzone.ts | 60 ++++++ packages/onebot/tests/qzone-actions.test.ts | 69 +++++++ packages/proto-defs/package.json | 2 +- packages/protocol/package.json | 2 +- packages/protocol/src/web/qzone.ts | 141 ++++++++++++- .../protocol/tests/web/qzone-detail.test.ts | 142 +++++++++++++ packages/proton/package.json | 2 +- packages/runtime/package.json | 2 +- packages/sdk/CHANGELOG.md | 5 +- packages/sdk/README.md | 22 +++ packages/sdk/VERSIONING.md | 14 +- packages/sdk/package.json | 2 +- packages/sdk/src/types/actions.ts | 3 + packages/sdk/src/types/params.ts | 10 + packages/sdk/src/types/results.ts | 23 +++ packages/sdk/tests/http-client.test.ts | 35 +++- packages/sqlite/package.json | 16 +- packages/ui/package.json | 2 +- packages/websocket/package.json | 2 +- packages/webui/package.json | 2 +- 31 files changed, 965 insertions(+), 40 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 packages/protocol/tests/web/qzone-detail.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..08f4ac7c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# 更新记录 + +## 2.0.0 + +### 升级 + +- 最低运行环境升至 Node.js 24。完整版内置 Node.js;Lite、SDK 和 MCP 用户需先升级运行环境。 +- 原生接口全面异步化,应用与原生产物需成套更新;请使用对应平台的完整发行包。 + +### 更新 + +- 新增本地 QQ 账号识别、进程状态诊断和有序账号通知。 +- WebUI 支持暂存更新、重启安装,并保留配置、日志与消息数据。 +- 新增群分享链接、空间说说详情与评论分页接口,同步 SDK 类型和 MCP 动作目录。 +- 改进合并转发引用预览、群相册评论、SILK 时长和 Highway 节点切换。 +- 整理原生调用、SQLite、账号关闭和重连顺序,修复请求取消、订阅和定时器释放。 +- 减少 WebUI 空闲轮询,修复反向代理认证与版本比较。 + +[查看自 v1.14.22 起的完整差异](https://github.com/SnowLuma/SnowLuma/compare/v1.14.22...dev) diff --git a/package.json b/package.json index fedf38b6..da36d33a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "license": "SEE LICENSE IN LICENSE", "name": "snowluma-monorepo", - "version": "1.14.22", + "version": "2.0.0", "description": "SnowLuma monorepo workspace (pnpm)", "private": true, "type": "module", diff --git a/packages/bridge/package.json b/packages/bridge/package.json index 1a38e886..f9e527a3 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -1,7 +1,7 @@ { "license": "SEE LICENSE IN LICENSE", "name": "@snowluma/bridge", - "version": "1.14.22", + "version": "2.0.0", "private": true, "type": "module", "description": "SnowLuma bridge — QQ NT desktop client lifecycle: hook injection (via native addon), per-PID HookSession orchestration, pipe-watcher for process discovery, and the BridgeManagerSink interface that lets the hook layer call back into a UIN-keyed session router without depending on @snowluma/core. Consumed by the top-level app entry (which constructs the BridgeManager from @snowluma/core and injects it as the sink) and by the WebUI server (read-only HookProcessInfo views).", diff --git a/packages/common/package.json b/packages/common/package.json index 416cd672..054ec0b4 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,7 +1,7 @@ { "license": "SEE LICENSE IN LICENSE", "name": "@snowluma/common", - "version": "1.14.22", + "version": "2.0.0", "private": true, "type": "module", "description": "SnowLuma shared infrastructure — leaf-package utilities with zero business dependencies. Logger / hex codec / log file transport / log line summariser / packet sender wire-protocol types / runtime config loader. Consumed by @snowluma/protocol, @snowluma/core, and @snowluma/onebot. Per-file subpath exports keep import sites unambiguous and let the bundler tree-shake aggressively (importing `@snowluma/common/hex` doesn't pull in `node:fs` from the logger transport).", diff --git a/packages/core/package.json b/packages/core/package.json index 5208aeda..3bb5616a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "license": "SEE LICENSE IN LICENSE", "name": "@snowluma/core", - "version": "1.14.22", + "version": "2.0.0", "description": "SnowLuma core - NTQQ packet sniffer with Node.js native addon", "private": true, "type": "module", diff --git a/packages/core/src/bridge/apis/qzone.ts b/packages/core/src/bridge/apis/qzone.ts index cc463083..273822dc 100644 --- a/packages/core/src/bridge/apis/qzone.ts +++ b/packages/core/src/bridge/apis/qzone.ts @@ -2,6 +2,7 @@ import { commentQzoneMsg, deleteQzoneMsg, getQzoneFeeds, + getQzoneMsgDetail, getQzoneMsgList, publishQzoneMsg, setQzoneBlack, @@ -10,6 +11,7 @@ import { uploadQzoneImageFromSource, type QzoneCommentResult, type QzoneFeedsResult, + type QzoneMsgDetailResult, type QzoneMsgListResult, type QzonePublishResult, type QzoneUpdateRightResult, @@ -36,6 +38,18 @@ export class QzoneApi { return getQzoneMsgList(cookieObject, uin, pos, num); } + /** 获取说说详情和一页评论。省略 targetUin 时使用当前账号。 */ + async getMsgDetail( + tid: string, + targetUin?: number, + pos = 0, + num = 20, + ): Promise { + const uin = targetUin === undefined ? this.ctx.identity.uin : String(targetUin); + const cookieObject = await this.ctx.apis.web.getCookies('qzone.qq.com'); + return getQzoneMsgDetail(cookieObject, uin, tid, pos, num); + } + /** * 获取好友动态(feed)。`pageNum` 为 1 起的页码,`count` 为本页数量。 * 始终以机器人自己的身份拉取好友动态。 diff --git a/packages/core/tests/actions/qzone.test.ts b/packages/core/tests/actions/qzone.test.ts index 5a51cdb1..8aeb04c5 100644 --- a/packages/core/tests/actions/qzone.test.ts +++ b/packages/core/tests/actions/qzone.test.ts @@ -20,6 +20,38 @@ describe('apis/qzone', () => { return { bridge, getCookies, fetchSpy }; } + it.each([undefined, 20002])( + 'getMsgDetail uses current-account credentials for owner %s', + async (targetUin) => { + const { bridge, getCookies } = bridgeWithWeb(); + const detail = { tid: 'T1', content: 'Post', comment_num: 0, comments: [] }; + const fetchDetail = vi.spyOn(qzoneWeb, 'getQzoneMsgDetail').mockResolvedValue(detail); + const result = await new QzoneApi(bridge as never).getMsgDetail('T1', targetUin); + expect(result).toEqual(detail); + expect(getCookies).toHaveBeenCalledWith('qzone.qq.com'); + expect(fetchDetail).toHaveBeenCalledWith( + { p_skey: 'PSK' }, + String(targetUin ?? 10001), + 'T1', + 0, + 20, + ); + }, + ); + + it('getMsgDetail passes pagination and propagates cookie and protocol failures', async () => { + const { bridge, getCookies } = bridgeWithWeb(); + const api = new QzoneApi(bridge as never); + const fetchDetail = vi + .spyOn(qzoneWeb, 'getQzoneMsgDetail') + .mockRejectedValue(new Error('denied')); + await expect(api.getMsgDetail('T1', 20002, 10, 5)).rejects.toThrow('denied'); + expect(fetchDetail).toHaveBeenCalledWith({ p_skey: 'PSK' }, '20002', 'T1', 10, 5); + getCookies.mockRejectedValueOnce(new Error('cookie failure')); + await expect(api.getMsgDetail('T1')).rejects.toThrow('cookie failure'); + expect(fetchDetail).toHaveBeenCalledTimes(1); + }); + it("defaults target_uin to the bot's own uin and uses default pos/num", async () => { const { bridge, getCookies, fetchSpy } = bridgeWithWeb(); await new QzoneApi(bridge as never).getMsgList(); diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 20c46bf3..1d6575b2 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,7 +1,7 @@ { "license": "SEE LICENSE IN LICENSE", "name": "@snowluma/mcp", - "version": "1.14.22", + "version": "2.0.0", "description": "MCP server exposing the SnowLuma OneBot action catalog (docs + JSON Schemas) and optional execution to LLM clients.", "repository": { "type": "git", diff --git a/packages/mcp/src/generated/catalog.json b/packages/mcp/src/generated/catalog.json index 1ed819b0..3fa63aa1 100644 --- a/packages/mcp/src/generated/catalog.json +++ b/packages/mcp/src/generated/catalog.json @@ -7504,6 +7504,190 @@ }, "category": "空间" }, + { + "name": "get_qzone_msg_detail", + "aliases": [], + "summary": "获取 QQ 空间说说详情与一页评论(含服务端内联回复,需有访问权限)", + "returns": "说说详情、服务端报告的评论总数与本页评论;replies 仅含服务端内联返回的二级回复,不保证完整。", + "returnsSchema": { + "type": "object", + "properties": { + "tid": { + "type": "string", + "description": "说说 ID" + }, + "content": { + "type": "string", + "description": "说说正文" + }, + "comment_num": { + "type": "integer", + "description": "服务端报告的评论总数(非本页数量)" + }, + "comments": { + "type": "array", + "description": "本页一级评论,数量可能少于 num", + "items": { + "type": "object", + "properties": { + "uin": { + "type": "integer", + "description": "评论者 QQ 号" + }, + "name": { + "type": "string", + "description": "评论者昵称" + }, + "time": { + "type": "integer", + "description": "评论时间(unix 秒)" + }, + "content": { + "type": "string", + "description": "评论正文,保留空间表情和 @ 标记" + }, + "comment_id": { + "type": "string", + "description": "评论 ID,在所属说说或父评论内唯一" + }, + "replies": { + "type": "array", + "description": "服务端内联二级回复,可能不完整", + "items": { + "type": "object", + "properties": { + "uin": { + "type": "integer", + "description": "评论者 QQ 号" + }, + "name": { + "type": "string", + "description": "评论者昵称" + }, + "time": { + "type": "integer", + "description": "评论时间(unix 秒)" + }, + "content": { + "type": "string", + "description": "评论正文,保留空间表情和 @ 标记" + }, + "comment_id": { + "type": "string", + "description": "评论 ID,在所属说说或父评论内唯一" + } + }, + "required": [ + "uin", + "name", + "time", + "content", + "comment_id" + ] + } + } + }, + "required": [ + "uin", + "name", + "time", + "content", + "comment_id", + "replies" + ] + } + } + }, + "required": [ + "tid", + "content", + "comment_num", + "comments" + ] + }, + "readOnly": true, + "params": [ + { + "name": "tid", + "type": "string", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "desc": "说说 tid(来自 get_qzone_msg_list / send_qzone_msg)" + }, + { + "name": "target_uin", + "type": "uint", + "required": false, + "role": "user_id", + "schema": { + "type": "integer", + "minimum": 1 + }, + "desc": "说说所属 QQ 号,省略则取机器人自己" + }, + { + "name": "pos", + "type": "int", + "required": false, + "schema": { + "type": "integer", + "minimum": 0 + }, + "desc": "一级评论起始偏移", + "default": 0 + }, + { + "name": "num", + "type": "int", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "desc": "请求的一级评论数量,实际数量受服务端限制", + "default": 20 + } + ], + "invariants": [], + "inputSchema": { + "type": "object", + "properties": { + "tid": { + "type": "string", + "minLength": 1, + "description": "说说 tid(来自 get_qzone_msg_list / send_qzone_msg)" + }, + "target_uin": { + "type": "integer", + "minimum": 1, + "description": "说说所属 QQ 号,省略则取机器人自己", + "x-role": "user_id" + }, + "pos": { + "type": "integer", + "minimum": 0, + "description": "一级评论起始偏移", + "default": 0 + }, + "num": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "请求的一级评论数量,实际数量受服务端限制", + "default": 20 + } + }, + "required": [ + "tid" + ], + "additionalProperties": true + }, + "category": "空间" + }, { "name": "get_qzone_msg_list", "aliases": [], @@ -13448,7 +13632,7 @@ }, { "category": "空间", - "count": 9 + "count": 10 }, { "category": "系统表情", diff --git a/packages/mcp/src/generated/catalog.ts b/packages/mcp/src/generated/catalog.ts index 91c229ca..70e607d2 100644 --- a/packages/mcp/src/generated/catalog.ts +++ b/packages/mcp/src/generated/catalog.ts @@ -7507,6 +7507,190 @@ export const ACTIONS: CatalogAction[] = [ }, "category": "空间" }, + { + "name": "get_qzone_msg_detail", + "aliases": [], + "summary": "获取 QQ 空间说说详情与一页评论(含服务端内联回复,需有访问权限)", + "returns": "说说详情、服务端报告的评论总数与本页评论;replies 仅含服务端内联返回的二级回复,不保证完整。", + "returnsSchema": { + "type": "object", + "properties": { + "tid": { + "type": "string", + "description": "说说 ID" + }, + "content": { + "type": "string", + "description": "说说正文" + }, + "comment_num": { + "type": "integer", + "description": "服务端报告的评论总数(非本页数量)" + }, + "comments": { + "type": "array", + "description": "本页一级评论,数量可能少于 num", + "items": { + "type": "object", + "properties": { + "uin": { + "type": "integer", + "description": "评论者 QQ 号" + }, + "name": { + "type": "string", + "description": "评论者昵称" + }, + "time": { + "type": "integer", + "description": "评论时间(unix 秒)" + }, + "content": { + "type": "string", + "description": "评论正文,保留空间表情和 @ 标记" + }, + "comment_id": { + "type": "string", + "description": "评论 ID,在所属说说或父评论内唯一" + }, + "replies": { + "type": "array", + "description": "服务端内联二级回复,可能不完整", + "items": { + "type": "object", + "properties": { + "uin": { + "type": "integer", + "description": "评论者 QQ 号" + }, + "name": { + "type": "string", + "description": "评论者昵称" + }, + "time": { + "type": "integer", + "description": "评论时间(unix 秒)" + }, + "content": { + "type": "string", + "description": "评论正文,保留空间表情和 @ 标记" + }, + "comment_id": { + "type": "string", + "description": "评论 ID,在所属说说或父评论内唯一" + } + }, + "required": [ + "uin", + "name", + "time", + "content", + "comment_id" + ] + } + } + }, + "required": [ + "uin", + "name", + "time", + "content", + "comment_id", + "replies" + ] + } + } + }, + "required": [ + "tid", + "content", + "comment_num", + "comments" + ] + }, + "readOnly": true, + "params": [ + { + "name": "tid", + "type": "string", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "desc": "说说 tid(来自 get_qzone_msg_list / send_qzone_msg)" + }, + { + "name": "target_uin", + "type": "uint", + "required": false, + "role": "user_id", + "schema": { + "type": "integer", + "minimum": 1 + }, + "desc": "说说所属 QQ 号,省略则取机器人自己" + }, + { + "name": "pos", + "type": "int", + "required": false, + "schema": { + "type": "integer", + "minimum": 0 + }, + "desc": "一级评论起始偏移", + "default": 0 + }, + { + "name": "num", + "type": "int", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "desc": "请求的一级评论数量,实际数量受服务端限制", + "default": 20 + } + ], + "invariants": [], + "inputSchema": { + "type": "object", + "properties": { + "tid": { + "type": "string", + "minLength": 1, + "description": "说说 tid(来自 get_qzone_msg_list / send_qzone_msg)" + }, + "target_uin": { + "type": "integer", + "minimum": 1, + "description": "说说所属 QQ 号,省略则取机器人自己", + "x-role": "user_id" + }, + "pos": { + "type": "integer", + "minimum": 0, + "description": "一级评论起始偏移", + "default": 0 + }, + "num": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "请求的一级评论数量,实际数量受服务端限制", + "default": 20 + } + }, + "required": [ + "tid" + ], + "additionalProperties": true + }, + "category": "空间" + }, { "name": "get_qzone_msg_list", "aliases": [], @@ -13452,7 +13636,7 @@ export const CATEGORIES: CatalogCategory[] = [ }, { "category": "空间", - "count": 9 + "count": 10 }, { "category": "系统表情", diff --git a/packages/onebot/package.json b/packages/onebot/package.json index 51cbe037..c63a4411 100644 --- a/packages/onebot/package.json +++ b/packages/onebot/package.json @@ -1,7 +1,7 @@ { "license": "SEE LICENSE IN LICENSE", "name": "@snowluma/onebot", - "version": "1.14.22", + "version": "2.0.0", "private": true, "type": "module", "description": "SnowLuma OneBot v11 adapter — the protocol translation layer that maps the public OneBot vocabulary (HTTP / WebSocket actions, message segments, post events) onto @snowluma/core's typed Api hub (`bridge.apis..method()`). Owns network adapters (HTTP server / HTTP client / WS server / WS client), the registered action handlers, the receive-side event→OneBot converter, message-id hashing, message store (SQLite-backed event history), media store, and the OneBot instance + manager that ties one OneBotConfig to one Bridge. Per-file subpath exports mirror the legacy `core/src/onebot/*` layout 1:1 so the previous `import { … } from '../onebot/'` collapses to `import { … } from '@snowluma/onebot/'`.", diff --git a/packages/onebot/src/actions/qzone.ts b/packages/onebot/src/actions/qzone.ts index c517c6f9..25a2f2c0 100644 --- a/packages/onebot/src/actions/qzone.ts +++ b/packages/onebot/src/actions/qzone.ts @@ -25,6 +25,16 @@ async function uploadQzoneImages( const QZONE_UGC_RIGHTS = new Set([1, 4, 16, 64, 128]); +const qzoneCommentProperties = { + uin: { type: 'integer', description: '评论者 QQ 号' }, + name: { type: 'string', description: '评论者昵称' }, + time: { type: 'integer', description: '评论时间(unix 秒)' }, + content: { type: 'string', description: '评论正文,保留空间表情和 @ 标记' }, + comment_id: { type: 'string', description: '评论 ID,在所属说说或父评论内唯一' }, +}; + +const qzoneCommentRequired = ['uin', 'name', 'time', 'content', 'comment_id']; + export const actions = [ // get_qzone_msg_list — 获取 QQ 空间说说列表。无 go-cqhttp 标准,自定义命名。 // 默认取机器人自己的空间;传 target_uin 可查看指定账号(需有权限)。 @@ -75,6 +85,56 @@ export const actions = [ }, }), + defineAction({ + name: 'get_qzone_msg_detail', + readOnly: true, + summary: '获取 QQ 空间说说详情与一页评论(含服务端内联回复,需有访问权限)', + returns: + '说说详情、服务端报告的评论总数与本页评论;replies 仅含服务端内联返回的二级回复,不保证完整。', + returnsSchema: { + type: 'object', + properties: { + tid: { type: 'string', description: '说说 ID' }, + content: { type: 'string', description: '说说正文' }, + comment_num: { type: 'integer', description: '服务端报告的评论总数(非本页数量)' }, + comments: { + type: 'array', + description: '本页一级评论,数量可能少于 num', + items: { + type: 'object', + properties: { + ...qzoneCommentProperties, + replies: { + type: 'array', + description: '服务端内联二级回复,可能不完整', + items: { + type: 'object', + properties: qzoneCommentProperties, + required: qzoneCommentRequired, + }, + }, + }, + required: [...qzoneCommentRequired, 'replies'], + }, + }, + }, + required: ['tid', 'content', 'comment_num', 'comments'], + }, + params: { + tid: f + .string({ allowEmpty: false }) + .describe('说说 tid(来自 get_qzone_msg_list / send_qzone_msg)'), + target_uin: f.userId().describe('说说所属 QQ 号,省略则取机器人自己').optional(), + pos: f.int({ min: 0 }).describe('一级评论起始偏移').default(0), + num: f + .int({ min: 1, max: 100 }) + .describe('请求的一级评论数量,实际数量受服务端限制') + .default(20), + }, + run: async (p, ctx) => + okResponse(await ctx.bridge.apis.qzone.getMsgDetail(p.tid, p.target_uin, p.pos, p.num)), + }), + // get_qzone_feeds — 获取好友动态(feed)。只读,始终以机器人自己身份拉取。 // 每条 feed 含结构化字段 + 预渲染 html 原样透传(深度解析交由调用方)。 defineAction({ diff --git a/packages/onebot/tests/qzone-actions.test.ts b/packages/onebot/tests/qzone-actions.test.ts index 7917a474..1230ea4d 100644 --- a/packages/onebot/tests/qzone-actions.test.ts +++ b/packages/onebot/tests/qzone-actions.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { BridgeInterface } from '@snowluma/core/bridge-interface'; import { ApiHandler, type ApiActionContext } from '../src/api-handler'; +import { actions } from '../src/actions/qzone'; function makeHandler(qzone: Record): ApiHandler { const bridge = { apis: { qzone } } as unknown as BridgeInterface; @@ -9,6 +10,74 @@ function makeHandler(qzone: Record): ApiHandler { } describe('qzone actions', () => { + it('get_qzone_msg_detail returns the comment page with default parameters', async () => { + const detail = { + tid: 'T1', + content: 'Post', + comment_num: 1, + comments: [ + { + uin: 20002, + name: 'Reader', + time: 1700000000, + content: 'Hello', + comment_id: '0', + replies: [], + }, + ], + }; + const getMsgDetail = vi.fn().mockResolvedValue(detail); + const res = await makeHandler({ getMsgDetail }).handle('get_qzone_msg_detail', { + tid: 'T1', + }); + expect(res).toMatchObject({ status: 'ok', retcode: 0, data: detail }); + expect(getMsgDetail).toHaveBeenCalledWith('T1', undefined, 0, 20); + expect( + actions.find((action) => action.names.includes('get_qzone_msg_detail'))?.describe() + .readOnly, + ).toBe(true); + }); + + it('get_qzone_msg_detail accepts HTTP query strings for owner and pagination', async () => { + const getMsgDetail = vi + .fn() + .mockResolvedValue({ tid: 'T1', content: '', comment_num: 0, comments: [] }); + const res = await makeHandler({ getMsgDetail }).handle('get_qzone_msg_detail', { + tid: 'T1', + target_uin: '20002', + pos: '5', + num: '10', + }); + expect(res).toMatchObject({ status: 'ok', retcode: 0 }); + expect(getMsgDetail).toHaveBeenCalledWith('T1', 20002, 5, 10); + }); + + it.each([ + {}, + { tid: '' }, + { tid: 'T1', target_uin: 0 }, + { tid: 'T1', target_uin: 'bad' }, + { tid: 'T1', pos: -1 }, + { tid: 'T1', num: 0 }, + { tid: 'T1', num: 101 }, + ])('get_qzone_msg_detail rejects invalid params (%j) before fetching', async (params) => { + const getMsgDetail = vi.fn(); + const res = await makeHandler({ getMsgDetail }).handle('get_qzone_msg_detail', params); + expect(res).toMatchObject({ status: 'failed', retcode: 1400 }); + expect(getMsgDetail).not.toHaveBeenCalled(); + }); + + it('get_qzone_msg_detail exposes upstream failures', async () => { + const getMsgDetail = vi + .fn() + .mockRejectedValue(new Error('qzone msg detail failed: code=-3000 need login')); + const res = await makeHandler({ getMsgDetail }).handle('get_qzone_msg_detail', { + tid: 'T1', + }); + expect(res).toMatchObject({ status: 'failed', retcode: 100 }); + expect(res.wording).toContain('code=-3000 need login'); + }); + it('send_qzone_msg uploads images and publishes richvals joined with tab', async () => { const uploadImageFromSource = vi .fn() diff --git a/packages/proto-defs/package.json b/packages/proto-defs/package.json index e0dfee45..da1ee84a 100644 --- a/packages/proto-defs/package.json +++ b/packages/proto-defs/package.json @@ -1,7 +1,7 @@ { "license": "SEE LICENSE IN LICENSE", "name": "@snowluma/proto-defs", - "version": "1.14.22", + "version": "2.0.0", "private": true, "type": "module", "description": "SnowLuma QQ/Lagrange wire-protobuf type definitions. Pure TypeScript interfaces with proton's `pb` marker types — `@snowluma/proton`'s vite plugin compiles them into zero-runtime encoder/decoder functions at the call sites in `@snowluma/core` (and any downstream consumer). Exposed as per-file subpaths (`@snowluma/proto-defs/oidb`, `@snowluma/proto-defs/message`, …) so the import-site shape mirrors the legacy `bridge/proto/proton/*` layout and avoids name collisions across files (notably `highway.ts` and `oidb-actions/media.ts` share NTV2-prefixed names).", diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 75093b32..dc52b802 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,7 +1,7 @@ { "license": "SEE LICENSE IN LICENSE", "name": "@snowluma/protocol", - "version": "1.14.22", + "version": "2.0.0", "private": true, "type": "module", "description": "SnowLuma protocol — the protocol-layer between QQ wire bytes and typed events. Owns: identity service (uid↔uin cache with SQLite persistence), event bus, packet pipeline (incoming SSO packet → QQEventVariant), msg-push decoders, element-builder (MessageElement → wire elems[]), highway upload protocol (file/image/video/ptt), OIDB envelope helper, and the event/notice/request type vocabulary. Consumers (@snowluma/core, @snowluma/onebot) talk to this package through `BridgeContext` for runtime calls and the standalone classes (IdentityService, BridgeEventBus, IncomingPacketPipeline) for composition. Per-file subpath exports mirror the legacy `core/src/bridge/*` layout 1:1 so the previous `import { …} from '../bridge/'` collapses to `import { … } from '@snowluma/protocol/'`.", diff --git a/packages/protocol/src/web/qzone.ts b/packages/protocol/src/web/qzone.ts index f09b8f04..0b6be0c3 100644 --- a/packages/protocol/src/web/qzone.ts +++ b/packages/protocol/src/web/qzone.ts @@ -62,6 +62,31 @@ export interface QzoneMsgListResult { msglist: QzoneEmotion[]; } +export interface QzoneCommentReply { + [key: string]: JsonValue; + uin: number; + name: string; + /** Unix seconds. */ + time: number; + content: string; + /** Scoped to the containing post (or parent comment for replies). */ + comment_id: string; +} + +export interface QzoneComment extends QzoneCommentReply { + /** Inline replies supplied by Qzone; may not include every reply. */ + replies: QzoneCommentReply[]; +} + +export interface QzoneMsgDetailResult { + [key: string]: JsonValue; + tid: string; + content: string; + /** Server-reported total, not the length of this comment page. */ + comment_num: number; + comments: QzoneComment[]; +} + /** * Parse a Qzone CGI body that may be raw JSON or a JSONP callback wrapper * (`_Callback({...});` / `callback({...})`). Uses a layered fallback approach: @@ -1553,6 +1578,8 @@ interface RawMsgDetailResponse { tid?: string; uin?: number | string; content?: string; + cmtnum?: unknown; + commentlist?: unknown; conlist?: Array<{ con?: string }> | null; pic?: RawMsgDetailPic[] | null; richtype?: number | string; @@ -1686,32 +1713,36 @@ export function buildQzoneUpdatePayload( }); } -/** Fetch one 说说's full detail (the update payload's source of truth). */ -async function getQzoneMsgDetail( +/** Shared raw detail for reading comments and rebuilding permission updates. */ +async function fetchQzoneMsgDetail( cookieObject: Record, - selfUin: string, + targetUin: string, tid: string, + page?: { pos: number; num: number }, ): Promise { const bkn = getBknFromCookie(cookieObject); const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msgdetail_v6?${new URLSearchParams( { tid, - uin: selfUin, + uin: targetUin, t1_source: '1', not_trunc_con: '1', need_right: '1', not_adapt_outpic: '1', g_tk: bkn, + ...(page ? { pos: String(page.pos), num: String(page.num) } : {}), }, ).toString()}`; const text = await RequestUtil.HttpGetText(url, 'GET', '', { Cookie: cookieToString(cookieObject), }); - log.trace('getQzoneMsgDetail raw body (tid=%s): %s', tid, text); const data = parseQzoneJson(text); + detailObject(data, 'response'); - const code = data.subcode ?? data.code ?? -1; + // Either error field can carry the failure; subcode=0 must not mask code!=0. + const code = + data.code !== undefined && data.code !== 0 ? data.code : (data.subcode ?? data.code ?? -1); if (code !== 0) { log.warn( @@ -1728,6 +1759,102 @@ async function getQzoneMsgDetail( return data; } +function detailObject(value: unknown, field: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`invalid qzone msg detail: ${field} must be an object`); + } + + return value as Record; +} + +function detailString(value: unknown, field: string): string { + if (typeof value !== 'string') { + throw new Error(`invalid qzone msg detail: ${field} must be a string`); + } + + return value; +} + +function detailInteger(value: unknown, field: string, min = 0): number { + const number = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value; + + if (typeof number !== 'number' || !Number.isSafeInteger(number) || number < min) { + throw new Error(`invalid qzone msg detail: ${field} must be an integer >= ${min}`); + } + + return number; +} + +function detailList(value: unknown, field: string): unknown[] { + if (value === undefined || value === null) { + return []; + } + + if (!Array.isArray(value)) { + throw new Error(`invalid qzone msg detail: ${field} must be an array`); + } + + return value; +} + +function mapQzoneComment(value: Record, field: string): QzoneCommentReply { + const id = + typeof value.tid === 'number' + ? String(detailInteger(value.tid, `${field}.tid`)) + : detailString(value.tid, `${field}.tid`); + + if (!id) { + throw new Error(`invalid qzone msg detail: ${field}.tid is empty`); + } + + return { + comment_id: id, + uin: detailInteger(value.uin, `${field}.uin`, 1), + name: detailString(value.name, `${field}.name`), + time: detailInteger(value.create_time, `${field}.create_time`), + content: detailString(value.content, `${field}.content`), + }; +} + +/** + * Read one post and a page of comments, with the inline replies Qzone supplies. + * Wire fields and pos/num follow SmartHypercube/Qzone-API's Comment / Emotion.load: + * https://github.com/SmartHypercube/Qzone-API/blob/master/qzone.py + * Mapping is independent of the update payload; malformed data must not look + * like a successful empty page. No raw response (private content) is logged. + */ +export async function getQzoneMsgDetail( + cookieObject: Record, + targetUin: string, + tid: string, + pos = 0, + num = 20, +): Promise { + const data = await fetchQzoneMsgDetail(cookieObject, targetUin, tid, { pos, num }); + const resultTid = detailString(data.tid, 'tid'); + + if (!resultTid || resultTid !== tid) { + throw new Error('invalid qzone msg detail: tid does not match the requested post'); + } + + return { + tid: resultTid, + content: detailString(data.content, 'content'), + comment_num: detailInteger(data.cmtnum, 'cmtnum'), + comments: detailList(data.commentlist, 'commentlist').map((value, index) => { + const field = `commentlist[${index}]`; + const comment = detailObject(value, field); + return { + ...mapQzoneComment(comment, field), + replies: detailList(comment.list_3, `${field}.list_3`).map((reply, replyIndex) => { + const replyField = `${field}.list_3[${replyIndex}]`; + return mapQzoneComment(detailObject(reply, replyField), replyField); + }), + }; + }), + }; +} + /** * Change the view permission of an existing 说说 (`tid`, on the bot's own * space) via the msgdetail_v6 → emotion_cgi_update two-step. `ugcRight` is @@ -1760,7 +1887,7 @@ export async function updateQzoneMsgRight( throw new Error('target_uins is required when ugc_right is 16 or 128'); } - const detail = await getQzoneMsgDetail(cookieObject, selfUin, tid); + const detail = await fetchQzoneMsgDetail(cookieObject, selfUin, tid); const bodyParams = buildQzoneUpdatePayload(detail, selfUin); bodyParams.set('ugc_right', String(right)); diff --git a/packages/protocol/tests/web/qzone-detail.test.ts b/packages/protocol/tests/web/qzone-detail.test.ts new file mode 100644 index 00000000..4c161bbb --- /dev/null +++ b/packages/protocol/tests/web/qzone-detail.test.ts @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getQzoneMsgDetail } from '@snowluma/protocol/web/qzone'; +import { RequestUtil, getBknFromCookie } from '@snowluma/protocol/web/request-util'; + +const cookies = { p_skey: 'PSK', skey: 'SK', uin: 'o10000' }; +// Synthetic data using Qzone-API's documented commentlist / list_3 wire fields. +const reply = { tid: 1, uin: 10000, name: 'Bot', create_time: 1700000002, content: 'Thanks!' }; +const comment = { + tid: 0, + uin: '20002', + name: 'Reader', + create_time: '1700000001', + content: 'Hello\n[em]e100[/em]', + list_3: [reply], +}; +const detail = { code: 0, tid: 'T1', content: 'Post', cmtnum: 12, commentlist: [comment] }; + +describe('qzone / getQzoneMsgDetail', () => { + afterEach(() => vi.restoreAllMocks()); + + it('reads a comment page through the authenticated detail CGI and maps replies', async () => { + const request = vi + .spyOn(RequestUtil, 'HttpGetText') + .mockResolvedValue(`_preloadCallback(${JSON.stringify(detail)});`); + const result = await getQzoneMsgDetail(cookies, '30003', 'T1', 5, 10); + expect(result).toEqual({ + tid: 'T1', + content: 'Post', + comment_num: 12, + comments: [ + { + comment_id: '0', + uin: 20002, + name: 'Reader', + time: 1700000001, + content: 'Hello\n[em]e100[/em]', + replies: [ + { + comment_id: '1', + uin: 10000, + name: 'Bot', + time: 1700000002, + content: 'Thanks!', + }, + ], + }, + ], + }); + expect(request).toHaveBeenCalledTimes(1); + const [url, method, body, headers] = request.mock.calls[0]!; + const parsed = new URL(url); + expect(parsed.origin + parsed.pathname).toBe( + 'https://h5.qzone.qq.com/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msgdetail_v6', + ); + expect(Object.fromEntries(parsed.searchParams)).toMatchObject({ + uin: '30003', + tid: 'T1', + pos: '5', + num: '10', + not_trunc_con: '1', + g_tk: getBknFromCookie(cookies), + }); + expect(method).toBe('GET'); + expect(body).toBe(''); + expect(headers).toMatchObject({ Cookie: expect.stringContaining('p_skey=PSK') }); + }); + + it.each([[undefined], [null], [[]]])( + 'accepts an empty comment page (%j)', + async (commentlist) => { + const request = vi + .spyOn(RequestUtil, 'HttpGetText') + .mockResolvedValue( + JSON.stringify({ ...detail, content: '', cmtnum: 0, commentlist }), + ); + await expect(getQzoneMsgDetail(cookies, '10000', 'T1')).resolves.toEqual({ + tid: 'T1', + content: '', + comment_num: 0, + comments: [], + }); + const query = new URL(request.mock.calls[0]![0]).searchParams; + expect(query.get('pos')).toBe('0'); + expect(query.get('num')).toBe('20'); + }, + ); + + it('preserves the total when a page is empty and normalizes absent replies', async () => { + vi.spyOn(RequestUtil, 'HttpGetText') + .mockResolvedValueOnce(JSON.stringify({ ...detail, commentlist: [] })) + .mockResolvedValueOnce( + JSON.stringify({ ...detail, commentlist: [{ ...comment, list_3: null }] }), + ); + await expect(getQzoneMsgDetail(cookies, '10000', 'T1', 20)).resolves.toMatchObject({ + comment_num: 12, + comments: [], + }); + const result = await getQzoneMsgDetail(cookies, '10000', 'T1'); + expect(result.comments[0]!.replies).toEqual([]); + }); + + it.each([{ code: -3000, subcode: 0 }, { code: 0, subcode: -4001 }, { subcode: -4001 }])( + 'propagates remote failure %j', + async (codes) => { + vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue( + JSON.stringify({ ...codes, message: 'denied' }), + ); + await expect(getQzoneMsgDetail(cookies, '10000', 'T1')).rejects.toThrow( + /qzone msg detail failed: .*denied/, + ); + }, + ); + + it.each([ + {}, + { ...detail, tid: undefined }, + { ...detail, tid: 'OTHER' }, + { ...detail, code: '0' }, + { ...detail, content: {} }, + { ...detail, cmtnum: -1 }, + { ...detail, commentlist: {} }, + { ...detail, commentlist: [null] }, + { ...detail, commentlist: [{ ...comment, uin: 'bad' }] }, + { ...detail, commentlist: [{ ...comment, tid: {} }] }, + { ...detail, commentlist: [{ ...comment, create_time: 'yesterday' }] }, + { ...detail, commentlist: [{ ...comment, list_3: {} }] }, + { ...detail, commentlist: [{ ...comment, list_3: [{ ...reply, content: {} }] }] }, + ])('rejects malformed data instead of returning an empty success (%#)', async (data) => { + vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(JSON.stringify(data)); + await expect(getQzoneMsgDetail(cookies, '10000', 'T1')).rejects.toThrow(/qzone msg detail/); + }); + + it('propagates transport and JSONP parse errors', async () => { + vi.spyOn(RequestUtil, 'HttpGetText') + .mockRejectedValueOnce(new Error('network down')) + .mockResolvedValueOnce('login'); + await expect(getQzoneMsgDetail(cookies, '10000', 'T1')).rejects.toThrow('network down'); + await expect(getQzoneMsgDetail(cookies, '10000', 'T1')).rejects.toThrow( + 'invalid response from qzone api', + ); + }); +}); diff --git a/packages/proton/package.json b/packages/proton/package.json index 21c8cefa..cde940cd 100644 --- a/packages/proton/package.json +++ b/packages/proton/package.json @@ -1,7 +1,7 @@ { "license": "SEE LICENSE IN LICENSE", "name": "@snowluma/proton", - "version": "1.14.22", + "version": "2.0.0", "private": true, "type": "module", "description": "Compile-time protobuf encoder/decoder generator for SnowLuma. TypeScript interfaces → fully-inlined, zero-runtime protobuf codec functions. Vendored from SnowLuma/protobuf-dsl with edge-case fixes applied.", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 85915923..b2aca375 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "license": "SEE LICENSE IN LICENSE", "name": "@snowluma/runtime", - "version": "1.14.22", + "version": "2.0.0", "private": true, "type": "module", "main": "index.mjs", diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 7cd0d227..89c38f94 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -1,7 +1,10 @@ # Changelog -## Unreleased +## 2.0.0 +Upgrade to Node.js 24 or newer before updating the SDK. SDK and application versions are released together. + +- Add typed `get_qzone_msg_detail` support for reading a post and its comment page with inline replies. - Release cancellation listeners as soon as a request stops waiting for a connection. - Isolate WebSocket connection lifetimes and release request timers and abort listeners on every outcome. - Reject duplicate in-flight WebSocket echoes instead of overwriting the original request. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index dd1226d7..57b658d4 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -144,6 +144,28 @@ const response = await bot.rawResponse('get_status'); `raw` 会在 SnowLuma 返回 `status: "failed"` 或非零 `retcode` 时抛出 `SnowLumaApiError`;`rawResponse` 会保留完整响应。 +### 读取空间说说评论 + +`get_qzone_msg_detail` 是只读扩展动作,`raw` 提供参数和结果类型: + +```ts +const detail = await bot.raw('get_qzone_msg_detail', { + tid: '说说 ID', // 来自 get_qzone_msg_list / send_qzone_msg + // target_uin: 10001, // 省略时读取机器人自己的空间 + pos: 0, + num: 20, +}); + +for (const comment of detail.comments) { + console.log(comment.comment_id, comment.uin, comment.name, comment.time, comment.content); + console.log(comment.replies); // 服务端内联返回的二级回复 +} +``` + +`comment_num` 是服务端报告的总数,`comments` 是本页一级评论;`num` 范围为 1–100,实际返回数量可能更少。二级回复不单独分页,`replies` 不保证完整。评论 ID 在所属说说内唯一,二级回复 ID 在父评论内唯一;时间均为 Unix 秒,正文保留空间表情和 @ 标记。该接口复用当前账号的空间登录态,登录或访问权限失败会抛出 `SnowLumaApiError`。 + +分页参数及 `commentlist` / `list_3` 字段参考 [Qzone-API](https://github.com/SmartHypercube/Qzone-API/blob/master/qzone.py)。实际分页和可见内容取决于 QQ 空间服务端。 + ## Message Builder ```ts diff --git a/packages/sdk/VERSIONING.md b/packages/sdk/VERSIONING.md index 6a03d9f9..f6361661 100644 --- a/packages/sdk/VERSIONING.md +++ b/packages/sdk/VERSIONING.md @@ -7,17 +7,9 @@ The SDK follows SemVer for its public package surface: - Supported subpath exports: `client`, `messages`, `events`, `errors`, `actions`, and `types`. - Runtime behavior of typed client helpers, message builders, event helpers, and SDK error classes. -## Current 0.x Policy +## Version policy -While the SDK is below `1.0.0`, breaking changes should use a minor release and include a migration note. Patch releases should remain bug fixes only. - -Examples: - -- `0.1.1`: bug fix, doc fix, type refinement that does not break consumers. -- `0.2.0`: new client helper, new event helper, new action typing, or documented breaking change before 1.0. -- `1.0.0`: first stable release where breaking changes move to major versions. - -## After 1.0 +SDK and application versions are synchronized by `pnpm bump ` from the repository root. - Major: removes or changes a public export, changes runtime behavior incompatibly, changes supported Node version, or changes package subpaths. - Minor: adds new public API, supports new SnowLuma actions, adds optional parameters, or adds error subclasses. @@ -25,7 +17,7 @@ Examples: ## Release Checklist -1. Update `packages/sdk/package.json` version. +1. Run `pnpm bump ` to update all workspace versions. 2. Update `packages/sdk/CHANGELOG.md`. 3. Run `pnpm --filter @snowluma/sdk typecheck`. 4. Run `pnpm --filter @snowluma/sdk test`. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 0456a0f4..9a3fa62d 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -4,7 +4,7 @@ "name": "MotricSeven", "email": "motricseven@gmail.com" }, - "version": "1.14.22", + "version": "2.0.0", "description": "TypeScript SDK for SnowLuma OneBot HTTP and WebSocket transports", "keywords": [ "snowluma", diff --git a/packages/sdk/src/types/actions.ts b/packages/sdk/src/types/actions.ts index d74088fd..ea930409 100644 --- a/packages/sdk/src/types/actions.ts +++ b/packages/sdk/src/types/actions.ts @@ -78,6 +78,7 @@ import type { DeleteGroupFolderParams, SendPacketParams, SearchSysFacesParams, + GetQzoneMsgDetailParams, } from './params'; import type { CapabilityInfo, @@ -115,11 +116,13 @@ import type { SystemFaceCatalogInfo, SystemFaceInfo, SystemFaceSearchInfo, + QzoneMsgDetailResult, } from './results'; export type ActionData = JsonValue; export interface SnowLumaActionMap { + get_qzone_msg_detail: { params: GetQzoneMsgDetailParams; data: QzoneMsgDetailResult }; get_login_info: { params: JsonObject; data: LoginInfo }; get_status: { params: JsonObject; data: StatusInfo }; get_version_info: { params: JsonObject; data: VersionInfo }; diff --git a/packages/sdk/src/types/params.ts b/packages/sdk/src/types/params.ts index 07cf844d..d16968c2 100644 --- a/packages/sdk/src/types/params.ts +++ b/packages/sdk/src/types/params.ts @@ -423,3 +423,13 @@ export interface SendPacketParams extends JsonObject { data?: string; rsp?: boolean; } + +export interface GetQzoneMsgDetailParams extends JsonObject { + tid: string; + /** Post owner; defaults to the current account. */ + target_uin?: number | string; + /** Top-level comment offset (default 0). */ + pos?: number | string; + /** Requested page size, 1–100 (default 20); Qzone may return fewer. */ + num?: number | string; +} diff --git a/packages/sdk/src/types/results.ts b/packages/sdk/src/types/results.ts index c1a9b8bd..60d07c8d 100644 --- a/packages/sdk/src/types/results.ts +++ b/packages/sdk/src/types/results.ts @@ -1,5 +1,28 @@ import type { JsonArray, JsonObject, JsonValue } from './json'; +export interface QzoneCommentReply { + uin: number; + name: string; + /** Unix seconds. */ + time: number; + content: string; + /** Scoped to the post, or parent comment for replies. */ + comment_id: string; +} + +export interface QzoneComment extends QzoneCommentReply { + /** Inline replies only; Qzone may omit further replies. */ + replies: QzoneCommentReply[]; +} + +export interface QzoneMsgDetailResult { + tid: string; + content: string; + /** Server-reported total, not the number of comments on this page. */ + comment_num: number; + comments: QzoneComment[]; +} + export interface SendMessageResult { message_id: number; } diff --git a/packages/sdk/tests/http-client.test.ts b/packages/sdk/tests/http-client.test.ts index f6b43219..23bf9db0 100644 --- a/packages/sdk/tests/http-client.test.ts +++ b/packages/sdk/tests/http-client.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; +import type { QzoneMsgDetailResult } from '../src'; import { SnowLumaAbortError, SnowLumaAuthError, @@ -7,6 +8,38 @@ import { } from '../src'; describe('SnowLumaHttpClient', () => { + it('reads a typed Qzone comment page through raw', async () => { + const data: QzoneMsgDetailResult = { + tid: 'T1', + content: 'Post', + comment_num: 1, + comments: [ + { + uin: 20002, + name: 'Reader', + time: 1700000000, + content: 'Hi', + comment_id: '0', + replies: [], + }, + ], + }; + const fetchImpl = vi.fn( + async () => new Response(JSON.stringify({ status: 'ok', retcode: 0, data })), + ); + const client = new SnowLumaHttpClient({ fetch: fetchImpl }); + const params = { tid: 'T1', target_uin: 10001, pos: 0, num: 20 }; + const result = await client.raw('get_qzone_msg_detail', params); + expectTypeOf(result).toEqualTypeOf(); + expect(result).toEqual(data); + expect(fetchImpl).toHaveBeenCalledWith( + 'http://127.0.0.1:3000/', + expect.objectContaining({ + body: JSON.stringify({ action: 'get_qzone_msg_detail', params }), + }), + ); + }); + it('requests group share links with default and explicit options', async () => { const url = 'https://qm.qq.com/q/example'; const fetchImpl = vi diff --git a/packages/sqlite/package.json b/packages/sqlite/package.json index 8a5177e5..adfd2042 100644 --- a/packages/sqlite/package.json +++ b/packages/sqlite/package.json @@ -1,15 +1,21 @@ { "license": "SEE LICENSE IN LICENSE", "name": "@snowluma/sqlite", - "version": "1.14.22", + "version": "2.0.0", "private": true, "type": "module", "description": "SnowLuma asynchronous SQLite facade, native loader and release assets.", "main": "./src/index.ts", "types": "./src/index.ts", "exports": { - ".": { "types": "./src/index.ts", "default": "./src/index.ts" }, - "./vite": { "types": "./src/vite.ts", "default": "./src/vite.ts" } + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./vite": { + "types": "./src/vite.ts", + "default": "./src/vite.ts" + } }, "scripts": { "typecheck": "tsc --noEmit", @@ -24,5 +30,7 @@ "vite": "catalog:", "vitest": "^4.1.4" }, - "engines": { "node": ">=24" } + "engines": { + "node": ">=24" + } } diff --git a/packages/ui/package.json b/packages/ui/package.json index 0f99250c..9dc193db 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -4,7 +4,7 @@ "name": "MotricSeven", "email": "motricseven@gmail.com" }, - "version": "1.14.22", + "version": "2.0.0", "description": "Shared SnowLuma UI primitives — Tailwind 4 theme tokens + shadcn-style atomic components", "keywords": [ "snowluma", diff --git a/packages/websocket/package.json b/packages/websocket/package.json index 87c2369f..53511ad3 100644 --- a/packages/websocket/package.json +++ b/packages/websocket/package.json @@ -1,7 +1,7 @@ { "license": "SEE LICENSE IN LICENSE", "name": "@snowluma/websocket", - "version": "1.14.22", + "version": "2.0.0", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/packages/webui/package.json b/packages/webui/package.json index 4755506c..0a5c2680 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -2,7 +2,7 @@ "license": "SEE LICENSE IN LICENSE", "name": "webui", "private": true, - "version": "1.14.22", + "version": "2.0.0", "type": "module", "scripts": { "dev": "vite",