chore: prepare v2.0.0 release

This commit is contained in:
手瓜一十雪
2026-10-18 13:58:08 +08:00
parent 1001f55a06
commit c15b2d857d
31 changed files with 965 additions and 40 deletions
+19
View File
@@ -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)
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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).",
+1 -1
View File
@@ -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).",
+1 -1
View File
@@ -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",
+14
View File
@@ -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<QzoneMsgDetailResult> {
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` 为本页数量。
* 始终以机器人自己的身份拉取好友动态。
+32
View File
@@ -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();
+1 -1
View File
@@ -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",
+185 -1
View File
@@ -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": "系统表情",
+185 -1
View File
@@ -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": "系统表情",
+1 -1
View File
@@ -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.<area>.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/<x>'` collapses to `import { … } from '@snowluma/onebot/<x>'`.",
+60
View File
@@ -25,6 +25,16 @@ async function uploadQzoneImages(
const QZONE_UGC_RIGHTS = new Set<number>([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({
@@ -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<string, unknown>): ApiHandler {
const bridge = { apis: { qzone } } as unknown as BridgeInterface;
@@ -9,6 +10,74 @@ function makeHandler(qzone: Record<string, unknown>): 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()
+1 -1
View File
@@ -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<N, T>` 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).",
+1 -1
View File
@@ -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/<x>'` collapses to `import { … } from '@snowluma/protocol/<x>'`.",
+134 -7
View File
@@ -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<string, string>,
selfUin: string,
targetUin: string,
tid: string,
page?: { pos: number; num: number },
): Promise<RawMsgDetailResponse> {
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<RawMsgDetailResponse>(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<string, unknown> {
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<string, unknown>;
}
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<string, unknown>, 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<string, string>,
targetUin: string,
tid: string,
pos = 0,
num = 20,
): Promise<QzoneMsgDetailResult> {
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));
@@ -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('<html>login</html>');
await expect(getQzoneMsgDetail(cookies, '10000', 'T1')).rejects.toThrow('network down');
await expect(getQzoneMsgDetail(cookies, '10000', 'T1')).rejects.toThrow(
'invalid response from qzone api',
);
});
});
+1 -1
View File
@@ -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.",
+1 -1
View File
@@ -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",
+4 -1
View File
@@ -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.
+22
View File
@@ -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
+3 -11
View File
@@ -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 <version>` 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 <version>` 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`.
+1 -1
View File
@@ -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",
+3
View File
@@ -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 };
+10
View File
@@ -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;
}
+23
View File
@@ -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;
}
+34 -1
View File
@@ -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<QzoneMsgDetailResult>();
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
+12 -4
View File
@@ -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"
}
}
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",