diff --git a/packages/core/src/bridge/apis/profile.ts b/packages/core/src/bridge/apis/profile.ts index 309b717e..660c2fda 100644 --- a/packages/core/src/bridge/apis/profile.ts +++ b/packages/core/src/bridge/apis/profile.ts @@ -5,6 +5,8 @@ import type { SetStatusReq, SetStatusResp, } from '@snowluma/proto-defs/oidb-actions/base'; +import { AddCustomFace } from '@snowluma/protocol/oidb-services/custom-face/add-custom-face'; +import { DeleteCustomFace } from '@snowluma/protocol/oidb-services/custom-face/delete-custom-face'; import { fetchHighwaySession, uploadHighwayHttp } from '@snowluma/protocol/highway'; import { computeHashes, loadBinarySource } from '@snowluma/protocol/highway/utils'; import { GetLike, type LikeInfo } from '@snowluma/protocol/oidb-services/profile/get-like'; @@ -147,4 +149,18 @@ export class ProfileApi { const faceIds = resp.item?.faceIds || []; return faceIds.slice(0, count).map((id: string) => `https://p.qpic.cn/qq_expression/${this.ctx.identity.uin}/${id}/0`); } + + /** 删除一个收藏表情(custom face)。emoji_id 来自 fetchCustomFace 返回的 URL 路径段。 */ + deleteCustomFace(emojiId: string): Promise { + return DeleteCustomFace.invoke(this.ctx, { uin: this.ctx.identity.uin, emojiId }); + } + + /** + * 添加收藏表情(custom face)。imageSource 支持 file:///、base64://、http(s):// + * (复用 highway utils 的 loadBinarySource)。返回新 emoji_id。 + */ + async addCustomFace(imageSource: string): Promise { + const { bytes } = await loadBinarySource(imageSource, 'custom-face'); + return AddCustomFace.invoke(this.ctx, { uin: this.ctx.identity.uin, imageBytes: bytes }); + } } diff --git a/packages/mcp/src/generated/catalog.ts b/packages/mcp/src/generated/catalog.ts index acccda55..2e1a65c7 100644 --- a/packages/mcp/src/generated/catalog.ts +++ b/packages/mcp/src/generated/catalog.ts @@ -110,7 +110,7 @@ export const ACTIONS: CatalogAction[] = [ { "name": "_mark_all_as_read", "aliases": [], - "summary": "标记全部已读", + "summary": "标记全部已读(no-op,待 RE 全读 cmd)", "readOnly": false, "params": [], "invariants": [], @@ -231,6 +231,38 @@ export const ACTIONS: CatalogAction[] = [ }, "category": "扩展" }, + { + "name": "add_custom_face", + "aliases": [], + "summary": "添加收藏表情", + "readOnly": false, + "params": [ + { + "name": "file", + "type": "string", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "invariants": [], + "inputSchema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "file" + ], + "additionalProperties": true + }, + "category": "扩展" + }, { "name": "bot_exit", "aliases": [], @@ -673,6 +705,38 @@ export const ACTIONS: CatalogAction[] = [ }, "category": "群相册" }, + { + "name": "delete_custom_face", + "aliases": [], + "summary": "删除收藏表情", + "readOnly": false, + "params": [ + { + "name": "emoji_id", + "type": "string", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "invariants": [], + "inputSchema": { + "type": "object", + "properties": { + "emoji_id": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "emoji_id" + ], + "additionalProperties": true + }, + "category": "扩展" + }, { "name": "delete_essence_msg", "aliases": [], @@ -1082,6 +1146,15 @@ export const ACTIONS: CatalogAction[] = [ "minimum": 0 }, "default": 10 + }, + { + "name": "return_type", + "type": "string", + "required": false, + "schema": { + "type": "string" + }, + "default": "url" } ], "invariants": [], @@ -1092,6 +1165,10 @@ export const ACTIONS: CatalogAction[] = [ "type": "integer", "minimum": 0, "default": 10 + }, + "return_type": { + "type": "string", + "default": "url" } }, "additionalProperties": true @@ -1666,7 +1743,7 @@ export const ACTIONS: CatalogAction[] = [ { "name": "get_file", "aliases": [], - "summary": "获取文件(未实现)", + "summary": "获取文件信息(仅图片/语音缓存;群文件请用 get_group_file_url)", "readOnly": true, "params": [ { @@ -1767,8 +1844,7 @@ export const ACTIONS: CatalogAction[] = [ "type": "int", "required": false, "schema": { - "type": "integer", - "minimum": 0 + "type": "integer" }, "default": 0 }, @@ -1793,7 +1869,6 @@ export const ACTIONS: CatalogAction[] = [ }, "message_id": { "type": "integer", - "minimum": 0, "default": 0 }, "count": { @@ -2448,8 +2523,7 @@ export const ACTIONS: CatalogAction[] = [ "type": "int", "required": false, "schema": { - "type": "integer", - "minimum": 0 + "type": "integer" }, "default": 0 }, @@ -2475,7 +2549,6 @@ export const ACTIONS: CatalogAction[] = [ }, "message_id": { "type": "integer", - "minimum": 0, "default": 0 }, "count": { @@ -2528,13 +2601,33 @@ export const ACTIONS: CatalogAction[] = [ { "name": "get_group_shut_list", "aliases": [], - "summary": "获取群禁言列表(占位)", + "summary": "获取群禁言列表", "readOnly": true, - "params": [], + "params": [ + { + "name": "group_id", + "type": "uint", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + }, + "desc": "群号" + } + ], "invariants": [], "inputSchema": { "type": "object", - "properties": {}, + "properties": { + "group_id": { + "type": "integer", + "minimum": 1, + "description": "群号" + } + }, + "required": [ + "group_id" + ], "additionalProperties": true }, "category": "扩展" @@ -2852,6 +2945,20 @@ export const ACTIONS: CatalogAction[] = [ }, "category": "扩展" }, + { + "name": "get_rkey_server", + "aliases": [], + "summary": "获取 rkey 服务器信息", + "readOnly": true, + "params": [], + "invariants": [], + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "category": "扩展" + }, { "name": "get_status", "aliases": [], @@ -3250,13 +3357,31 @@ export const ACTIONS: CatalogAction[] = [ "aliases": [ ".ocr_image" ], - "summary": "OCR 图片(未实现)", + "summary": "OCR 图片(服务端,需图片 URL 或已缓存的图片 file_id)", "readOnly": true, - "params": [], + "params": [ + { + "name": "image", + "type": "string", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], "invariants": [], "inputSchema": { "type": "object", - "properties": {}, + "properties": { + "image": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "image" + ], "additionalProperties": true }, "category": "扩展" @@ -3264,16 +3389,77 @@ export const ACTIONS: CatalogAction[] = [ { "name": "rename_group_file", "aliases": [], - "summary": "重命名群文件(未实现)", + "summary": "重命名群文件", "readOnly": false, - "params": [], + "params": [ + { + "name": "group_id", + "type": "uint", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + }, + "desc": "群号" + }, + { + "name": "file_id", + "type": "string", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "current_parent_directory", + "type": "string", + "required": false, + "schema": { + "type": "string" + }, + "default": "/" + }, + { + "name": "new_name", + "type": "string", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], "invariants": [], "inputSchema": { "type": "object", - "properties": {}, + "properties": { + "group_id": { + "type": "integer", + "minimum": 1, + "description": "群号" + }, + "file_id": { + "type": "string", + "minLength": 1 + }, + "current_parent_directory": { + "type": "string", + "default": "/" + }, + "new_name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "group_id", + "file_id", + "new_name" + ], "additionalProperties": true }, - "category": "扩展" + "category": "群文件" }, { "name": "rename_group_file_folder", @@ -5872,7 +6058,7 @@ export const CATEGORIES: CatalogCategory[] = [ }, { "category": "群文件", - "count": 11 + "count": 12 }, { "category": "请求", @@ -5880,7 +6066,7 @@ export const CATEGORIES: CatalogCategory[] = [ }, { "category": "扩展", - "count": 85 + "count": 87 }, { "category": "群相册", diff --git a/packages/onebot/src/actions/extended.ts b/packages/onebot/src/actions/extended.ts index f0c7f8e2..16696d87 100644 --- a/packages/onebot/src/actions/extended.ts +++ b/packages/onebot/src/actions/extended.ts @@ -213,6 +213,36 @@ export const actions = [ }, }), + // 删除收藏表情(Faceroam.OpReq opType=2) + defineAction({ + name: 'delete_custom_face', + summary: '删除收藏表情', + params: { emoji_id: f.string({ allowEmpty: false }) }, + run: async (p, ctx) => { + try { + await ctx.bridge.apis.profile.deleteCustomFace(p.emoji_id); + return okResponse(); + } catch (e) { + return failedResponse(RETCODE.ACTION_FAILED, String(e)); + } + }, + }), + + // 添加收藏表情(ImgStore.BDHExpressionRoam + highway HTTP 上传) + defineAction({ + name: 'add_custom_face', + summary: '添加收藏表情', + params: { file: f.string({ allowEmpty: false }) }, + run: async (p, ctx) => { + try { + const emojiId = await ctx.bridge.apis.profile.addCustomFace(p.file); + return okResponse({ emoji_id: emojiId }); + } catch (e) { + return failedResponse(RETCODE.ACTION_FAILED, String(e)); + } + }, + }), + // 消息历史 groupAction({ name: 'get_group_msg_history', @@ -803,10 +833,22 @@ export const actions = [ name: 'fetch_custom_face', summary: '获取自定义表情', readOnly: true, - params: { count: f.int({ min: 0 }).default(10) }, + params: { + count: f.int({ min: 0 }).default(10), + // return_type=url 返回图片 URL(默认,给前端显示); + // return_type=id 返回 emoji_id(给 delete/add 用)。 + return_type: f.string().default('url'), + }, run: async (p, ctx) => { try { const urls = await ctx.bridge.apis.profile.fetchCustomFace(p.count); + if (p.return_type === 'id') { + const emojiIds = urls.map((url) => { + const m = /\/qq_expression\/[^/]+\/([^/]+)\//.exec(url); + return m ? m[1] : ''; + }).filter(Boolean); + return okResponse(emojiIds); + } return okResponse(urls); } catch (e) { return failedResponse(RETCODE.ACTION_FAILED, String(e)); diff --git a/packages/proto-defs/src/oidb-actions/base.ts b/packages/proto-defs/src/oidb-actions/base.ts index ef50b9db..4143558e 100644 --- a/packages/proto-defs/src/oidb-actions/base.ts +++ b/packages/proto-defs/src/oidb-actions/base.ts @@ -557,8 +557,101 @@ export interface FaceroamOpReq { inner?: pb<1, FaceroamOpReqInner>; uin?: pb<2, uint_64>; field3?: pb<3, uint_32>; + body?: pb<5, FaceroamOpBody>; field6?: pb<6, uint_32>; } +export interface FaceroamOpBody { + emojiId?: pb<1, string>; +} + +// ImgStore.BDHExpressionRoam — 收藏表情上传申请(add 第1步) +// 请求 body 结构来自 9.9.26-44343 frida 抓包。 +export interface BDHExpressionRoamInner { + field1?: pb<1, uint_32>; + uin?: pb<2, uint_64>; + field3?: pb<3, uint_32>; + /** 图片 MD5(16B 二进制,非 hex 字符串)。 */ + md5?: pb<4, bytes>; + /** 图片字节数。 */ + filesize?: pb<5, uint_32>; + field7?: pb<7, uint_32>; + field8?: pb<8, uint_32>; + field9?: pb<9, uint_32>; + ver?: pb<13, string>; + field16?: pb<16, uint_32>; +} +export interface BDHExpressionRoamTailInner { + field1?: pb<1, uint_32>; + field2?: pb<2, uint_32>; + field3?: pb<3, string>; +} +export interface BDHExpressionRoamTail { + inner?: pb<1, BDHExpressionRoamTailInner>; + field2?: pb<2, uint_32>; +} +export interface BDHExpressionRoamReq { + field1?: pb<1, uint_32>; + field2?: pb<2, uint_32>; + inner?: pb<3, BDHExpressionRoamInner>; + field7?: pb<7, uint_32>; + tail?: pb<1001, BDHExpressionRoamTail>; +} + +// BDHExpressionRoam 响应:含上传节点 + token(add 第2步 highway 上传用) +export interface BDHExpressionRoamRespInner { + field1?: pb<1, uint_32>; + field2?: pb<2, uint_32>; + field4?: pb<4, uint_32>; + /** repeated varint,抓包见 6 个;上传节点走 fetchHighwaySession,不用这个。 */ + field6?: pb_repeated<6, uint_32>; + field7?: pb_repeated<7, uint_32>; + /** 128B 上传 token,highway head 的 serviceTicket 用它。 */ + token?: pb<8, bytes>; + field12?: pb<12, uint_32>; + field3?: pb<3, bytes>; +} +export interface BDHExpressionRoamResp { + field1?: pb<1, uint_32>; + field2?: pb<2, uint_32>; + inner?: pb<3, BDHExpressionRoamRespInner>; +} + +// highway head(add 第2步,PicUp.DataUp commandId=9) +// 结构对齐 SnowLuma makeHighwayHead 的 msgBaseHead + msgSegHead, +// 但 commandId=9(表情)、segHead.serviceTicket = BDHExpressionRoam token(非 sigSession)、 +// 顶层 f3 = emoji_id、f5 = 82B(含义未知,先不填)。 +export interface FavEmojiHighwayBaseHead { + version?: pb<1, uint_32>; + uin?: pb<2, string>; + command?: pb<3, string>; + seq?: pb<4, uint_32>; + retryTimes?: pb<5, uint_32>; + filesize?: pb<6, uint_64>; + dataFlag?: pb<7, uint_32>; + commandId?: pb<8, uint_32>; +} +export interface FavEmojiHighwaySegHead { + serviceId?: pb<1, uint_32>; + filesize?: pb<2, uint_64>; + dataOffset?: pb<3, uint_64>; + dataLength?: pb<4, uint_64>; + /** 上传凭证 = BDHExpressionRoam token。 */ + serviceTicket?: pb<6, bytes>; + md5?: pb<8, bytes>; + fileMd5?: pb<9, bytes>; +} +export interface FavEmojiIdWrap { + emojiId?: pb<1, string>; +} +export interface FavEmojiHighwayHead { + baseHead?: pb<1, FavEmojiHighwayBaseHead>; + segHead?: pb<2, FavEmojiHighwaySegHead>; + emojiIdWrap?: pb<3, FavEmojiIdWrap>; + field4?: pb<4, uint_32>; + /** 82B,抓包见过但非必需。 */ + field5?: pb<5, bytes>; + field8?: pb<8, uint_32>; +} export interface FaceroamOpRespItem { faceIds?: pb_repeated<1, string>; category?: pb<3, string>; diff --git a/packages/protocol/package.json b/packages/protocol/package.json index b31d7b52..f6a825b1 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -51,6 +51,14 @@ "types": "./src/oidb-services/reaction/get-emoji-likes.ts", "default": "./src/oidb-services/reaction/get-emoji-likes.ts" }, + "./oidb-services/custom-face/delete-custom-face": { + "types": "./src/oidb-services/custom-face/delete-custom-face.ts", + "default": "./src/oidb-services/custom-face/delete-custom-face.ts" + }, + "./oidb-services/custom-face/add-custom-face": { + "types": "./src/oidb-services/custom-face/add-custom-face.ts", + "default": "./src/oidb-services/custom-face/add-custom-face.ts" + }, "./oidb-services/interaction/send-poke": { "types": "./src/oidb-services/interaction/send-poke.ts", "default": "./src/oidb-services/interaction/send-poke.ts" diff --git a/packages/protocol/src/oidb-services/custom-face/add-custom-face.ts b/packages/protocol/src/oidb-services/custom-face/add-custom-face.ts new file mode 100644 index 00000000..22ba70aa --- /dev/null +++ b/packages/protocol/src/oidb-services/custom-face/add-custom-face.ts @@ -0,0 +1,121 @@ +// Faceroam add — 添加收藏表情(custom face):BDHExpressionRoam 申请 → highway HTTP 上传。 +// +// 走的是 trpc service "ImgStore.BDHExpressionRoam"(不是 OIDB),申请上传 +// 拿到 token,然后把图片数据打成 highway 帧用 HTTP POST 发到 +// httpconn?htcmd=0x6FF0087。上传节点 host:port 复用 fetchHighwaySession +// (0x6ff_501)的 serverInfos,端口 80。 +// +// head segHead.serviceTicket = BDHExpressionRoam 响应 token(不是 sigSession, +// 端到端验证过)。head field5 (82B) 抓包见过但非必需,不填也能上传。 + +import { protobuf_decode, protobuf_encode } from '@snowluma/proton'; +import type { + BDHExpressionRoamReq, + BDHExpressionRoamResp, + FavEmojiHighwayHead, +} from '@snowluma/proto-defs/oidb-actions/base'; +import type { BridgeContext } from '../../bridge-context'; +import { fetchHighwaySession } from '../../highway/highway-client'; +import { computeMd5, packHighwayFrame } from '../../highway/utils'; + +export namespace AddCustomFace { + const BDH_SERVICE = 'ImgStore.BDHExpressionRoam'; + + export interface Params { + uin: string; + imageBytes: Uint8Array; + } + + // fetchHighwaySession 需要 sendRawPacket + identity,所以 Deps 比纯 OidbSender 多 identity。 + export type Deps = Pick; + + function md5Hex(md5: Uint8Array): string { + let s = ''; + for (const b of md5) s += b.toString(16).padStart(2, '0'); + return s; + } + + export async function invoke(deps: Deps, params: Params): Promise { + const { uin, imageBytes } = params; + const md5 = computeMd5(imageBytes); + const emojiId = `${uin}_0_0_0_${md5Hex(md5).toUpperCase()}_0_0`; + + // 1. fetchHighwaySession 拿上传节点 host:port(复用 SnowLuma highway-client) + const session = await fetchHighwaySession(deps as unknown as BridgeContext); + const host = session.host; + const port = session.port; + + // 2. BDHExpressionRoam 申请上传 token + const reqBody = protobuf_encode({ + field1: 3, + field2: 1, + inner: { + field1: 0, + uin: BigInt(uin), + field3: 0, + md5, + filesize: imageBytes.length, + field7: 2, + field8: 0, + field9: 1, + ver: '1.0.0', + field16: 1, + }, + field7: 9, + tail: { inner: { field1: 0, field2: 0, field3: '0' }, field2: 1 }, + }); + const result = await deps.sendRawPacket(BDH_SERVICE, reqBody); + if (!result.gotResponse) throw new Error(result.errorMessage || 'BDHExpressionRoam: no response'); + if (!result.success) throw new Error(result.errorMessage || 'BDHExpressionRoam: send failed'); + const resp = protobuf_decode(result.responseData ?? new Uint8Array(0)); + const token = resp.inner?.token; + if (!token) throw new Error('BDHExpressionRoam: response missing token (field8)'); + + // 3. 构造 highway head。serviceTicket = BDHExpressionRoam token。 + const head = protobuf_encode({ + baseHead: { + version: 1, + uin, + command: 'PicUp.DataUp', + seq: 1, + retryTimes: 0, + filesize: BigInt(imageBytes.length), + dataFlag: 16, + commandId: 9, + }, + segHead: { + serviceId: 0, + filesize: BigInt(imageBytes.length), + dataOffset: 0n, + dataLength: BigInt(imageBytes.length), + serviceTicket: token, + md5, + fileMd5: md5, + }, + emojiIdWrap: { emojiId }, + field4: 0, + field8: 9, + // field5 (82B) 抓包见过但非必需,不填 + }); + + // 4. highway 帧(复用 SnowLuma packHighwayFrame,结构和抓包一致) + const frame = packHighwayFrame(head, imageBytes); + + // 5. HTTP POST 上传。端口用 fetchHighwaySession 的 port(抓包确认 httpconn 端口 80)。 + const url = `http://${host}:${port}/cgi-bin/httpconn?htcmd=0x6FF0087&uin=${uin}`; + const httpResp = await fetch(url, { + method: 'POST', + body: frame, + headers: { + Accept: '*/*', + Connection: 'Keep-Alive', + 'User-Agent': 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.2)', + 'Cache-Control': 'no-cache', + Pragma: 'no-cache', + }, + }); + if (!httpResp.ok) throw new Error(`highway upload failed: HTTP ${httpResp.status}`); + + return emojiId; + } +} diff --git a/packages/protocol/src/oidb-services/custom-face/delete-custom-face.ts b/packages/protocol/src/oidb-services/custom-face/delete-custom-face.ts new file mode 100644 index 00000000..8851cb5b --- /dev/null +++ b/packages/protocol/src/oidb-services/custom-face/delete-custom-face.ts @@ -0,0 +1,42 @@ +// Faceroam.OpReq opType=2 — 删除一个收藏表情(custom face)。 +// +// 和 fetch 同一个 trpc service,区别在 field3=2、inner 不带 qqVersion、 +// 业务体 field5 塞 emoji_id(即 custom face id)。id 格式是 +// `_0_0_0__0_0`,直接用 fetch 响应里解析出来的值回传即可, +// 不用自己拼。field2 同样是当前账号 UIN。 +// +// 请求 wire 同样对齐 9.9.26-44343 抓包(单测样本)。响应暂时只看 +// success / errorCode,业务字段等需要时再解。 + +import { protobuf_encode } from '@snowluma/proton'; +import type { FaceroamOpReq } from '@snowluma/proto-defs/oidb-actions/base'; +import type { OidbSender } from '../../oidb-service'; +import { FACEROAM_SERVICE, makeInner } from './shared'; + +export namespace DeleteCustomFace { + export interface Params { + /** 当前账号 UIN,写入 field2。 */ + uin: string; + /** 形如 `_0_0_0__0_0`,来自 fetch 响应。 */ + emojiId: string; + } + + export type Deps = OidbSender; + + export const serialize = (_ctx: Deps, p: Params): FaceroamOpReq => ({ + inner: makeInner(false), + uin: BigInt(p.uin), + field3: 2, + body: { emojiId: p.emojiId }, + }); + + export const encode = (req: FaceroamOpReq): Uint8Array => + protobuf_encode(req); + + export async function invoke(deps: Deps, params: Params): Promise { + const body = encode(serialize(deps, params)); + const result = await deps.sendRawPacket(FACEROAM_SERVICE, body); + if (!result.gotResponse) throw new Error(result.errorMessage || 'Faceroam.OpReq delete: no response'); + if (!result.success) throw new Error(result.errorMessage || 'Faceroam.OpReq delete: send failed'); + } +} diff --git a/packages/protocol/src/oidb-services/custom-face/shared.ts b/packages/protocol/src/oidb-services/custom-face/shared.ts new file mode 100644 index 00000000..fb63d91d --- /dev/null +++ b/packages/protocol/src/oidb-services/custom-face/shared.ts @@ -0,0 +1,27 @@ +// Faceroam.OpReq 共用的常量与请求 inner 构造。 +// +// 放在 custom-face/ 目录而不是上一层 oidb-services/shared.ts,因为这些值 +// 只有 Faceroam 这一族操作要用——那边存的是所有 OIDB namespace 共享 +// 的 toInt / ensureRetCodeZero 那一类小工具,混进去会让无关 service 也 +// 看到 Faceroam 的版本号字符串。 + +import type { FaceroamOpReqInner } from '@snowluma/proto-defs/oidb-actions/base'; + +/** trpc service 名,sendRawPacket 的第一个参数。 */ +export const FACEROAM_SERVICE = 'Faceroam.OpReq'; + +// 客户端版本标识。直接硬编码抓包看到的值——QQ 不同小版本之间这部分 +// 理论上会变,但 Faceroam 服务端对版本号并不敏感,先按当前目标版本写死, +// 真出问题再从 identity 里取。 +const CLIENT_VERSION = '10.0.26200'; +const CLIENT_BUILD = '9.9.26-44343'; + +/** + * 构造请求 inner(field1 客户端环境)。fetch 带 qqVersion,delete 不带—— + * 这不是随手写的,是 wire dump 里两者 inner 长度差 14 字节的直接来源。 + */ +export function makeInner(withQqVersion: boolean): FaceroamOpReqInner { + const inner: FaceroamOpReqInner = { field1: 1, osVersion: CLIENT_VERSION }; + if (withQqVersion) inner.qqVersion = CLIENT_BUILD; + return inner; +} diff --git a/packages/protocol/tests/oidb-services/custom-face/delete-custom-face.test.ts b/packages/protocol/tests/oidb-services/custom-face/delete-custom-face.test.ts new file mode 100644 index 00000000..4c0ae250 --- /dev/null +++ b/packages/protocol/tests/oidb-services/custom-face/delete-custom-face.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { SendPacketResult } from '@snowluma/common/packet-sender'; + +import { DeleteCustomFace } from '../../../src/oidb-services/custom-face/delete-custom-face'; + +function makeSender() { + const resp: SendPacketResult = { + success: true, gotResponse: true, errorCode: 0, errorMessage: '', + responseData: Buffer.alloc(0), + }; + return { sendRawPacket: vi.fn(async () => resp) }; +} + +const SAMPLE_UIN = '2550419068'; +const SAMPLE_EMOJI_ID = '2550419068_0_0_0_2765030D118F8E04B1BFB8DC773C343B_0_0'; + +// Wire bytes captured from QQ 9.9.26-44343 (Faceroam.OpReq, opType=2, deleting +// the emoji_id above). +const DELETE_WIRE_HEX = + '0a0e0801120a31302e302e323632303010fc9c91c00918022a370a35323535303431393036385f305f305f305f32373635303330443131384638453034423142464238444337373343333433425f305f30'; + +describe('DeleteCustomFace namespace', () => { + describe('serialize + encode', () => { + it('reproduces the captured delete wire bytes exactly', () => { + const req = DeleteCustomFace.serialize({} as any, { uin: SAMPLE_UIN, emojiId: SAMPLE_EMOJI_ID }); + const hex = Buffer.from(DeleteCustomFace.encode(req)).toString('hex'); + expect(hex).toBe(DELETE_WIRE_HEX); + }); + + it('omits qqVersion from inner (delete inner is 14 bytes, not 28)', () => { + const req = DeleteCustomFace.serialize({} as any, { uin: SAMPLE_UIN, emojiId: 'x' }); + expect(req.inner?.qqVersion).toBeUndefined(); + }); + + it('puts the emoji_id into field5.body.emojiId', () => { + const req = DeleteCustomFace.serialize({} as any, { uin: SAMPLE_UIN, emojiId: SAMPLE_EMOJI_ID }); + expect(req.body?.emojiId).toBe(SAMPLE_EMOJI_ID); + }); + + it('uses field3=2 and leaves field6 unset', () => { + const req = DeleteCustomFace.serialize({} as any, { uin: SAMPLE_UIN, emojiId: 'x' }); + expect(req.field3).toBe(2); + expect(req.field6).toBeUndefined(); + }); + }); + + describe('invoke', () => { + it('routes through "Faceroam.OpReq"', async () => { + const sender = makeSender(); + await DeleteCustomFace.invoke(sender, { uin: SAMPLE_UIN, emojiId: SAMPLE_EMOJI_ID }); + expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('Faceroam.OpReq'); + }); + + it('passes the captured wire body', async () => { + const sender = makeSender(); + await DeleteCustomFace.invoke(sender, { uin: SAMPLE_UIN, emojiId: SAMPLE_EMOJI_ID }); + const [, body] = sender.sendRawPacket.mock.calls[0]!; + expect(Buffer.from(body as Uint8Array).toString('hex')).toBe(DELETE_WIRE_HEX); + }); + + it('resolves on success without returning a body', async () => { + const sender = makeSender(); + await expect(DeleteCustomFace.invoke(sender, { uin: SAMPLE_UIN, emojiId: 'x' })).resolves.toBeUndefined(); + }); + + it('throws when the sender reports no response', async () => { + const sender = { + sendRawPacket: vi.fn(async (): Promise => ({ + success: false, gotResponse: false, errorCode: 0, errorMessage: 'timeout', responseData: null, + })), + }; + await expect(DeleteCustomFace.invoke(sender, { uin: SAMPLE_UIN, emojiId: 'x' })).rejects.toThrow(); + }); + }); +});