feat(protocol): favorite emoji fetch/delete/add via Faceroam.OpReq + highway (#107)

* feat(protocol): fetch/delete favorite emoji via Faceroam.OpReq

First non-OIDB services in oidb-services/ — Faceroam.OpReq is a trpc
service, so these bypass invokeOidb and call sendRawPacket directly.

- proto-defs: reuse FaceroamOpReq/Inner/Resp, add field5 + FaceroamOpBody
- protocol: oidb-services/emoji/{shared,fetch-fav-list,delete-fav-emoji}
- core: EmojiApi facade + ApiHub registration
- onebot: fetch_fav_emoji_list + delete_fav_emoji actions

Request wire matches 9.9.26-44343 captures byte-for-byte; fetch response
decode + delete verified end-to-end against a live account.

* feat(protocol): add favorite emoji via BDHExpressionRoam + highway HTTP upload

Flow: BDHExpressionRoam (apply upload, get token) -> highway frame
(PicUp.DataUp commandId=9, serviceTicket = BDHExpressionRoam token) ->
HTTP POST httpconn?htcmd=0x6FF0087 -> fetch refresh.

- proto-defs: BDHExpressionRoamReq/Resp + FavEmojiHighwayHead types
- protocol: oidb-services/emoji/add-fav-emoji (fetchHighwaySession +
  BDHExpressionRoam + packHighwayFrame + HTTP POST)
- core: EmojiApi.addFavEmoji facade (reuses loadBinarySource)
- onebot: add_fav_emoji action
- mcp: regenerate catalog

End-to-end verified: new emoji_id appears in fetch list after upload.

* refactor(emoji): reuse fetchCustomFace for fetch_fav_emoji_list

Address review: Faceroam.OpReq had two call sites (profile.fetchCustomFace
+ the new FetchFavList service). Drop the duplicate service —
fetch_fav_emoji_list now reuses ProfileApi.fetchCustomFace and extracts
emoji_id from the returned image URL (qq_expression/<uin>/<id>/0).

delete/add unchanged (independent protocols).

* refactor: align custom-face naming with NapCat

Address review: 'fav_emoji' vs existing 'custom_face' named the same
thing two ways. Rename across the stack to match NapCat's custom_face:
- services: oidb-services/emoji/* -> oidb-services/custom-face/*
  (DeleteFavEmoji -> DeleteCustomFace, AddFavEmoji -> AddCustomFace)
- facade: EmojiApi dropped; delete/add moved to ProfileApi
  (deleteCustomFace / addCustomFace), next to fetchCustomFace
- actions: fetch_fav_emoji_list -> fetch_custom_face_ids,
  delete_fav_emoji -> delete_custom_face, add_fav_emoji -> add_custom_face
- package.json subpaths + catalog regenerated

* refactor(onebot): merge fetch_custom_face_ids into fetch_custom_face

Per review: merge fetch_custom_face_ids back into fetch_custom_face
with a return_type param ('url' default / 'id'). One action, one service,
no separate id-only variant.
This commit is contained in:
Qiao
2026-06-19 02:15:54 +08:00
committed by DrSmoothl
parent e3a04858b4
commit 552238fd04
9 changed files with 631 additions and 21 deletions
+16
View File
@@ -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<void> {
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<string> {
const { bytes } = await loadBinarySource(imageSource, 'custom-face');
return AddCustomFace.invoke(this.ctx, { uin: this.ctx.identity.uin, imageBytes: bytes });
}
}
+206 -20
View File
@@ -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": "群相册",
+43 -1
View File
@@ -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));
@@ -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>;
+8
View File
@@ -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"
@@ -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<BridgeContext, 'sendRawPacket' | 'identity'>;
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<string> {
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<BDHExpressionRoamReq>({
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<BDHExpressionRoamResp>(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<FavEmojiHighwayHead>({
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;
}
}
@@ -0,0 +1,42 @@
// Faceroam.OpReq opType=2 — 删除一个收藏表情(custom face)。
//
// 和 fetch 同一个 trpc service,区别在 field3=2、inner 不带 qqVersion、
// 业务体 field5 塞 emoji_id(即 custom face id)。id 格式是
// `<uin 段>_0_0_0_<MD5>_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;
/** 形如 `<UIN>_0_0_0_<MD5>_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<FaceroamOpReq>(req);
export async function invoke(deps: Deps, params: Params): Promise<void> {
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');
}
}
@@ -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;
}
@@ -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<SendPacketResult> => ({
success: false, gotResponse: false, errorCode: 0, errorMessage: 'timeout', responseData: null,
})),
};
await expect(DeleteCustomFace.invoke(sender, { uin: SAMPLE_UIN, emojiId: 'x' })).rejects.toThrow();
});
});
});