import type { GroupAvatarExtra, 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 { FetchCustomFaceDetail } from '@snowluma/protocol/oidb-services/custom-face/fetch-custom-face-detail'; import { FetchCustomFaceList } from '@snowluma/protocol/oidb-services/custom-face/fetch-custom-face-list'; import { ModifyCustomFace } from '@snowluma/protocol/oidb-services/custom-face/modify-custom-face'; import { MoveCustomFace } from '@snowluma/protocol/oidb-services/custom-face/move-custom-face'; import { OrderCustomFace } from '@snowluma/protocol/oidb-services/custom-face/order-custom-face'; import { BufferChunkSource, 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'; import { GetUnidirectionalFriendList, type UnidirectionalFriendEntry, } from '@snowluma/protocol/oidb-services/profile/get-unidirectional-friend-list'; import { SetInputStatus } from '@snowluma/protocol/oidb-services/profile/set-input-status'; import { SetProfile } from '@snowluma/protocol/oidb-services/profile/set-profile'; import { SetSelfLongNick } from '@snowluma/protocol/oidb-services/profile/set-self-long-nick'; import { protobuf_decode, protobuf_encode } from '@snowluma/proton'; import type { Bridge } from '../bridge'; import type { BridgeContext } from '../bridge-context'; function asBridge(ctx: BridgeContext): Bridge { return ctx as unknown as Bridge; } export interface CustomFaceDetail { emojiId: string; url: string; md5: string; desc: string; } export class ProfileApi { constructor(private readonly ctx: BridgeContext) {} // ─────────────── status / profile setters ─────────────── async setOnlineStatus(status: number, extStatus = 0, batteryStatus = 100): Promise { await this.dispatchSetStatus({ status, extStatus, batteryStatus }); } /** * DIY (custom) online status. napcat fixes status=10 / extStatus=2000 * — the values QQ associates with "I have a custom status string" — * and threads the faceId / wording / faceType through the customExt * sub-message of the same SetStatus wire call. */ async setDiyOnlineStatus(faceId: number, wording: string, faceType: number): Promise { await this.dispatchSetStatus({ status: 10, extStatus: 2000, batteryStatus: 0, customExt: { faceId, text: wording, faceType }, }); } private async dispatchSetStatus(value: { status: number; extStatus: number; batteryStatus: number; customExt?: { faceId: number; text: string; faceType: number }; }): Promise { const request = protobuf_encode(value); const result = await this.ctx.sendRawPacket( 'trpc.qq_new_tech.status_svc.StatusService.SetStatus', request, ); if (!result.success) { throw new Error(result.errorMessage || 'set online status failed (network/timeout)'); } if (result.responseData && result.responseData.length > 0) { const resp = protobuf_decode(result.responseData); if (!resp) { throw new Error( result.errorMessage || 'set online status failed (network/timeout)', ); } const message = resp.errMsg ?? ''; if (message !== '' && message !== 'set status success') { throw new Error(message); } } } setProfile(nickname?: string, personalNote?: string, sex?: number): Promise { return SetProfile.invoke(this.ctx, { nickname, personalNote, sex }); } setSelfLongNick(longNick: string): Promise { return SetSelfLongNick.invoke(this.ctx, { longNick }); } setInputStatus(userId: number, eventType: number): Promise { return SetInputStatus.invoke(this.ctx, { userId, eventType }); } async setAvatar(source: string): Promise { const bridge = asBridge(this.ctx); const loaded = await loadBinarySource(source, 'avatar'); if (!loaded.bytes.length) { throw new Error('avatar file is empty'); } const hashes = computeHashes(loaded.bytes); const session = await fetchHighwaySession(bridge); await uploadHighwayHttp( bridge, session, 90, new BufferChunkSource(loaded.bytes), hashes.md5, new Uint8Array(0), ); } /** * Set group avatar. Mirrors Lagrange.Core's GroupSetAvatar: * - same highway HTTP upload as personal avatar * - cmdId 3000 (instead of 90) * - GroupAvatarExtra proto carried as the `extend` blob, with the * four protocol-prescribed constants (type=101, field5=3, field6=1, * field3.field1=1) and the target groupUin. * * Source ref: Lagrange.Core/Internal/Context/Logic/Implementation/OperationLogic.cs#GroupSetAvatar. */ async setGroupAvatar(groupId: number, source: string): Promise { const bridge = asBridge(this.ctx); const loaded = await loadBinarySource(source, 'group-avatar'); if (!loaded.bytes.length) { throw new Error('group avatar file is empty'); } const hashes = computeHashes(loaded.bytes); const session = await fetchHighwaySession(bridge); const extra = protobuf_encode({ type: 101, groupUin: groupId, field3: { field1: 1 }, field5: 3, field6: 1, }); await uploadHighwayHttp( bridge, session, 3000, new BufferChunkSource(loaded.bytes), hashes.md5, extra, ); } // ─────────────── queries on me / my contacts ─────────────── getLike(userId?: number, start = 0, limit = 10): Promise { return GetLike.invoke(this.ctx, { userId, start, limit }); } getUnidirectionalFriendList(): Promise { return GetUnidirectionalFriendList.invoke(this.ctx); } async fetchCustomFace(count = 10): Promise { const faceIds = await this.fetchCustomFaceIds(count); return faceIds.map((id) => this.customFaceUrl(id)); } async fetchCustomFaceDetails(count = 10): Promise { const faceIds = await this.fetchCustomFaceIds(count); if (faceIds.length === 0) { return []; } const emojis = faceIds.map((emojiId) => ({ emojiId, md5: md5FromEmojiId(emojiId), })); const details = await FetchCustomFaceDetail.invoke(this.ctx, { emojis }); const descriptions = new Map(details.map((detail) => [detail.emojiId, detail.desc])); return emojis.map(({ emojiId, md5 }) => ({ emojiId, url: this.customFaceUrl(emojiId), md5, // The detail response may omit entries which have never had a remark. desc: descriptions.get(emojiId) ?? '', })); } async fetchCustomFaceIds(count = 10): Promise { if (!Number.isInteger(count) || count < 0) { throw new Error('custom face count must be a non-negative integer'); } if (count === 0) { return []; } const faceIds = await FetchCustomFaceList.invoke(this.ctx, { uin: this.ctx.identity.uin }); return faceIds.slice(0, count); } private customFaceUrl(emojiId: string): string { return `https://p.qpic.cn/qq_expression/${this.ctx.identity.uin}/${emojiId}/0`; } /** Resolve an existing saved sticker by its resource id or content MD5. */ async resolveCustomFace( identifier: string, ): Promise> { const wanted = identifier.trim(); if (!wanted) { throw new Error('custom face identifier is empty'); } const ids = await FetchCustomFaceList.invoke(this.ctx, { uin: this.ctx.identity.uin }); const byMd5 = /^[a-fA-F0-9]{32}$/.test(wanted); const emojiId = ids.find((id) => byMd5 ? md5FromEmojiId(id).toLowerCase() === wanted.toLowerCase() : id === wanted, ); if (!emojiId) { throw new Error('custom face is not in the saved sticker list'); } return { emojiId, url: this.customFaceUrl(emojiId), md5: md5FromEmojiId(emojiId) }; } /** 删除一个收藏表情(custom face)。emoji_id 来自收藏列表。 */ 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 }); } /** * 修改收藏表情(custom face)备注。emoji_id 来自收藏列表;md5 从 emoji_id * 中段解析,无需调用方单独提供。desc 为空串则清空备注。 */ modifyCustomFace(emojiId: string, desc: string): Promise { return ModifyCustomFace.invoke(this.ctx, { emojiId, md5: md5FromEmojiId(emojiId), desc, }); } /** * 收藏表情(custom face)移到最前。QQ 客户端只有"移动到最前",协议层 * (0x902f 的 f3=1)也只支持最前,不支持移到其他位置。两步流程:先 0x902f * 移动指令,再 0x902e opType=2 上传新顺序(fetch 当前列表把目标挪到第一)。 * 两步都发才生效。 */ async moveCustomFaceToFront(emojiId: string): Promise { // 1. fetch 当前完整列表(fetch 顺序即可,不需要 DB 显示顺序) const ids = await this.fetchCustomFaceIds(1000); const idx = ids.indexOf(emojiId); if (idx < 0) { throw new Error(`emoji_id not in list: ${emojiId}`); } // 新顺序:目标挪到第一,其余按原顺序 const reordered = ids.slice(); reordered.splice(idx, 1); reordered.unshift(emojiId); // 2. 0x902f 移动指令(f3=1 = 移到最前) await OrderCustomFace.invoke(this.ctx, { emojiId, position: 1 }); // 3. 0x902e opType=2 上传新顺序 await MoveCustomFace.invoke(this.ctx, { emojis: reordered.map((id) => ({ emojiId: id, md5: md5FromEmojiId(id) })), }); } } /** * 从 emoji_id `_0_0_0__0_0` 提取中段 32 位大写 hex MD5。 * 格式固定(fetch/delete/move 共用),取第 5 段。解析失败抛错——比静默 * 传空串让服务端拒绝更早定位问题。 */ function md5FromEmojiId(emojiId: string): string { const parts = emojiId.split('_'); // 期望形如 `_0_0_0__0_0`,共 7 段,md5 在 index 4。 const md5 = parts[4]; if (!md5 || md5.length !== 32 || !/^[0-9a-fA-F]{32}$/.test(md5)) { throw new Error(`invalid emoji_id (cannot extract md5): ${emojiId}`); } return md5.toUpperCase(); }