Skip to content

Commit 288cc7c

Browse files
authored
feat(qzone): add QQ space message publishing and image upload (#1965)
- Add QzonePublishResponse interface and parsing logic for publish API responses - Add buildQzonePublishBody function to construct publish request parameters - Add buildQzoneUploadImageBody function to construct image upload request parameters - Add parseQzoneUploadImageResponseText function to parse upload API responses - Add toQzoneRichval function to format image data for publish requests - Add publishQzoneMsg method to NTQQWebApi for publishing space messages with optional images - Add uploadImageToQzone method to NTQQWebApi for uploading images to QQ space - Add getQzoneAuth private method to retrieve authentication tokens (skey, pskey, g_tk) - Add SendQzoneMsg action handler to expose QQ space publishing via OneBot API - Add comprehensive unit tests for all Qzone data construction and parsing functions - Update action router to register SendQzoneMsg action - Update action examples to demonstrate Qzone publishing usage
1 parent 216d0c7 commit 288cc7c

7 files changed

Lines changed: 400 additions & 0 deletions

File tree

packages/napcat-core/apis/webapi.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ import { createHash } from 'node:crypto';
1313
import { basename } from 'node:path';
1414
import { qunAlbumControl } from '../data/webapi';
1515
import { createAlbumCommentRequest, createAlbumFeedPublish, createAlbumMediaFeed } from '../data/album';
16+
import {
17+
buildQzonePublishBody,
18+
buildQzoneUploadImageBody,
19+
parseQzonePublishResponse,
20+
parseQzoneUploadImageResponseText,
21+
toQzoneRichval,
22+
QzonePublishResponse,
23+
} from '../data/qzone';
1624
export interface SetNoticeRetSuccess {
1725
ec: number;
1826
em: string;
@@ -540,6 +548,63 @@ export class NTQQWebApi {
540548
);
541549
}
542550

551+
private async getQzoneAuth () {
552+
const skey = await this.core.apis.UserApi.getSKey() || '';
553+
const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || '';
554+
const uin = this.core.selfInfo.uin || '10001';
555+
const g_tk = this.getBknFromSKey(skey);
556+
const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`;
557+
return { skey, pskey, uin, g_tk, cookie };
558+
}
559+
560+
/**
561+
* 上传图片到 QQ 空间相册, 返回可用于发说说的 richval 字符串
562+
* @param base64 图片Base64编码内容(不带data:前缀)
563+
*/
564+
async uploadImageToQzone (base64: string): Promise<string> {
565+
const { skey, pskey, uin, g_tk, cookie } = await this.getQzoneAuth();
566+
const body = buildQzoneUploadImageBody({ uin, skey, pskey, g_tk, base64 });
567+
const api = `https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image?g_tk=${g_tk}`;
568+
const resultText = await RequestUtil.HttpGetText(api, 'POST', body, {
569+
Cookie: cookie,
570+
'Content-Type': 'application/x-www-form-urlencoded',
571+
});
572+
const parsed = parseQzoneUploadImageResponseText(resultText);
573+
if (parsed.code !== undefined && parsed.code !== 0) {
574+
throw new Error(parsed.msg || `QQ空间图片上传失败, code=${parsed.code}`);
575+
}
576+
if (!parsed.data) {
577+
throw new Error('QQ空间图片上传失败, 响应缺少data字段');
578+
}
579+
return toQzoneRichval(parsed.data);
580+
}
581+
582+
/**
583+
* 发表QQ空间说说
584+
* @param content 说说正文
585+
* @param richvals 每张图片对应的richval数组, 为空表示纯文字说说
586+
* @param ugcRight 查看权限 1所有人可见 4好友可见 16部分好友可见 64仅自己可见 128部分好友不可见
587+
* @param targetUins 权限作用QQ号数组, ugcRight为16/128时使用
588+
*/
589+
async publishQzoneMsg (content: string, richvals: string[], ugcRight: number, targetUins?: number[]) {
590+
const { uin, g_tk, cookie } = await this.getQzoneAuth();
591+
const richval = richvals.length > 0 ? richvals.join('\t') : undefined;
592+
const allowUins = targetUins && targetUins.length > 0 ? targetUins.join('|') : undefined;
593+
const body = buildQzonePublishBody({
594+
hostuin: uin,
595+
content,
596+
richval,
597+
ugcRight,
598+
allowUins,
599+
});
600+
const api = `https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_publish_v6?g_tk=${g_tk}`;
601+
const result = await RequestUtil.HttpGetJson<QzonePublishResponse>(api, 'POST', body, {
602+
Cookie: cookie,
603+
'Content-Type': 'application/x-www-form-urlencoded',
604+
}, true, false);
605+
return parseQzonePublishResponse(result);
606+
}
607+
543608
async getDaySignedList (groupCode: string) {
544609
const pSkey = (await this.core.apis.UserApi.getPSkey(['qun.qq.com'])).domainPskeyMap.get('qun.qq.com')!;
545610
const selfUin = this.core.selfInfo.uin;

packages/napcat-core/data/qzone.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/**
2+
* QQ空间(Qzone)发说说/上传图片相关的纯数据构造与解析函数
3+
* 不发起任何网络请求,方便单独测试
4+
*/
5+
6+
export interface QzoneUploadImageParams {
7+
uin: string;
8+
skey: string;
9+
pskey: string;
10+
g_tk: string;
11+
base64: string;
12+
}
13+
14+
/**
15+
* 构造上传图片到 QQ 空间的表单请求体 (application/x-www-form-urlencoded)
16+
* 对齐 https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image
17+
*/
18+
export function buildQzoneUploadImageBody (params: QzoneUploadImageParams): string {
19+
const { uin, skey, pskey, g_tk, base64 } = params;
20+
const backUrls = `http://upbak.photo.qzone.qq.com/cgi-bin/upload/cgi_upload_image,http://119.147.64.75/cgi-bin/upload/cgi_upload_image&url=https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image?g_tk=${g_tk}`;
21+
const query = new URLSearchParams({
22+
filename: 'filename',
23+
uin,
24+
skey,
25+
zzpaneluin: uin,
26+
p_uin: uin,
27+
p_skey: pskey,
28+
uploadtype: '1',
29+
albumtype: '7',
30+
exttype: '0',
31+
refer: 'shuoshuo',
32+
output_type: 'jsonhtml',
33+
charset: 'utf-8',
34+
output_charset: 'utf-8',
35+
upload_hd: '1',
36+
hd_width: '2048',
37+
hd_height: '10000',
38+
hd_quality: '96',
39+
backUrls,
40+
base64: '1',
41+
jsonhtml_callback: 'callback',
42+
picfile: base64,
43+
qzreferrer: `https://user.qzone.qq.com/${uin}/main`,
44+
});
45+
return query.toString();
46+
}
47+
48+
export interface QzoneUploadImageData {
49+
albumid: string;
50+
lloc: string;
51+
type: string;
52+
height: string;
53+
width: string;
54+
url?: string;
55+
}
56+
57+
export interface QzoneUploadImageRawResponse {
58+
code?: number;
59+
msg?: string;
60+
data?: QzoneUploadImageData;
61+
}
62+
63+
/**
64+
* 解析上传图片接口返回的 `frameElement.callback({...});</script>` 包裹响应
65+
*/
66+
export function parseQzoneUploadImageResponseText (raw: string): QzoneUploadImageRawResponse {
67+
const startTag = 'frameElement.callback';
68+
const endTag = '</script>';
69+
const startIdx = raw.indexOf(startTag);
70+
const endIdx = raw.indexOf(endTag);
71+
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
72+
throw new Error('QQ空间图片上传响应格式异常');
73+
}
74+
const segment = raw.slice(startIdx + startTag.length, endIdx);
75+
const jsonStart = segment.indexOf('(');
76+
const jsonEnd = segment.lastIndexOf(')');
77+
if (jsonStart === -1 || jsonEnd === -1 || jsonEnd <= jsonStart) {
78+
throw new Error('QQ空间图片上传响应解析失败');
79+
}
80+
const jsonText = segment.slice(jsonStart + 1, jsonEnd);
81+
try {
82+
return JSON.parse(jsonText) as QzoneUploadImageRawResponse;
83+
} catch {
84+
throw new Error('QQ空间图片上传响应JSON解析失败');
85+
}
86+
}
87+
88+
/**
89+
* 依据上传图片返回数据拼接 richval 字段
90+
* 格式: ,albumid,lloc,sloc,type,height,width,,height,width (sloc 与 lloc 一致)
91+
*/
92+
export function toQzoneRichval (data: QzoneUploadImageData): string {
93+
const { albumid, lloc, type, height, width } = data;
94+
return `,${albumid},${lloc},${lloc},${type},${height},${width},,${height},${width}`;
95+
}
96+
97+
export interface QzonePublishParams {
98+
hostuin: string;
99+
content: string;
100+
/** 多张图片使用 \t 拼接后的 richval,无图时不传 */
101+
richval?: string;
102+
ugcRight: number;
103+
/** ugc_right 为 16/128 时必填,多个QQ号使用 | 拼接 */
104+
allowUins?: string;
105+
}
106+
107+
/**
108+
* 构造发表说说的表单请求体 (application/x-www-form-urlencoded)
109+
* 对齐 https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_publish_v6
110+
*/
111+
export function buildQzonePublishBody (params: QzonePublishParams): string {
112+
const { hostuin, content, richval, ugcRight, allowUins } = params;
113+
const fields: Record<string, string> = {
114+
syn_tweet_verson: '1',
115+
paramstr: '1',
116+
con: content,
117+
feedversion: '1',
118+
ver: '1',
119+
ugc_right: String(ugcRight),
120+
to_sign: '0',
121+
hostuin,
122+
code_version: '1',
123+
format: 'json',
124+
qzreferrer: `https://user.qzone.qq.com/${hostuin}/main`,
125+
};
126+
if (richval) {
127+
fields['richtype'] = '1';
128+
fields['richval'] = richval;
129+
}
130+
if (allowUins) {
131+
fields['allow_uins'] = allowUins;
132+
fields['who'] = '1';
133+
}
134+
return new URLSearchParams(fields).toString();
135+
}
136+
137+
export interface QzonePublishResponse {
138+
subcode?: number;
139+
code?: number;
140+
tid?: string;
141+
t1_tid?: string;
142+
message?: string;
143+
msg?: string;
144+
}
145+
146+
/**
147+
* 解析发表说说接口返回的 JSON, 提取 tid
148+
*/
149+
export function parseQzonePublishResponse (json: QzonePublishResponse): { tid: string; } {
150+
const subcode = json.subcode ?? json.code;
151+
if (subcode !== undefined && subcode !== 0) {
152+
throw new Error(json.message || json.msg || `QQ空间发说说失败, subcode=${subcode}`);
153+
}
154+
const tid = json.t1_tid || json.tid;
155+
if (!tid) {
156+
throw new Error('QQ空间发说说失败, 未返回tid');
157+
}
158+
return { tid };
159+
}

packages/napcat-onebot/action/example/ExtendsActionsExamples.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,8 @@ export const ExtendsActionsExamples = {
4242
payload: { group_id: '123456', user_id: '123456789', special_title: '头衔' },
4343
response: null,
4444
},
45+
SendQzoneMsg: {
46+
payload: { content: '今天天气不错', images: [], ugc_right: 1, target_uins: [] },
47+
response: { tid: '1234567890abcdef1234567890' },
48+
},
4549
};
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { uriToLocalFile } from 'napcat-common/src/file';
2+
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
3+
import { ActionName } from '@/napcat-onebot/action/router';
4+
import { Static, Type } from '@sinclair/typebox';
5+
import { existsSync, readFileSync } from 'node:fs';
6+
import { unlink } from 'node:fs/promises';
7+
import { ExtendsActionsExamples } from '../example/ExtendsActionsExamples';
8+
9+
const ValidUgcRights = [1, 4, 16, 64, 128] as const;
10+
11+
const PayloadSchema = Type.Object({
12+
content: Type.String({ description: '说说正文' }),
13+
images: Type.Optional(Type.Array(Type.String(), { description: '图片数组, 支持 file:// http(s):// base64:// , 自动上传' })),
14+
ugc_right: Type.Optional(Type.Union([Type.Number(), Type.String()], { default: 1, description: '查看权限: 1所有人可见 4好友可见 16部分好友可见 64仅自己可见 128部分好友不可见' })),
15+
target_uins: Type.Optional(Type.Array(Type.Union([Type.Number(), Type.String()]), { description: 'ugc_right为16/128时, 权限作用的QQ号数组' })),
16+
});
17+
18+
type PayloadType = Static<typeof PayloadSchema>;
19+
20+
const ReturnSchema = Type.Object({
21+
tid: Type.String({ description: '说说Tid' }),
22+
}, { description: '发说说结果' });
23+
24+
type ReturnType = Static<typeof ReturnSchema>;
25+
26+
export class SendQzoneMsg extends OneBotAction<PayloadType, ReturnType> {
27+
override actionName = ActionName.SendQzoneMsg;
28+
override actionSummary = '发表QQ空间说说';
29+
override actionDescription = '在QQ空间(Qzone)发表说说, 支持纯文字或带(多)图, 可设置查看权限';
30+
override actionTags = ['扩展接口'];
31+
override payloadExample = ExtendsActionsExamples.SendQzoneMsg.payload;
32+
override returnExample = ExtendsActionsExamples.SendQzoneMsg.response;
33+
34+
override payloadSchema = PayloadSchema;
35+
override returnSchema = ReturnSchema;
36+
37+
async _handle (payload: PayloadType): Promise<ReturnType> {
38+
const ugcRight = payload.ugc_right !== undefined ? +payload.ugc_right : 1;
39+
if (!ValidUgcRights.includes(ugcRight as typeof ValidUgcRights[number])) {
40+
throw new Error(`ugc_right 参数不合法, 允许的值为: ${ValidUgcRights.join(', ')}`);
41+
}
42+
const targetUins = payload.target_uins?.map((uin) => +uin).filter((uin) => !Number.isNaN(uin));
43+
if ((ugcRight === 16 || ugcRight === 128) && (!targetUins || targetUins.length === 0)) {
44+
throw new Error('ugc_right 为 16 或 128 时, target_uins 不能为空');
45+
}
46+
47+
const richvals: string[] = [];
48+
const cleanupPaths: string[] = [];
49+
try {
50+
for (const image of payload.images ?? []) {
51+
const downloadResult = await uriToLocalFile(this.core.NapCatTempPath, image);
52+
if (!downloadResult.success) {
53+
throw new Error(`图片${image}处理失败, ${downloadResult.errMsg}`);
54+
}
55+
if (!downloadResult.isLocal) {
56+
cleanupPaths.push(downloadResult.path);
57+
}
58+
const base64 = readFileSync(downloadResult.path).toString('base64');
59+
const richval = await this.core.apis.WebApi.uploadImageToQzone(base64);
60+
richvals.push(richval);
61+
}
62+
63+
const result = await this.core.apis.WebApi.publishQzoneMsg(payload.content, richvals, ugcRight, targetUins);
64+
return result;
65+
} finally {
66+
for (const p of cleanupPaths) {
67+
if (existsSync(p)) {
68+
await unlink(p).catch(() => { });
69+
}
70+
}
71+
}
72+
}
73+
}

packages/napcat-onebot/action/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ import { RefuseOnlineFile } from './file/online/RefuseOnlineFile';
160160
import { GetFilesetId } from './file/flash/GetFilesetIdByCode';
161161
import { FetchPttText } from '@/napcat-onebot/action/msg/FetchPttText';
162162
import { GetGroupSignedList } from './extends/GetGroupSignedList';
163+
import { SendQzoneMsg } from './extends/SendQzoneMsg';
163164

164165
export function getAllHandlers (obContext: NapCatOneBot11Adapter, core: NapCatCore) {
165166
const actionHandlers = [
@@ -216,6 +217,7 @@ export function getAllHandlers (obContext: NapCatOneBot11Adapter, core: NapCatCo
216217
new RenameGroupFile(obContext, core),
217218
new TransGroupFile(obContext, core),
218219
new GetGroupSignedList(obContext, core),
220+
new SendQzoneMsg(obContext, core),
219221
// onebot11
220222
new SendLike(obContext, core),
221223
new GetMsg(obContext, core),

packages/napcat-onebot/action/router.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,4 +214,7 @@ export const ActionName = {
214214
CancelOnlineFile: 'cancel_online_file',
215215

216216
GetGroupSignedList: 'get_group_signed_list',
217+
218+
// QQ 空间扩展
219+
SendQzoneMsg: 'send_qzone_msg',
217220
} as const;

0 commit comments

Comments
 (0)