diff --git a/packages/core/src/bridge/actions/group-album.ts b/packages/core/src/bridge/actions/group-album.ts new file mode 100644 index 00000000..a8bfe139 --- /dev/null +++ b/packages/core/src/bridge/actions/group-album.ts @@ -0,0 +1,76 @@ +// Group album actions via TRPC protocol + +import type { Bridge } from '../bridge'; +import { protoEncode, protoDecode } from '@/protobuf/decode'; +import { GetMediaListRequestSchema, GetMediaListResponseSchema } from '../proto/oidb-action'; + +export interface GroupAlbumMediaResult { + mediaList: any[]; + nextAttachInfo: string; + raw: any; +} + +function convertBigIntToString(obj: any): any { + if (obj === null || obj === undefined) return obj; + if (typeof obj === 'bigint') return obj.toString(); + if (Array.isArray(obj)) return obj.map(convertBigIntToString); + if (typeof obj === 'object') { + const result: any = {}; + for (const key in obj) { + result[key] = convertBigIntToString(obj[key]); + } + return result; + } + return obj; +} + +export async function getGroupAlbumMediaList( + bridge: Bridge, + groupId: number, + albumId: string, + attachInfo: string = '' +): Promise { + const traceId = `_${Date.now()}_${Math.floor(Math.random() * 100000)}`; + + const body = protoEncode({ + field1: 0, + field2: new Uint8Array(0), + field3: new Uint8Array(0), + reqInfo: { + groupId: groupId.toString(), + albumId: albumId, + field3: 0, + attachInfo: attachInfo, // 填入翻页游标 + field5: '', + }, + traceId: traceId, + extMap: [{ key: 'fc-appid', value: '100' }], + }, GetMediaListRequestSchema); + + const result = await bridge.sendRawPacket( + 'QunAlbum.trpc.qzone.webapp_qun_media.QunMedia.GetMediaList', + body, + 15000 + ); + + if (!result.success || !result.gotResponse || !result.responseData) { + throw new Error(result.errorMessage || 'failed to get album media list'); + } + + const resp = protoDecode(result.responseData, GetMediaListResponseSchema); + + // 校验外层 TRPC 状态码 + if (!resp || (resp as any).field1 !== 0) { + throw new Error(`fetch album media list error: retCode ${(resp as any)?.field1 || 'unknown'}`); + } + + const data = (resp as any).data || {}; + const mediaList = data.mediaList || []; + const nextAttachInfo = data.nextAttachInfo || ''; + + return convertBigIntToString({ + mediaList, + nextAttachInfo, + raw: resp + }); +} diff --git a/packages/core/src/bridge/bridge.ts b/packages/core/src/bridge/bridge.ts index 0cd1692f..4f1a17bb 100644 --- a/packages/core/src/bridge/bridge.ts +++ b/packages/core/src/bridge/bridge.ts @@ -111,7 +111,10 @@ import { getCookiesStr as getCookiesStr_, getCsrfToken as getCsrfToken_, getCredentials as getCredentials_, + getGroupAlbumListWeb as getGroupAlbumListWeb_, + uploadImageToGroupAlbumWeb as uploadImageToGroupAlbumWeb_, } from './web-actions'; +import { getGroupAlbumMediaList as getGroupAlbumMediaList_ } from './actions/group-album'; import type { GroupFilesResult } from './actions/group-file'; import type { MediaIndexNode } from './actions/shared'; import { BridgeEventBus } from './event-bus'; @@ -645,6 +648,18 @@ export class Bridge { return getGroupEssenceAll_(this, groupId); } + async getGroupAlbumList(groupId: number): Promise { + return getGroupAlbumListWeb_(this, groupId); + } + + async uploadImageToGroupAlbum(groupId: number, albumId: string, albumName: string, filePath: string): Promise { + return uploadImageToGroupAlbumWeb_(this, groupId, albumId, albumName, filePath); + } + + async getGroupAlbumMediaList(groupId: number, albumId: string): Promise { + return getGroupAlbumMediaList_(this, groupId, albumId); + } + async sendGroupNotice(groupId: number, content: string, options?: any) { return sendGroupNotice_(this, groupId, content, options); } diff --git a/packages/core/src/bridge/proto/oidb-action.ts b/packages/core/src/bridge/proto/oidb-action.ts index 59012e4b..90c55574 100644 --- a/packages/core/src/bridge/proto/oidb-action.ts +++ b/packages/core/src/bridge/proto/oidb-action.ts @@ -1120,4 +1120,82 @@ export const Oidb0xf16ReqSchema = { field12: { field: 12, type: 'uint32' as const }, } satisfies ProtoSchema; -export const Oidb0xf16RespSchema = {} satisfies ProtoSchema; \ No newline at end of file +export const Oidb0xf16RespSchema = {} satisfies ProtoSchema; + +///////// --- group album +// --- Request Schemas --- + +export const ExtMapEntrySchema = { + key: { field: 1, type: 'string' as const }, + value: { field: 2, type: 'string' as const }, +} satisfies ProtoSchema; + +export const ReqInfoSchema = { + groupId: { field: 1, type: 'string' as const }, + albumId: { field: 2, type: 'string' as const }, + field3: { field: 3, type: 'int32' as const }, + attachInfo: { field: 4, type: 'string' as const }, + field5: { field: 5, type: 'string' as const }, +} satisfies ProtoSchema; + +export const GetMediaListRequestSchema = { + field1: { field: 1, type: 'int32' as const }, + field2: { field: 2, type: 'bytes' as const }, + field3: { field: 3, type: 'bytes' as const }, + reqInfo: { field: 4, type: 'message' as const, schema: ReqInfoSchema }, + traceId: { field: 5, type: 'string' as const }, + extMap: { field: 10, type: 'repeated_message' as const, schema: ExtMapEntrySchema }, +} satisfies ProtoSchema; + +// --- Response Schemas --- + +export const UrlInfoSchema = { + url: { field: 1, type: 'string' as const }, + width: { field: 2, type: 'uint32' as const }, + height: { field: 3, type: 'uint32' as const }, +} satisfies ProtoSchema; + +export const PhotoUrlSchema = { + spec: { field: 1, type: 'uint32' as const }, + url: { field: 2, type: 'message' as const, schema: UrlInfoSchema } +} satisfies ProtoSchema; + +export const ImageInfoSchema = { + name: { field: 1, type: 'string' as const }, + sloc: { field: 2, type: 'string' as const }, + lloc: { field: 3, type: 'string' as const }, + photoUrls: { field: 4, type: 'repeated_message' as const, schema: PhotoUrlSchema }, + defaultUrl: { field: 5, type: 'message' as const, schema: UrlInfoSchema }, + isGif: { field: 6, type: 'bool' as const }, + hasRaw: { field: 7, type: 'bool' as const }, +} satisfies ProtoSchema; + +export const MediaInfoSchema = { + type: { field: 1, type: 'uint32' as const }, + image: { field: 2, type: 'message' as const, schema: ImageInfoSchema }, + uploader: { field: 6, type: 'string' as const }, + batchId: { field: 7, type: 'uint64' as const }, + uploadTime: { field: 8, type: 'uint64' as const }, +} satisfies ProtoSchema; + +export const GetMediaListRspDataSchema = { + albumInfo: { + field: 1, + type: 'message' as const, + schema: { + albumId: { field: 1, type: 'string' as const }, + owner: { field: 2, type: 'string' as const }, + name: { field: 3, type: 'string' as const }, + } satisfies ProtoSchema + }, + mediaList: { field: 3, type: 'repeated_message' as const, schema: MediaInfoSchema }, + prevAttachInfo: { field: 4, type: 'string' as const }, + nextAttachInfo: { field: 5, type: 'string' as const }, // 用于翻页拉取的 token +} satisfies ProtoSchema; + +export const GetMediaListResponseSchema = { + field1: { field: 1, type: 'int32' as const }, + field2: { field: 2, type: 'bytes' as const }, + field3: { field: 3, type: 'bytes' as const }, + data: { field: 4, type: 'message' as const, schema: GetMediaListRspDataSchema }, // 将原本的 bytes 修正为 message 解析 +} satisfies ProtoSchema; \ No newline at end of file diff --git a/packages/core/src/bridge/web-actions/group-album.ts b/packages/core/src/bridge/web-actions/group-album.ts new file mode 100644 index 00000000..205611c8 --- /dev/null +++ b/packages/core/src/bridge/web-actions/group-album.ts @@ -0,0 +1,29 @@ +// Group album actions via web API + +import type { Bridge } from '../bridge'; +import { getGroupAlbumList, uploadImageToGroupAlbum } from '../web/group-album'; +import { getCookies } from './cookies'; + +export async function getGroupAlbumListWeb(bridge: Bridge, groupId: number) { + const groupCode = groupId.toString(); + const uin = bridge.qqInfo.uin; + const cookieObject = await getCookies(bridge, 'qzone.qq.com'); + + const albumData = await getGroupAlbumList(cookieObject, groupCode, uin); + + return albumData?.album || []; +} + +export async function uploadImageToGroupAlbumWeb( + bridge: Bridge, + groupId: number, + albumId: string, + albumName: string, + filePath: string +): Promise { + const groupCode = groupId.toString(); + const uin = bridge.qqInfo.uin; + const cookieObject = await getCookies(bridge, 'qzone.qq.com'); + + await uploadImageToGroupAlbum(cookieObject, groupCode, albumId, albumName, filePath, uin); +} diff --git a/packages/core/src/bridge/web-actions/index.ts b/packages/core/src/bridge/web-actions/index.ts index 8cf8375b..c7f834f8 100644 --- a/packages/core/src/bridge/web-actions/index.ts +++ b/packages/core/src/bridge/web-actions/index.ts @@ -25,3 +25,8 @@ export { getGroupNotice, deleteGroupNoticeByFid, } from './group-notice'; + +export { + getGroupAlbumListWeb, + uploadImageToGroupAlbumWeb, +} from './group-album'; diff --git a/packages/core/src/bridge/web/group-album.ts b/packages/core/src/bridge/web/group-album.ts new file mode 100644 index 00000000..f6c33d03 --- /dev/null +++ b/packages/core/src/bridge/web/group-album.ts @@ -0,0 +1,250 @@ +import { RequestUtil, cookieToString, getBknFromCookie } from './request-util'; +import { createHash } from 'crypto'; +import { readFileSync, statSync, createReadStream, writeFileSync, unlinkSync } from 'fs'; +import { basename, join } from 'path'; +import { tmpdir } from 'os'; + +// 群相册信息 +export interface GroupAlbumInfo { + id: string; + name: string; + picNum: number; + createTime: number; + [key: string]: any; +} + +// 获取群相册列表返回 +export interface GroupAlbumListRet { + album: GroupAlbumInfo[]; + [key: string]: any; +} + +// 相册媒体信息 +export interface AlbumMediaInfo { + photoId: string; + url: string; + uploadTime: number; + [key: string]: any; +} + +/** + * 获取群相册列表 + */ +export async function getGroupAlbumList( + cookieObject: Record, + groupId: string, + uin: string +): Promise { + if (!cookieObject || typeof cookieObject !== 'object') { + throw new Error('cookieObject is required'); + } + + const bkn = getBknFromCookie(cookieObject); + + const url = `https://h5.qzone.qq.com/proxy/domain/u.photo.qzone.qq.com/cgi-bin/upp/qun_list_album_v2?${new URLSearchParams({ + random: '7570', + g_tk: bkn, + format: 'json', + inCharset: 'utf-8', + outCharset: 'utf-8', + qua: 'V1_IPH_SQ_6.2.0_0_HDBM_T', + cmd: 'qunGetAlbumList', + qunId: groupId, + qunid: groupId, + start: '0', + num: '1000', + uin, + getMemberRole: '0', + }).toString()}`; + + const ret = await RequestUtil.HttpGetJson<{ data: GroupAlbumListRet }>( + url, + 'GET', + '', + { Cookie: cookieToString(cookieObject) } + ); + + if (!ret || typeof ret !== 'object') { + throw new Error('invalid response from qzone api'); + } + + return ret.data; +} + +/** + * 创建群相册上传会话 + */ +async function createAlbumUploadSession( + groupId: string, + albumId: string, + albumName: string, + filePath: string, + skey: string, + pskey: string, + imgMd5: string, + uin: string +): Promise { + const imgSize = statSync(filePath).size; + const imgName = basename(filePath); + const bkn = getBknFromCookie({ skey }); + const timestamp = Math.floor(Date.now() / 1000); + + const body = { + control_req: [{ + uin, + token: { type: 4, data: pskey, appid: 5 }, + appid: 'qun', + checksum: imgMd5, + check_type: 0, + file_len: imgSize, + env: { refer: 'qzone', deviceInfo: 'h5' }, + model: 0, + biz_req: { + sPicTitle: imgName, + sPicDesc: '', + sAlbumName: albumName, + sAlbumID: albumId, + iAlbumTypeID: 0, + iBitmap: 0, + iUploadType: 0, + iUpPicType: 0, + iBatchID: timestamp, + sPicPath: '', + iPicWidth: 0, + iPicHight: 0, + iWaterType: 0, + iDistinctUse: 0, + iNeedFeeds: 1, + iUploadTime: timestamp, + mapExt: { appid: 'qun', userid: groupId }, + stExtendInfo: { mapParams: { photo_num: '1', video_num: '0', batch_num: '1' } }, + }, + session: '', + asy_upload: 0, + cmd: 'FileUpload', + }], + }; + + const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileBatchControl/${imgMd5}?g_tk=${bkn}`; + const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`; + + const response = await RequestUtil.HttpGetJson<{ data: { session: string }, ret: number, msg: string }>( + api, + 'POST', + JSON.stringify(body), + { Cookie: cookie, 'Content-Type': 'application/json' } + ); + + if (response.ret !== 0 || !response.data?.session) { + throw new Error(`创建上传会话失败: ${response.msg}`); + } + + return response.data.session; +} + + +/** + * 修复后的上传图片分片方法 + */ +async function uploadAlbumSlice( + session: string, + filePath: string, + // 移除 offset 和 chunkSize 参数,改为在内部控制 + imgMd5: string, + skey: string, + pskey: string, + uin: string +): Promise { + const img_size = statSync(filePath).size; + const slice_size = 16384; // 严格使用 16KB + const bkn = getBknFromCookie({ skey }); + const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`; + + const stream = createReadStream(filePath, { highWaterMark: slice_size }); + let seq = 0; + let offset = 0; + + for await (const chunk of stream) { + const end = Math.min(offset + chunk.length, img_size); + + // 使用原生的 FormData 构建安全的 multipart 请求 + const form = new FormData(); + form.append('uin', uin); + form.append('appid', 'qun'); + form.append('session', session); + form.append('offset', offset.toString()); + form.append('data', new Blob([chunk as Buffer], { type: 'application/octet-stream' }), 'blob'); + form.append('checksum', ''); + form.append('check_type', '0'); + form.append('retry', '0'); + form.append('seq', seq.toString()); + form.append('end', end.toString()); + form.append('cmd', 'FileUpload'); + form.append('slice_size', slice_size.toString()); + form.append('biz_req.iUploadType', '0'); + + const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileUpload?seq=${seq}&retry=0&offset=${offset}&end=${end}&total=${img_size}&type=form&g_tk=${bkn}`; + + // 放弃使用 HttpGetJson,改用原生 fetch,它能完美处理 FormData 和边界 + const response = await fetch(api, { + method: 'POST', + headers: { + Cookie: cookie, + // 注意:不要手动设置 Content-Type,fetch 会自动加上带有正确 boundary 的 multipart/form-data + }, + body: form, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const post = await response.json() as { ret: number, msg: string }; + if (post.ret !== 0) { + throw new Error(`分片 ${seq} 上传失败: ${post.msg}`); + } + + offset += chunk.length; + seq++; + } +} + +/** + * 上传图片到群相册 + */ +export async function uploadImageToGroupAlbum( + cookieObject: Record, + groupId: string, + albumId: string, + albumName: string, + filePath: string, + uin: string +): Promise { + const { loadBinarySource } = await import('../highway/utils'); + const loaded = await loadBinarySource(filePath, 'album image'); + + let tempFile: string | null = null; + let actualPath = filePath; + + if (/^(https?:\/\/|base64:\/\/)/i.test(filePath)) { + tempFile = join(tmpdir(), `album_${Date.now()}_${Math.random().toString(36).slice(2)}.tmp`); + writeFileSync(tempFile, loaded.bytes); + actualPath = tempFile; + } + + try { + const imgMd5 = createHash('md5').update(loaded.bytes).digest('hex'); + // const imgSize = loaded.bytes.length; + const skey = cookieObject.skey || ''; + const pskey = cookieObject.p_skey || ''; + + const session = await createAlbumUploadSession(groupId, albumId, albumName, actualPath, skey, pskey, imgMd5, uin); + + await uploadAlbumSlice(session, actualPath, imgMd5, skey, pskey, uin); + + } finally { + if (tempFile) { + try { unlinkSync(tempFile); } catch { /* ignore */ } + } + } +} diff --git a/packages/core/src/onebot/actions/group-album.ts b/packages/core/src/onebot/actions/group-album.ts new file mode 100644 index 00000000..322ec249 --- /dev/null +++ b/packages/core/src/onebot/actions/group-album.ts @@ -0,0 +1,54 @@ +import type { ApiHandler, ApiActionContext } from '../api-handler'; +import { asNumber, asString } from '../api-handler'; +import { RETCODE, failedResponse, okResponse } from '../types'; + +export function register(h: ApiHandler, ctx: ApiActionContext): void { + h.registerAction('get_group_album_list', async (params) => { + const groupId = asNumber(params.group_id); + if (!groupId) return failedResponse(RETCODE.BAD_REQUEST, 'group_id is required'); + + try { + const albumList = await ctx.getGroupAlbumList(groupId); + return okResponse(albumList); + } catch (error) { + const message = error instanceof Error ? error.message : 'failed to get group album list'; + return failedResponse(RETCODE.INTERNAL_ERROR, message); + } + }); + + h.registerAction('upload_image_to_qun_album', async (params) => { + const groupId = asNumber(params.group_id); + const albumId = asString(params.album_id); + const albumName = asString(params.album_name); + const file = asString(params.file); + + if (!groupId) return failedResponse(RETCODE.BAD_REQUEST, 'group_id is required'); + if (!albumId) return failedResponse(RETCODE.BAD_REQUEST, 'album_id is required'); + if (!albumName) return failedResponse(RETCODE.BAD_REQUEST, 'album_name is required'); + if (!file) return failedResponse(RETCODE.BAD_REQUEST, 'file is required'); + + try { + await ctx.uploadImageToGroupAlbum(groupId, albumId, albumName, file); + return okResponse(null); + } catch (error) { + const message = error instanceof Error ? error.message : 'failed to upload image to group album'; + return failedResponse(RETCODE.INTERNAL_ERROR, message); + } + }); + + h.registerAction('get_group_album_media_list', async (params) => { + const groupId = asNumber(params.group_id); + const albumId = asString(params.album_id); + + if (!groupId) return failedResponse(RETCODE.BAD_REQUEST, 'group_id is required'); + if (!albumId) return failedResponse(RETCODE.BAD_REQUEST, 'album_id is required'); + + try { + const mediaList = await ctx.getGroupAlbumMediaList(groupId, albumId); + return okResponse(mediaList); + } catch (error) { + const message = error instanceof Error ? error.message : 'failed to get group album media list'; + return failedResponse(RETCODE.INTERNAL_ERROR, message); + } + }); +} diff --git a/packages/core/src/onebot/api-handler.ts b/packages/core/src/onebot/api-handler.ts index f9762d41..2a5b9107 100644 --- a/packages/core/src/onebot/api-handler.ts +++ b/packages/core/src/onebot/api-handler.ts @@ -10,6 +10,7 @@ import { register as registerGroupAdmin } from './actions/group-admin'; import { register as registerGroupFile } from './actions/group-file'; import { register as registerRequest } from './actions/request'; import { register as registerExtended } from './actions/extended'; +import { register as registerGroupAlbum } from './actions/group-album'; import { WebHonorType } from '@/bridge/web/group-honor'; // import {ClientKeyInfo} from "@/bridge/bridge"; @@ -118,6 +119,9 @@ export interface ApiActionContext { getGroupHonorInfo: (groupId: number, type: WebHonorType | string) => Promise; getGroupEssence: (groupId: number, pageStart?: number, pageLimit?: number) => Promise; getGroupEssenceAll: (groupId: number) => Promise; + getGroupAlbumList: (groupId: number) => Promise; + uploadImageToGroupAlbum: (groupId: number, albumId: string, albumName: string, filePath: string) => Promise; + getGroupAlbumMediaList: (groupId: number, albumId: string) => Promise; sendGroupNotice: (groupId: number, content: string, options?: any) => Promise; getGroupNotice: (groupId: number) => Promise; deleteGroupNotice: (groupId: number, fid: string) => Promise; @@ -145,6 +149,7 @@ export class ApiHandler { registerGroupFile(this, context); registerRequest(this, context); registerExtended(this, context); + registerGroupAlbum(this, context); } registerAction(action: string, handler: ActionHandler): void { diff --git a/packages/core/src/onebot/instance-context.ts b/packages/core/src/onebot/instance-context.ts index 059fd985..910d24f0 100644 --- a/packages/core/src/onebot/instance-context.ts +++ b/packages/core/src/onebot/instance-context.ts @@ -187,6 +187,9 @@ export function buildApiContext(ref: OneBotInstanceContext): ApiActionContext { getGroupHonorInfo: (groupId: number, type: WebHonorType | string) => bridge.getGroupHonorInfo(groupId, type), getGroupEssence: (groupId, pageStart = 0, pageLimit = 50) => bridge.getGroupEssence(groupId, pageStart, pageLimit), getGroupEssenceAll: (groupId) => bridge.getGroupEssenceAll(groupId), + getGroupAlbumList: (groupId) => bridge.getGroupAlbumList(groupId), + uploadImageToGroupAlbum: (groupId, albumId, albumName, filePath) => bridge.uploadImageToGroupAlbum(groupId, albumId, albumName, filePath), + getGroupAlbumMediaList: (groupId, albumId) => bridge.getGroupAlbumMediaList(groupId, albumId), sendGroupNotice: (groupId, content, options) => bridge.sendGroupNotice(groupId, content, options), getGroupNotice: (groupId) => bridge.getGroupNotice(groupId), deleteGroupNotice: (groupId, fid) => bridge.deleteGroupNotice(groupId, fid),