chore: prepare v2.0.0 release
This commit is contained in:
@@ -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
@@ -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,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,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,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",
|
||||
|
||||
@@ -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` 为本页数量。
|
||||
* 始终以机器人自己的身份拉取好友动态。
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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": "系统表情",
|
||||
|
||||
@@ -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,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>'`.",
|
||||
|
||||
@@ -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,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,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>'`.",
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user