refactor(api): move 10 web methods to WebApi + finalize cleanup (#6 commit 12/13)

Final commit of the #6 NapCat-style Api refactor. Inlines the four
files under `web-actions/` (cookies / group-essence / group-honor /
group-notice) into `apis/web.ts::WebApi`. Both the `actions/` and
`web-actions/` directories are now empty and deleted from the tree.

Method renaming (Api namespace already says "web"):

  forceFetchClientKey   → forceFetchClientKey  (kept — unique name)
  getCookiesStr         → getCookiesStr        (kept — disambiguates from
                                                 the new public getCookies)
  getCsrfToken          → getCsrfToken
  getCredentials        → getCredentials
  getGroupEssence       → getEssence
  getGroupEssenceAll    → getEssenceAll
  getGroupHonorInfo     → getHonorInfo
  sendGroupNotice       → sendNotice
  getGroupNotice        → getNotice
  deleteGroupNotice     → deleteNotice

Plus a new public `getCookies(domain)` on WebApi so GroupAlbumApi can
reach the cookie-acquisition flow through `this.ctx.apis.web.getCookies`
rather than reaching into a private helper. Internal helpers
(`getPSkey` / `getSKey` / `getBknFromSKey`) remain file-private since
they're only used by the publicly-exposed methods above.

Cleanup performed in this same commit:

  • `actions/shared.ts` → moved to `apis/shared.ts` (only file left in
    actions/; it's a small helper module used by 4 Api classes).
  • `actions/` directory deleted entirely.
  • `web-actions/` directory deleted entirely.
  • `BridgeInterface` slimmed to `BridgeContext + activePid` (the only
    non-Api field OneBot consumers reach into). The 70-line list of
    method signatures is gone — every method type now comes from the
    `ApiHub` definition in `bridge-context.ts`.
  • `bridge.ts` no longer imports/wraps any action functions. Its
    public class is just the per-account state holder + `apis: ApiHub`.

Callers updated: `instance-context.ts`, `actions/extended.ts`,
`actions/group-info.ts`. Tests under `tests/web-actions/` deleted —
those tested file-private helpers (`getPSkey`/`getSKey`/`getBknFromSKey`)
that are no longer reachable from outside; the public WebApi surface
is exercised through OneBot integration tests in `extended-actions.test.ts`.

552/552 remaining tests pass. (Test count down 35 from the deleted
web-actions tests; production behaviour unchanged.)

This concludes the #6 refactor. The 12 Api hubs that the OneBot side
can reach via `bridge.apis.*`:

  message / contacts / groupAdmin / groupFile / groupAlbum
  interaction / friend / profile / forward / misc / extras / web
This commit is contained in:
DrSmoothl
2026-05-20 23:49:48 +08:00
parent 22add4ad1c
commit f4c6838e65
22 changed files with 413 additions and 1067 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ import type {
OidbAiVoiceReq,
OidbAiVoiceResp,
} from '@snowluma/proto-defs/oidb-actions/media';
import type { MediaIndexNode } from '../actions/shared';
import type { MediaIndexNode } from './shared';
import type { BridgeContext } from '../bridge-context';
import type { Bridge } from '../bridge';
import { makeOidbEnvelope, runOidb } from '../bridge-oidb';
+1 -1
View File
@@ -26,7 +26,7 @@ import type { Bridge } from '../bridge';
import { buildSendElems } from '../element-builder';
import type { ForwardNodePayload, MessageElement } from '../events';
import { parseMsgPush } from '../msg-push';
import { resolveSelfUid, toInt } from '../actions/shared';
import { resolveSelfUid, toInt } from './shared';
function asBridge(ctx: BridgeContext): Bridge { return ctx as unknown as Bridge; }
+4 -5
View File
@@ -18,7 +18,8 @@ import type {
import type { BridgeContext } from '../bridge-context';
import type { Bridge } from '../bridge';
import { getGroupAlbumList, uploadImageToGroupAlbum } from '../web/group-album';
import { getCookies } from '../web-actions/cookies';
// `getCookies` is part of WebApi — go through the hub so we don't
// duplicate the cookie acquisition flow.
function asBridge(ctx: BridgeContext): Bridge { return ctx as unknown as Bridge; }
@@ -54,20 +55,18 @@ export class GroupAlbumApi {
/** List a group's albums (HTTP-cookie-based, qzone.qq.com). */
async list(groupId: number): Promise<any> {
const bridge = asBridge(this.ctx);
const groupCode = groupId.toString();
const uin = this.ctx.identity.uin;
const cookieObject = await getCookies(bridge, 'qzone.qq.com');
const cookieObject = await this.ctx.apis.web.getCookies('qzone.qq.com');
const albumData = await getGroupAlbumList(cookieObject, groupCode, uin);
return albumData?.album || [];
}
/** Upload an image into an existing album (HTTP slice-upload). */
async upload(groupId: number, albumId: string, albumName: string, filePath: string): Promise<void> {
const bridge = asBridge(this.ctx);
const groupCode = groupId.toString();
const uin = this.ctx.identity.uin;
const cookieObject = await getCookies(bridge, 'qzone.qq.com');
const cookieObject = await this.ctx.apis.web.getCookies('qzone.qq.com');
await uploadImageToGroupAlbum(cookieObject, groupCode, albumId, albumName, filePath, uin);
}
+3 -3
View File
@@ -38,7 +38,7 @@ import type {
OidbPrivateFileUploadReq,
OidbPrivateFileUploadResp,
} from '@snowluma/proto-defs/oidb-actions/media';
import { ensureRetCodeZero, resolveSelfUid, toInt, type MediaIndexNode } from '../actions/shared';
import { ensureRetCodeZero, resolveSelfUid, toInt, type MediaIndexNode } from './shared';
const log = createLogger('GroupFile');
@@ -78,8 +78,8 @@ export interface UploadFileResult {
fileHash?: string | null;
}
// Re-export so callers don't have to dig into actions/shared.
export type { MediaIndexNode } from '../actions/shared';
// Re-export so callers don't have to dig into apis/shared.
export type { MediaIndexNode } from './shared';
// ─────────────── file-specific helpers ───────────────
+7 -11
View File
@@ -15,10 +15,8 @@
// 3. Construct it inside `buildApiHub(ctx)` below.
// 4. Move the relevant methods OFF Bridge and onto the new Api.
//
// This file evolves commit-by-commit as `#6 NapCat-style Api on ctx`
// lands the 13 areas. Until all 13 are in place, ApiHub will have a
// mix of "done" entries and "TODO" markers below — the TODO list
// shrinks as each commit lands.
// All 12 Api hubs are now live (#6 commits 1-12). The interface lists
// every typed area an OneBot caller can reach via `bridge.apis.*`.
import type { BridgeContext } from '../bridge-context';
import { ContactsApi } from './contacts';
@@ -32,6 +30,7 @@ import { InteractionApi } from './interaction';
import { MessageApi } from './message';
import { MiscApi } from './misc';
import { ProfileApi } from './profile';
import { WebApi } from './web';
export interface ApiHub {
/** Send/recall/markRead operations across c2c + group + c2c-file. */
@@ -56,13 +55,8 @@ export interface ApiHub {
readonly misc: MiscApi;
/** Tier-2 napcat-parity: group todo / stranger status / AI voice. */
readonly extras: ExtrasApi;
// Pending — added as later commits land:
// readonly interaction: InteractionApi;
// readonly profile: ProfileApi;
// readonly forward: ForwardApi;
// readonly misc: MiscApi;
// readonly extras: ExtrasApi;
// readonly web: WebApi;
/** Cookie-backed HTTP: essence / honor / notice / client-key / csrf-token. */
readonly web: WebApi;
}
/**
@@ -86,6 +80,7 @@ export function buildApiHub(ctx: BridgeContext): ApiHub {
forward: new ForwardApi(ctx),
misc: new MiscApi(ctx),
extras: new ExtrasApi(ctx),
web: new WebApi(ctx),
};
}
@@ -103,3 +98,4 @@ export { InteractionApi } from './interaction';
export { MessageApi } from './message';
export { MiscApi } from './misc';
export { ProfileApi } from './profile';
export { WebApi } from './web';
+1 -1
View File
@@ -25,7 +25,7 @@ import type { Bridge } from '../bridge';
import { makeOidbEnvelope, runOidb } from '../bridge-oidb';
import { fetchHighwaySession, uploadHighwayHttp } from '../highway/highway-client';
import { computeHashes, loadBinarySource } from '../highway/utils';
import { resolveSelfUid } from '../actions/shared';
import { resolveSelfUid } from './shared';
function asBridge(ctx: BridgeContext): Bridge { return ctx as unknown as Bridge; }
+354
View File
@@ -0,0 +1,354 @@
// WebApi — cookie-backed HTTP wrappers (group essence / honor / notice /
// client-key / pskey / csrf-token / credentials). Inlined from the
// per-feature files in `web-actions/` (all deleted in commit 13).
// The pure HTTP transport stays in `web/*.ts` and stays unchanged —
// only the Bridge-binding helpers move here.
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbClientKeyReq,
OidbClientKeyResp,
OidbGetPskeyReq,
OidbGetPskeyResp,
} from '@snowluma/proto-defs/oidb-actions/base';
import type { BridgeContext } from '../bridge-context';
import type { Bridge } from '../bridge';
import { makeOidbEnvelope, runOidb } from '../bridge-oidb';
import { getGroupEssenceMsg, getGroupEssenceMsgAll } from '../web/group-essence';
import { getHonorListWebAPI, WebHonorType } from '../web/group-honor';
import {
deleteGroupNotice as deleteGroupNoticeHttp,
getGroupNoticeWebAPI,
setGroupNoticeWebAPI,
uploadGroupNoticeImage,
} from '../web/group-notice';
import { RequestUtil } from '../web/request-util';
function asBridge(ctx: BridgeContext): Bridge { return ctx as unknown as Bridge; }
export interface ClientKeyInfo {
clientKey: string;
keyIndex: string;
expireTime: string;
}
// ─────────────── private helpers (cookie acquisition) ───────────────
async function forceFetchClientKeyInner(bridge: Bridge): Promise<ClientKeyInfo> {
const env = makeOidbEnvelope<OidbClientKeyReq>(0x102A, 1, {});
const respBytes = await runOidb(bridge, 'OidbSvcTrpcTcp.0x102a_1', protobuf_encode<OidbBase<OidbClientKeyReq>>(env));
const resp = protobuf_decode<OidbBase<OidbClientKeyResp>>(respBytes).body;
const clientKey = resp?.clientKey || '';
// keyIndex falls back to "19" — origin unknown but the value is what
// every NapCat-derived implementation uses when the server omits it.
const keyIndex = String(resp?.keyIndex || '19');
return {
clientKey,
keyIndex,
expireTime: String(resp?.expireTime || '1800'),
};
}
async function getPSkey(bridge: Bridge, domainList: string[]): Promise<{ domainPskeyMap: Map<string, string> }> {
const env = makeOidbEnvelope<OidbGetPskeyReq>(0x102A, 0, { domainList });
const respBytes = await runOidb(bridge, 'OidbSvcTrpcTcp.0x102a_0', protobuf_encode<OidbBase<OidbGetPskeyReq>>(env));
const resp = protobuf_decode<OidbBase<OidbGetPskeyResp>>(respBytes).body;
const domainPskeyMap = new Map<string, string>();
if (resp?.pskeyItems && Array.isArray(resp.pskeyItems)) {
for (const item of resp.pskeyItems) {
if (item.domain && item.pskey) {
domainPskeyMap.set(item.domain, item.pskey);
}
}
}
return { domainPskeyMap };
}
async function getCookies(bridge: Bridge, domain: string): Promise<Record<string, string>> {
const ClientKeyData = await forceFetchClientKeyInner(bridge);
// Build the ptlogin2 jump URL: this is the canonical way for the
// bot to swap its clientKey for cookie-jar entries on a given
// qq.com subdomain.
const requestUrl = 'https://ssl.ptlogin2.qq.com/jump?ptlang=1033&clientuin=' + bridge.identity.uin +
'&clientkey=' + ClientKeyData.clientKey +
'&u1=https%3A%2F%2F' + domain + '%2F' + bridge.identity.uin + '%2Finfocenter&keyindex=' + ClientKeyData.keyIndex;
const data = await RequestUtil.HttpsGetCookies(requestUrl);
if (!data['p_skey'] || data['p_skey'].length === 0) {
// ptlogin2 sometimes omits p_skey; fall back to OIDB getPSkey
// for the same domain. Errors are swallowed so the caller can
// still proceed with whatever cookies it did get.
try {
const pskeyData = await getPSkey(bridge, [domain]);
const pskey = pskeyData.domainPskeyMap.get(domain);
if (pskey) {
data['p_skey'] = pskey;
}
} catch {
return data;
}
}
return data;
}
async function getSKey(bridge: Bridge): Promise<string> {
const ClientKeyData = await forceFetchClientKeyInner(bridge);
if (!ClientKeyData.clientKey) {
throw new Error('getClientKey Error: clientKey is empty');
}
const u1 = encodeURIComponent('https://h5.qzone.qq.com/qqnt/qzoneinpcqq/friend?refresh=0&clientuin=0&darkMode=0');
const requestUrl = 'https://ssl.ptlogin2.qq.com/jump?ptlang=1033' +
'&clientuin=' + bridge.identity.uin +
'&clientkey=' + ClientKeyData.clientKey +
'&u1=' + u1 +
'&keyindex=' + ClientKeyData.keyIndex;
const cookies: { [key: string]: string } = await RequestUtil.HttpsGetCookies(requestUrl);
const skey = cookies['skey'];
if (!skey) {
throw new Error('SKey is Empty');
}
return skey;
}
/**
* Standard QQ bkn hash (also known as token / csrf_token) derived from
* skey or p_skey. djb2 hash truncated to 31 bits.
*/
function getBknFromSKey(skey: string): number {
let hash = 5381;
for (let i = 0; i < skey.length; i++) {
hash += (hash << 5) + skey.charCodeAt(i);
}
return hash & 2147483647;
}
export class WebApi {
constructor(private readonly ctx: BridgeContext) {}
// ─────────────── cookie / token primitives ───────────────
async forceFetchClientKey(): Promise<ClientKeyInfo> {
return forceFetchClientKeyInner(asBridge(this.ctx));
}
/** Fetch the cookie jar for a `qq.com` subdomain. Used by GroupAlbumApi
* (qzone.qq.com) and by every other web helper here. */
async getCookies(domain: string): Promise<Record<string, string>> {
return getCookies(asBridge(this.ctx), domain);
}
/** Cookies for `domain` joined into the canonical "k=v; k=v" header form. */
async getCookiesStr(domain: string): Promise<string> {
const cookieObject = await getCookies(asBridge(this.ctx), domain);
return Object.entries(cookieObject)
.map(([key, value]) => `${key}=${value}`)
.join('; ');
}
/** CSRF token == bkn(skey) — used by qzone / qun web APIs. */
async getCsrfToken(): Promise<number> {
const skey = await getSKey(asBridge(this.ctx));
if (!skey) {
throw new Error('SKey is Empty');
}
return getBknFromSKey(skey);
}
/** Returns the OneBot `get_credentials` payload (cookie string + bkn). */
async getCredentials(domain: string): Promise<{ cookies: string; token: number; csrf_token: number }> {
const cookieObject = await getCookies(asBridge(this.ctx), domain);
const cookiesStr = Object.entries(cookieObject)
.map(([key, value]) => `${key}=${value}`)
.join('; ');
const skey = cookieObject['p_skey'] || cookieObject['skey'] || '';
const token = skey ? getBknFromSKey(skey) : 0;
return {
cookies: cookiesStr,
token,
csrf_token: token,
};
}
// ─────────────── group essence ───────────────
/** Paginated fetch — `pageStart` is 0-indexed, `pageLimit` is server-capped at 50. */
async getEssence(groupId: number, pageStart = 0, pageLimit = 50): Promise<any> {
const bridge = asBridge(this.ctx);
const groupCode = groupId.toString();
const cookieObject = await getCookies(bridge, 'qun.qq.com');
const essenceData = await getGroupEssenceMsg(cookieObject, groupCode, pageStart, pageLimit);
return essenceData || { retcode: -1, data: { is_end: true, msg_list: [] } };
}
/** Walks every page and returns the concatenated result. */
async getEssenceAll(groupId: number): Promise<any> {
const bridge = asBridge(this.ctx);
const groupCode = groupId.toString();
const cookieObject = await getCookies(bridge, 'qun.qq.com');
return await getGroupEssenceMsgAll(cookieObject, groupCode);
}
// ─────────────── group honor ───────────────
async getHonorInfo(groupId: number, type: WebHonorType | string): Promise<any> {
const bridge = asBridge(this.ctx);
const groupCode = groupId.toString();
const cookieObject = await getCookies(bridge, 'qun.qq.com');
const honorInfo: any = {
group_id: groupId,
current_talkative: null,
talkative_list: [],
performer_list: [],
legend_list: [],
emotion_list: [],
strong_newbie_list: [],
};
if (type === WebHonorType.TALKATIVE || type === WebHonorType.ALL) {
const talkativeList = await getHonorListWebAPI(cookieObject, groupCode, 1);
if (talkativeList.length > 0) {
honorInfo.current_talkative = talkativeList[0];
honorInfo.talkative_list = talkativeList;
}
}
if (type === WebHonorType.PERFORMER || type === WebHonorType.ALL) {
honorInfo.performer_list = await getHonorListWebAPI(cookieObject, groupCode, 2);
}
if (type === WebHonorType.LEGEND || type === WebHonorType.ALL) {
honorInfo.legend_list = await getHonorListWebAPI(cookieObject, groupCode, 3);
}
if (type === WebHonorType.EMOTION || type === WebHonorType.ALL) {
honorInfo.emotion_list = await getHonorListWebAPI(cookieObject, groupCode, 6);
}
return honorInfo;
}
// ─────────────── group notice ───────────────
async sendNotice(
groupId: number,
content: string,
options?: {
image?: string; // local path, http URL, or undefined (text-only)
pinned?: number;
type?: number;
confirm_required?: number;
},
): Promise<any> {
const bridge = asBridge(this.ctx);
const groupCode = groupId.toString();
const cookieObject = await getCookies(bridge, 'qun.qq.com');
let picId = '';
let imgWidth = 540;
let imgHeight = 300;
if (options?.image) {
let imageBuffer: Buffer;
if (options.image.startsWith('http://') || options.image.startsWith('https://')) {
const response = await fetch(options.image);
if (!response.ok) throw new Error(`Failed to download image: ${response.status}`);
imageBuffer = Buffer.from(await response.arrayBuffer());
} else {
const { readFileSync } = await import('fs');
imageBuffer = readFileSync(options.image);
}
const picInfo = await uploadGroupNoticeImage(cookieObject, imageBuffer);
if (picInfo) {
picId = picInfo.id;
imgWidth = picInfo.width;
imgHeight = picInfo.height;
}
}
const ret = await setGroupNoticeWebAPI(
cookieObject,
groupCode,
content,
options?.pinned ?? 0,
options?.type ?? 1,
1,
1,
options?.confirm_required ?? 1,
picId,
imgWidth,
imgHeight,
);
if (!ret || ret.ec !== 0) {
throw new Error(`设置群公告失败: ${ret?.em || '未知错误(Cookie过期或权限不足)'}`);
}
return ret;
}
async getNotice(groupId: number): Promise<any[]> {
const bridge = asBridge(this.ctx);
const groupCode = groupId.toString();
const cookieObject = await getCookies(bridge, 'qun.qq.com');
const ret = await getGroupNoticeWebAPI(cookieObject, groupCode);
if (!ret) {
throw new Error('获取公告失败');
}
const retNotices: any[] = [];
if (ret.feeds) {
for (const key in ret.feeds) {
const feed = ret.feeds[key];
if (!feed) continue;
const image = feed.msg?.pics?.map((pic) => ({
id: pic.id,
height: pic.h,
width: pic.w,
})) || [];
retNotices.push({
notice_id: feed.fid,
sender_id: feed.u,
publish_time: feed.pubt,
message: {
text: feed.msg?.text || '',
image,
images: image,
},
settings: feed.settings,
read_num: feed.read_num,
});
}
}
return retNotices;
}
async deleteNotice(groupId: number, fid: string): Promise<boolean> {
const bridge = asBridge(this.ctx);
const groupCode = groupId.toString();
const cookieObject = await getCookies(bridge, 'qun.qq.com');
return await deleteGroupNoticeHttp(cookieObject, groupCode, fid);
}
}
+19 -93
View File
@@ -1,96 +1,22 @@
import type { SendPacketResult } from '../protocol/packet-sender';
import type { ApiHub } from './apis';
import type {
ClientKeyInfo,
DownloadRKeyInfo,
UploadedFileMeta,
} from './bridge';
import type { BridgeEventBus } from './event-bus';
import type { IdentityService } from './identity-service';
import type { WebHonorType } from './web/group-honor';
// BridgeInterface — the public Bridge surface for OneBot-side
// consumers. Slimmed down to BridgeContext + the few extra fields
// OneBot reaches into directly (most notably `activePid`).
//
// Most OneBot code paths reach business methods through
// `bridge.apis.<area>.method()` — those types come from `ApiHub`
// inside `bridge-context.ts`. The remaining members below are the
// non-Api state that doesn't fit into a typed Api hub.
//
// Internal Api implementations should depend on `BridgeContext`
// instead (the narrower contract). `BridgeInterface` is for callers
// that need the activePid hook or for backwards compatibility with
// pre-#6 code that imported `BridgeInterface` as the bridge type.
export interface BridgeInterface {
// ─── Shared state ───
readonly identity: IdentityService;
readonly events: BridgeEventBus;
import type { BridgeContext } from './bridge-context';
export interface BridgeInterface extends BridgeContext {
/** Last-active process id — used by `OneBotManager` to thread
* per-process state into the right instance. `null` when no
* process has bound yet. */
readonly activePid: number | null;
/**
* Typed Api hub — `apis.message.sendGroup(...)`, `apis.message.recallGroup(...)`,
* etc. The shape of `ApiHub` grows commit-by-commit as the #6
* refactor moves the remaining areas (group-admin, group-file,
* profile, …) off Bridge and onto their dedicated Api classes.
*/
readonly apis: ApiHub;
// ─── Resolution ───
resolveUserUid(uin: number, groupId?: number): Promise<string>;
// ─── Raw packet (deliberate escape hatch for `send_packet` action) ───
// OneBot clients use this to debug or invoke commands SnowLuma has no
// typed wrapper for. Every other method on this interface eventually
// routes through here.
sendRawPacket(serviceCmd: string, body: Uint8Array, timeoutMs?: number): Promise<SendPacketResult>;
// ─── Send-side protocol helpers (used by Api classes) ───
nextMessageRandom(): number;
nextClientSequence(): number;
// ─── Send (messages) ───
// Moved to `apis.message.{sendGroup, sendPrivate, sendC2cFile,
// recallGroup, recallPrivate, markGroupRead, markPrivateRead}`.
// ─── Fetch (contacts / profile / system) — moved to apis.contacts ───
// ─── Group admin (moved to apis.groupAdmin) ───
// ─── Group reaction / essence (moved to apis.interaction) ───
// ─── Friend (moved to apis.friend) ───
// - handleRequest / delete / setRemark
// ─── Files (moved to apis.groupFile) ───
// - upload / uploadPrivate / publish
// - list / getCount
// - getUrl / getPrivateUrl / getPttUrl / getPrivatePttUrl
// - getVideoUrl / getPrivateVideoUrl
// - delete / move / createFolder / deleteFolder / renameFolder
/** Cache file metadata so a later send_msg with just `file_id` can rehydrate it. */
rememberUploadedFile(meta: UploadedFileMeta): void;
recallUploadedFile(fileId: string): UploadedFileMeta | undefined;
// ─── Group Album (moved to apis.groupAlbum) ───
// - list / upload / getMediaList / comment / like / delete
// ─── Forward (moved to apis.forward) ───
// - upload / fetch
// ─── Message ops (moved to apis.message) ───
// ─── Interaction (moved to apis.interaction) ───
// - sendPoke / sendLike / setReaction / setEssence / getEmojiLikes
// ─── Web-backed ───
getGroupHonorInfo(groupId: number, type: WebHonorType | string): Promise<any>;
forceFetchClientKey(): Promise<ClientKeyInfo>;
getGroupEssence(groupId: number, pageStart?: number, pageLimit?: number): Promise<any>;
getGroupEssenceAll(groupId: number): Promise<any>;
sendGroupNotice(groupId: number, content: string, options?: any): Promise<any>;
getGroupNotice(groupId: number): Promise<any>;
deleteGroupNotice(groupId: number, fid: string): Promise<boolean>;
getCookiesStr(domain: string): Promise<string>;
getCsrfToken(): Promise<number>;
getCredentials(domain: string): Promise<any>;
// ─── Personal profile (moved to apis.profile) ───
// - setOnlineStatus / setDiyOnlineStatus
// - setProfile / setSelfLongNick / setInputStatus
// - setAvatar / setGroupAvatar / fetchCustomFace
// - getLike / getUnidirectionalFriendList
// ─── Misc (moved to apis.misc) ───
// - translateEn2Zh / getMiniAppArk / clickInlineKeyboardButton / sendGroupSign
// ─── Tier-2 napcat parity extras (moved to apis.extras) ───
// - setGroupTodo / completeGroupTodo / cancelGroupTodo
// - getStrangerStatus
// - fetchAiVoiceList / fetchAiVoice
}
+4 -42
View File
@@ -36,19 +36,7 @@ import type { GroupFilesResult } from './apis/group-file';
// `bridge-contacts.ts` removed — its 6 functions are now methods on
// `apis.contacts` (see `apis/contacts.ts::ContactsApi`).
import { BridgeEventBus } from './event-bus';
import {
deleteGroupNoticeByFid as deleteGroupNotice_,
forceFetchClientKey as forceFetchClientKey_,
getCookiesStr as getCookiesStr_,
getCredentials as getCredentials_,
getCsrfToken as getCsrfToken_,
getGroupEssence as getGroupEssence_,
getGroupEssenceAll as getGroupEssenceAll_,
getGroupHonorInfo as getGroupHonorInfo_,
getGroupNotice as getGroupNotice_,
sendGroupNotice as sendGroupNotice_,
} from './web-actions';
import type { WebHonorType } from './web/group-honor';
// web-actions/* removed — moved to apis/web.ts::WebApi.
export { AiVoiceChatType };
export type { AiVoiceCategory, StrangerStatus };
@@ -300,41 +288,15 @@ export class Bridge implements BridgeInterface {
// Interaction methods (sendPoke/sendLike/setReaction/setEssence/
// getEmojiLikes) moved to apis.interaction.
// recall* / markRead* moved to apis/message.ts::MessageApi.
async getGroupHonorInfo(groupId: number, type: WebHonorType | string): Promise<any> {
return getGroupHonorInfo_(this, groupId, type);
}
async forceFetchClientKey(): Promise<ClientKeyInfo> { return forceFetchClientKey_(this) }
async getGroupEssence(groupId: number, pageStart: number = 0, pageLimit: number = 50): Promise<any> {
return getGroupEssence_(this, groupId, pageStart, pageLimit);
}
async getGroupEssenceAll(groupId: number): Promise<any> {
return getGroupEssenceAll_(this, groupId);
}
// GroupAlbum methods (list/upload/getMediaList/comment/like/delete)
// moved to apis.groupAlbum (apis/group-album.ts::GroupAlbumApi).
async sendGroupNotice(groupId: number, content: string, options?: any) {
return sendGroupNotice_(this, groupId, content, options);
}
async getGroupNotice(groupId: number) {
return getGroupNotice_(this, groupId);
}
async deleteGroupNotice(groupId: number, fid: string): Promise<boolean> {
return deleteGroupNotice_(this, groupId, fid);
}
// extend
// Web methods (getHonorInfo / forceFetchClientKey / getEssence /
// getEssenceAll / sendNotice / getNotice / deleteNotice /
// getCookiesStr / getCsrfToken / getCredentials) moved to apis.web.
// Profile methods (setOnlineStatus / setDiyOnlineStatus / setProfile /
// setSelfLongNick / setInputStatus / setAvatar / setGroupAvatar /
// fetchCustomFace / getLike / getUnidirectionalFriendList) moved to
// apis.profile.
async getCookiesStr(domain: string): Promise<string> { return getCookiesStr_(this, domain); }
async getCsrfToken(): Promise<number> { return getCsrfToken_(this); }
async getCredentials(domain: string) { return getCredentials_(this, domain); }
// Misc methods (translateEn2Zh / getMiniAppArk /
// clickInlineKeyboardButton / sendGroupSign) moved to apis.misc.
@@ -1,146 +0,0 @@
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { Bridge } from '../bridge';
import { makeOidbEnvelope, runOidb } from '../bridge-oidb';
import { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbClientKeyReq,
OidbClientKeyResp,
OidbGetPskeyReq,
OidbGetPskeyResp,
} from '@snowluma/proto-defs/oidb-actions/base';
import { RequestUtil } from '../web/request-util';
export async function forceFetchClientKey(bridge: Bridge) {
const env = makeOidbEnvelope<OidbClientKeyReq>(0x102A, 1, {});
const respBytes = await runOidb(bridge, 'OidbSvcTrpcTcp.0x102a_1', protobuf_encode<OidbBase<OidbClientKeyReq>>(env));
const resp = protobuf_decode<OidbBase<OidbClientKeyResp>>(respBytes).body;
const clientKey = resp?.clientKey || '';
// keyIndex falls back to "19" — origin unknown but the value is what
// every NapCat-derived implementation uses when the server omits it.
const keyIndex = String(resp?.keyIndex || '19');
return {
clientKey,
keyIndex,
expireTime: String(resp?.expireTime || '1800'),
};
}
export async function getPSkey(bridge: Bridge, domainList: string[]) {
const env = makeOidbEnvelope<OidbGetPskeyReq>(0x102A, 0, { domainList });
const respBytes = await runOidb(bridge, 'OidbSvcTrpcTcp.0x102a_0', protobuf_encode<OidbBase<OidbGetPskeyReq>>(env));
const resp = protobuf_decode<OidbBase<OidbGetPskeyResp>>(respBytes).body;
const domainPskeyMap = new Map<string, string>();
if (resp?.pskeyItems && Array.isArray(resp.pskeyItems)) {
for (const item of resp.pskeyItems) {
if (item.domain && item.pskey) {
domainPskeyMap.set(item.domain, item.pskey);
}
}
}
return { domainPskeyMap };
}
export async function getCookies(bridge: Bridge, domain: string) {
const ClientKeyData = await forceFetchClientKey(bridge);
// Build the ptlogin2 jump URL: this is the canonical way for the
// bot to swap its clientKey for cookie-jar entries on a given
// qq.com subdomain.
const requestUrl = 'https://ssl.ptlogin2.qq.com/jump?ptlang=1033&clientuin=' + bridge.identity.uin +
'&clientkey=' + ClientKeyData.clientKey +
'&u1=https%3A%2F%2F' + domain + '%2F' + bridge.identity.uin + '%2Finfocenter&keyindex=' + ClientKeyData.keyIndex;
const data = await RequestUtil.HttpsGetCookies(requestUrl);
if (!data['p_skey'] || data['p_skey'].length === 0) {
// ptlogin2 sometimes omits p_skey; fall back to OIDB getPSkey
// for the same domain. Errors are swallowed so the caller can
// still proceed with whatever cookies it did get.
try {
const pskeyData = await getPSkey(bridge, [domain]);
const pskey = pskeyData.domainPskeyMap.get(domain);
if (pskey) {
data['p_skey'] = pskey;
}
} catch {
return data;
}
}
return data;
}
export async function getSKey(bridge: Bridge): Promise<string> {
const ClientKeyData = await forceFetchClientKey(bridge);
if (!ClientKeyData.clientKey) {
throw new Error('getClientKey Error: clientKey is empty');
}
const u1 = encodeURIComponent('https://h5.qzone.qq.com/qqnt/qzoneinpcqq/friend?refresh=0&clientuin=0&darkMode=0');
const requestUrl = 'https://ssl.ptlogin2.qq.com/jump?ptlang=1033' +
'&clientuin=' + bridge.identity.uin +
'&clientkey=' + ClientKeyData.clientKey +
'&u1=' + u1 +
'&keyindex=' + ClientKeyData.keyIndex;
const cookies: { [key: string]: string } = await RequestUtil.HttpsGetCookies(requestUrl);
const skey = cookies['skey'];
if (!skey) {
throw new Error('SKey is Empty');
}
return skey;
}
/**
* Standard QQ bkn hash (also known as token / csrf_token) derived from
* skey or p_skey. djb2 hash truncated to 31 bits.
*/
export function getBknFromSKey(skey: string): number {
let hash = 5381;
for (let i = 0; i < skey.length; i++) {
hash += (hash << 5) + skey.charCodeAt(i);
}
return hash & 2147483647;
}
/** Cookies for `domain` joined into the canonical "k=v; k=v" header form. */
export async function getCookiesStr(bridge: Bridge, domain: string): Promise<string> {
const cookieObject = await getCookies(bridge, domain);
return Object.entries(cookieObject)
.map(([key, value]) => `${key}=${value}`)
.join('; ');
}
/** CSRF token == bkn(skey) — used by qzone / qun web APIs. */
export async function getCsrfToken(bridge: Bridge): Promise<number> {
const skey = await getSKey(bridge);
if (!skey) {
throw new Error('SKey is Empty');
}
return getBknFromSKey(skey);
}
/** Returns the OneBot `get_credentials` payload (cookie string + bkn). */
export async function getCredentials(bridge: Bridge, domain: string) {
const cookieObject = await getCookies(bridge, domain);
const cookiesStr = Object.entries(cookieObject)
.map(([key, value]) => `${key}=${value}`)
.join('; ');
const skey = cookieObject['p_skey'] || cookieObject['skey'] || '';
const token = skey ? getBknFromSKey(skey) : 0;
return {
cookies: cookiesStr,
token: token,
csrf_token: token,
};
}
@@ -1,23 +0,0 @@
import type { Bridge } from '../bridge';
import { getGroupEssenceMsg, getGroupEssenceMsgAll } from '../web/group-essence';
import { getCookies } from './cookies';
/** Paginated fetch — `pageStart` is 0-indexed, `pageLimit` is server-capped at 50. */
export async function getGroupEssence(bridge: Bridge, groupId: number, pageStart: number = 0, pageLimit: number = 50) {
const groupCode = groupId.toString();
const cookieObject = await getCookies(bridge, 'qun.qq.com');
const essenceData = await getGroupEssenceMsg(cookieObject, groupCode, pageStart, pageLimit);
return essenceData || { retcode: -1, data: { is_end: true, msg_list: [] } };
}
/** Walks every page and returns the concatenated result. */
export async function getGroupEssenceAll(bridge: Bridge, groupId: number) {
const groupCode = groupId.toString();
const cookieObject = await getCookies(bridge, 'qun.qq.com');
const essenceDataAll = await getGroupEssenceMsgAll(cookieObject, groupCode);
return essenceDataAll;
}
@@ -1,40 +0,0 @@
import type { Bridge } from '../bridge';
import { getHonorListWebAPI, WebHonorType } from '../web/group-honor';
import { getCookies } from './cookies';
export async function getGroupHonorInfo(bridge: Bridge, groupId: number, type: WebHonorType | string) {
const groupCode = groupId.toString();
const cookieObject = await getCookies(bridge, 'qun.qq.com');
const honorInfo: any = {
group_id: groupId,
current_talkative: null,
talkative_list: [],
performer_list: [],
legend_list: [],
emotion_list: [],
strong_newbie_list: [],
};
if (type === WebHonorType.TALKATIVE || type === WebHonorType.ALL) {
const talkativeList = await getHonorListWebAPI(cookieObject, groupCode, 1);
if (talkativeList.length > 0) {
honorInfo.current_talkative = talkativeList[0];
honorInfo.talkative_list = talkativeList;
}
}
if (type === WebHonorType.PERFORMER || type === WebHonorType.ALL) {
honorInfo.performer_list = await getHonorListWebAPI(cookieObject, groupCode, 2);
}
if (type === WebHonorType.LEGEND || type === WebHonorType.ALL) {
honorInfo.legend_list = await getHonorListWebAPI(cookieObject, groupCode, 3);
}
if (type === WebHonorType.EMOTION || type === WebHonorType.ALL) {
honorInfo.emotion_list = await getHonorListWebAPI(cookieObject, groupCode, 6);
}
return honorInfo;
}
@@ -1,113 +0,0 @@
import type { Bridge } from '../bridge';
import {
deleteGroupNotice,
getGroupNoticeWebAPI,
setGroupNoticeWebAPI,
uploadGroupNoticeImage,
} from '../web/group-notice';
import { getCookies } from './cookies';
export async function sendGroupNotice(
bridge: Bridge,
groupId: number,
content: string,
options?: {
image?: string; // local path, http URL, or undefined (text-only)
pinned?: number;
type?: number;
confirm_required?: number;
},
) {
const groupCode = groupId.toString();
const cookieObject = await getCookies(bridge, 'qun.qq.com');
let picId = '';
let imgWidth = 540;
let imgHeight = 300;
if (options?.image) {
let imageBuffer: Buffer;
if (options.image.startsWith('http://') || options.image.startsWith('https://')) {
const response = await fetch(options.image);
if (!response.ok) throw new Error(`Failed to download image: ${response.status}`);
imageBuffer = Buffer.from(await response.arrayBuffer());
} else {
const { readFileSync } = await import('fs');
imageBuffer = readFileSync(options.image);
}
const picInfo = await uploadGroupNoticeImage(cookieObject, imageBuffer);
if (picInfo) {
picId = picInfo.id;
imgWidth = picInfo.width;
imgHeight = picInfo.height;
}
}
const ret = await setGroupNoticeWebAPI(
cookieObject,
groupCode,
content,
options?.pinned ?? 0,
options?.type ?? 1,
1,
1,
options?.confirm_required ?? 1,
picId,
imgWidth,
imgHeight,
);
if (!ret || ret.ec !== 0) {
throw new Error(`设置群公告失败: ${ret?.em || '未知错误(Cookie过期或权限不足)'}`);
}
return ret;
}
export async function getGroupNotice(bridge: Bridge, groupId: number) {
const groupCode = groupId.toString();
const cookieObject = await getCookies(bridge, 'qun.qq.com');
const ret = await getGroupNoticeWebAPI(cookieObject, groupCode);
if (!ret) {
throw new Error('获取公告失败');
}
const retNotices: any[] = [];
if (ret.feeds) {
for (const key in ret.feeds) {
const feed = ret.feeds[key];
if (!feed) continue;
const image = feed.msg?.pics?.map((pic) => ({
id: pic.id,
height: pic.h,
width: pic.w,
})) || [];
retNotices.push({
notice_id: feed.fid,
sender_id: feed.u,
publish_time: feed.pubt,
message: {
text: feed.msg?.text || '',
image: image,
images: image,
},
settings: feed.settings,
read_num: feed.read_num,
});
}
}
return retNotices;
}
export async function deleteGroupNoticeByFid(bridge: Bridge, groupId: number, fid: string): Promise<boolean> {
const groupCode = groupId.toString();
const cookieObject = await getCookies(bridge, 'qun.qq.com');
return await deleteGroupNotice(cookieObject, groupCode, fid);
}
@@ -1,15 +0,0 @@
export {
forceFetchClientKey, getBknFromSKey, getCookies, getCookiesStr, getCredentials, getCsrfToken, getPSkey, getSKey
} from './cookies';
export { getGroupHonorInfo } from './group-honor';
export {
getGroupEssence,
getGroupEssenceAll
} from './group-essence';
export {
deleteGroupNoticeByFid, getGroupNotice, sendGroupNotice
} from './group-notice';
+8 -8
View File
@@ -146,7 +146,7 @@ export function register(h: ApiHandler, ctx: ApiActionContext): void {
try {
const essenceDataAll = await ctx.bridge.getGroupEssenceAll(groupId);
const essenceDataAll = await ctx.bridge.apis.web.getEssenceAll(groupId);
const allMsgs = essenceDataAll.flatMap((res: any) => res.data?.msg_list || []);
@@ -307,7 +307,7 @@ export function register(h: ApiHandler, ctx: ApiActionContext): void {
confirm_required: params.confirm_required !== undefined ? Number(params.confirm_required) : 1,
};
await ctx.bridge.sendGroupNotice(groupId, content, options);
await ctx.bridge.apis.web.sendNotice(groupId, content, options);
return okResponse();
} catch (e) {
return failedResponse(RETCODE.ACTION_FAILED, String(e));
@@ -320,7 +320,7 @@ export function register(h: ApiHandler, ctx: ApiActionContext): void {
try {
const notices = await ctx.bridge.getGroupNotice(groupId);
const notices = await ctx.bridge.apis.web.getNotice(groupId);
return okResponse(notices);
} catch (e) {
return failedResponse(RETCODE.ACTION_FAILED, String(e));
@@ -337,7 +337,7 @@ export function register(h: ApiHandler, ctx: ApiActionContext): void {
try {
const success = await ctx.bridge.deleteGroupNotice(groupId, fid);
const success = await ctx.bridge.apis.web.deleteNotice(groupId, fid);
if (success) {
return okResponse();
} else {
@@ -483,7 +483,7 @@ export function register(h: ApiHandler, ctx: ApiActionContext): void {
try {
const cookies = await ctx.bridge.getCookiesStr(domain);
const cookies = await ctx.bridge.apis.web.getCookiesStr(domain);
return okResponse({ cookies });
} catch (e) {
return failedResponse(RETCODE.ACTION_FAILED, String(e));
@@ -493,7 +493,7 @@ export function register(h: ApiHandler, ctx: ApiActionContext): void {
h.registerAction('get_csrf_token', async () => {
try {
const token = await ctx.bridge.getCsrfToken();
const token = await ctx.bridge.apis.web.getCsrfToken();
return okResponse({ token });
} catch (e) {
return failedResponse(RETCODE.ACTION_FAILED, String(e));
@@ -505,7 +505,7 @@ export function register(h: ApiHandler, ctx: ApiActionContext): void {
try {
const creds = await ctx.bridge.getCredentials(domain);
const creds = await ctx.bridge.apis.web.getCredentials(domain);
return okResponse(creds);
} catch (e) {
return failedResponse(RETCODE.ACTION_FAILED, String(e));
@@ -983,7 +983,7 @@ export function register(h: ApiHandler, ctx: ApiActionContext): void {
});
h.registerAction('get_clientkey', async () => {
const clientKeyInfo = await ctx.bridge.forceFetchClientKey();
const clientKeyInfo = await ctx.bridge.apis.web.forceFetchClientKey();
if (!clientKeyInfo.clientKey) {
return failedResponse(RETCODE.ACTION_FAILED, 'get clientkey error');
}
@@ -68,7 +68,7 @@ export function register(h: ApiHandler, ctx: ApiActionContext): void {
}
try {
const honorInfo = await ctx.bridge.getGroupHonorInfo(groupId, typeStr as WebHonorType);
const honorInfo = await ctx.bridge.apis.web.getHonorInfo(groupId, typeStr as WebHonorType);
return okResponse(honorInfo);
} catch (e) {
return failedResponse(RETCODE.ACTION_FAILED, `failed to get group honor info: ${(e as Error).message}`);
+10 -10
View File
@@ -147,7 +147,7 @@ export function buildApiContext(ref: OneBotInstanceContext): ApiActionContext {
// Profile-like / get_group_essence — OneBot-specific defaults that the
// action handlers don't supply.
getProfileLike: (userId = undefined, start = 0, limit = 10) => bridge.apis.profile.getLike(userId, start, limit),
getGroupEssence: (groupId, pageStart = 0, pageLimit = 50) => bridge.getGroupEssence(groupId, pageStart, pageLimit),
getGroupEssence: (groupId, pageStart = 0, pageLimit = 50) => bridge.apis.web.getEssence(groupId, pageStart, pageLimit),
// Message-store-backed history reads.
getGroupMsgHistory: (groupId, messageId, count) => getGroupMsgHistory(messageStore, groupId, messageId, count),
@@ -162,7 +162,7 @@ export function buildApiContext(ref: OneBotInstanceContext): ApiActionContext {
getForwardMsg: (resId) => getForwardMessage(ref, resId),
forwardSingleMsg: (messageId, target) => forwardSingleMessage(ref, messageId, target),
handleDeleteFriend: (userId, block) => bridge.apis.friend.delete(userId, !!block),
forceFetchClientKey: () => bridge.forceFetchClientKey(),
forceFetchClientKey: () => bridge.apis.web.forceFetchClientKey(),
setFriendRemark: (userId, remark) => bridge.apis.friend.setRemark(userId, remark),
setGroupRemark: (groupId, remark) => bridge.apis.groupAdmin.setRemark(groupId, remark),
setGroupAvatar: (groupId, source) => bridge.apis.profile.setGroupAvatar(groupId, source),
@@ -184,20 +184,20 @@ export function buildApiContext(ref: OneBotInstanceContext): ApiActionContext {
setProfile: (nickname?: string, personalNote?: string) => bridge.apis.profile.setProfile(nickname, personalNote),
// Web-backed actions.
getGroupHonorInfo: (groupId: number, type: WebHonorType | string) => bridge.getGroupHonorInfo(groupId, type),
getGroupEssenceAll: (groupId) => bridge.getGroupEssenceAll(groupId),
getGroupHonorInfo: (groupId: number, type: WebHonorType | string) => bridge.apis.web.getHonorInfo(groupId, type),
getGroupEssenceAll: (groupId) => bridge.apis.web.getEssenceAll(groupId),
getGroupAlbumList: (groupId) => bridge.apis.groupAlbum.list(groupId),
uploadImageToGroupAlbum: (groupId, albumId, albumName, filePath) => bridge.apis.groupAlbum.upload(groupId, albumId, albumName, filePath),
getGroupAlbumMediaList: (groupId, albumId, attachInfo) => bridge.apis.groupAlbum.getMediaList(groupId, albumId, attachInfo),
commentGroupAlbumMedia: (groupId, albumId, lloc, content) => bridge.apis.groupAlbum.comment(groupId, albumId, lloc, content),
deleteGroupAlbumMedia: (groupId, albumId, lloc) => bridge.apis.groupAlbum.delete(groupId, albumId, lloc),
likeGroupAlbumMedia: (groupId, albumId, batchId, lloc, isLike) => bridge.apis.groupAlbum.like(groupId, albumId, batchId, lloc, isLike),
sendGroupNotice: (groupId, content, options) => bridge.sendGroupNotice(groupId, content, options),
getGroupNotice: (groupId) => bridge.getGroupNotice(groupId),
deleteGroupNotice: (groupId, fid) => bridge.deleteGroupNotice(groupId, fid),
getCookiesStr: (domain) => bridge.getCookiesStr(domain),
getCsrfToken: () => bridge.getCsrfToken(),
getCredentials: (domain) => bridge.getCredentials(domain),
sendGroupNotice: (groupId, content, options) => bridge.apis.web.sendNotice(groupId, content, options),
getGroupNotice: (groupId) => bridge.apis.web.getNotice(groupId),
deleteGroupNotice: (groupId, fid) => bridge.apis.web.deleteNotice(groupId, fid),
getCookiesStr: (domain) => bridge.apis.web.getCookiesStr(domain),
getCsrfToken: () => bridge.apis.web.getCsrfToken(),
getCredentials: (domain) => bridge.apis.web.getCredentials(domain),
// Extended
fetchCustomFace: (count) => bridge.apis.profile.fetchCustomFace(count),
@@ -1,245 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbClientKeyResp,
OidbGetPskeyResp,
} from '@snowluma/proto-defs/oidb-actions/base';
// `encodeOidbEnv` / `decodeOidbEnv` are proton-bound pass-through wrappers
// — the plugin substitutes them at the call site with the inlined codec,
// so mocking them on the module object is a no-op. We mock `runOidb`
// (non-generic, untouched by proton) to return real proton-encoded bytes
// that the production-side codec then decodes.
vi.mock('../../src/bridge/bridge-oidb', async () => {
const actual = await vi.importActual<typeof import('../../src/bridge/bridge-oidb')>(
'../../src/bridge/bridge-oidb',
);
return {
...actual,
runOidb: vi.fn(async () => new Uint8Array()),
makeOidbEnvelope: vi.fn((_oidbCmd, _subCmd, body) => ({ body })),
};
});
// Mock the actual HTTP cookie fetch.
vi.mock('../../src/bridge/web/request-util', () => ({
RequestUtil: {
HttpsGetCookies: vi.fn(async () => ({}) as Record<string, string>),
},
}));
import * as oidb from '../../src/bridge/bridge-oidb';
import { RequestUtil } from '../../src/bridge/web/request-util';
import {
forceFetchClientKey,
getPSkey,
getCookies,
getSKey,
getBknFromSKey,
getCookiesStr,
getCsrfToken,
getCredentials,
} from '../../src/bridge/web-actions/cookies';
const bridge = { identity: { uin: '10001' } } as any;
function resetOidbMocks() {
vi.mocked(oidb.runOidb).mockReset();
vi.mocked(oidb.runOidb).mockResolvedValue(new Uint8Array());
vi.mocked(oidb.makeOidbEnvelope).mockClear();
}
// Bake the encoded OidbBase<X> bytes a test wants `runOidb` to return.
// Keeping these inline as `protobuf_encode<OidbBase<...>>({body: ...})`
// — proton substitutes them with the inlined codec at this file's
// transform time, just like for production code.
describe('cookies — forceFetchClientKey', () => {
beforeEach(() => { resetOidbMocks(); });
it('returns clientKey + keyIndex + expireTime from the OIDB response', async () => {
vi.mocked(oidb.runOidb).mockResolvedValueOnce(
protobuf_encode<OidbBase<OidbClientKeyResp>>({
body: { clientKey: 'ck-xyz', keyIndex: 42, expireTime: 3600 } as any,
}),
);
const out = await forceFetchClientKey(bridge);
expect(out).toEqual({ clientKey: 'ck-xyz', keyIndex: '42', expireTime: '3600' });
});
it('falls back to default keyIndex/expireTime when the response omits them', async () => {
vi.mocked(oidb.runOidb).mockResolvedValueOnce(
protobuf_encode<OidbBase<OidbClientKeyResp>>({ body: { clientKey: 'ck' } as any }),
);
const out = await forceFetchClientKey(bridge);
expect(out).toEqual({ clientKey: 'ck', keyIndex: '19', expireTime: '1800' });
});
});
describe('cookies — getPSkey', () => {
beforeEach(() => { resetOidbMocks(); });
it('folds pskeyItems into a domain → pskey Map', async () => {
vi.mocked(oidb.runOidb).mockResolvedValueOnce(
protobuf_encode<OidbBase<OidbGetPskeyResp>>({
body: {
pskeyItems: [
{ domain: 'qun.qq.com', pskey: 'psk-a' },
{ domain: 'qzone.qq.com', pskey: 'psk-b' },
{ domain: 'corrupt', pskey: '' },
],
} as any,
}),
);
const { domainPskeyMap } = await getPSkey(bridge, ['qun.qq.com', 'qzone.qq.com']);
expect(domainPskeyMap.get('qun.qq.com')).toBe('psk-a');
expect(domainPskeyMap.get('qzone.qq.com')).toBe('psk-b');
expect(domainPskeyMap.has('corrupt')).toBe(false); // empty pskey skipped
});
});
describe('cookies — getCookies', () => {
beforeEach(() => {
resetOidbMocks();
vi.mocked(RequestUtil.HttpsGetCookies).mockReset();
});
it('builds the ptlogin2 jump URL with the right query params', async () => {
vi.mocked(oidb.runOidb).mockResolvedValueOnce(
protobuf_encode<OidbBase<OidbClientKeyResp>>({
body: { clientKey: 'ck', keyIndex: 19 } as any,
}),
);
vi.mocked(RequestUtil.HttpsGetCookies).mockResolvedValueOnce({
p_skey: 'psk',
skey: 'sk',
});
const out = await getCookies(bridge, 'qun.qq.com');
expect(out).toEqual({ p_skey: 'psk', skey: 'sk' });
const url = vi.mocked(RequestUtil.HttpsGetCookies).mock.calls[0]![0];
expect(url).toContain('ssl.ptlogin2.qq.com/jump');
expect(url).toContain('clientuin=10001');
expect(url).toContain('clientkey=ck');
expect(url).toContain('keyindex=19');
});
it('falls back to getPSkey when p_skey is missing in the cookie response', async () => {
vi.mocked(oidb.runOidb)
.mockResolvedValueOnce(
protobuf_encode<OidbBase<OidbClientKeyResp>>({ body: { clientKey: 'ck', keyIndex: 19 } as any }),
)
.mockResolvedValueOnce(
protobuf_encode<OidbBase<OidbGetPskeyResp>>({
body: { pskeyItems: [{ domain: 'qun.qq.com', pskey: 'psk-from-oidb' }] } as any,
}),
);
vi.mocked(RequestUtil.HttpsGetCookies).mockResolvedValueOnce({});
const out = await getCookies(bridge, 'qun.qq.com');
expect(out['p_skey']).toBe('psk-from-oidb');
});
it('returns whatever cookies it got if getPSkey fallback throws', async () => {
vi.mocked(oidb.runOidb)
.mockResolvedValueOnce(
protobuf_encode<OidbBase<OidbClientKeyResp>>({ body: { clientKey: 'ck', keyIndex: 19 } as any }),
)
.mockRejectedValueOnce(new Error('pskey unavailable'));
vi.mocked(RequestUtil.HttpsGetCookies).mockResolvedValueOnce({ skey: 'sk' });
const out = await getCookies(bridge, 'qun.qq.com');
expect(out).toEqual({ skey: 'sk' }); // no p_skey, but no throw
});
});
describe('cookies — getSKey', () => {
beforeEach(() => {
resetOidbMocks();
vi.mocked(RequestUtil.HttpsGetCookies).mockReset();
});
it('returns skey from the qzone-targeted ptlogin2 jump', async () => {
vi.mocked(oidb.runOidb).mockResolvedValueOnce(
protobuf_encode<OidbBase<OidbClientKeyResp>>({ body: { clientKey: 'ck', keyIndex: 19 } as any }),
);
vi.mocked(RequestUtil.HttpsGetCookies).mockResolvedValueOnce({ skey: 'the-skey' });
expect(await getSKey(bridge)).toBe('the-skey');
});
it('throws when clientKey comes back empty', async () => {
// Empty clientKey is the proto3 default; omit so it decodes back to ''.
vi.mocked(oidb.runOidb).mockResolvedValueOnce(
protobuf_encode<OidbBase<OidbClientKeyResp>>({ body: {} }),
);
await expect(getSKey(bridge)).rejects.toThrow(/clientKey is empty/);
});
it('throws when the jump response omits skey', async () => {
vi.mocked(oidb.runOidb).mockResolvedValueOnce(
protobuf_encode<OidbBase<OidbClientKeyResp>>({ body: { clientKey: 'ck', keyIndex: 19 } as any }),
);
vi.mocked(RequestUtil.HttpsGetCookies).mockResolvedValueOnce({});
await expect(getSKey(bridge)).rejects.toThrow(/SKey is Empty/);
});
});
describe('cookies — pure helpers', () => {
beforeEach(() => { resetOidbMocks(); });
it('getBknFromSKey: deterministic djb2 truncated to 31 bits', () => {
// Two calls on the same input → same number; different input → different.
const a = getBknFromSKey('hello');
const b = getBknFromSKey('hello');
const c = getBknFromSKey('world');
expect(a).toBe(b);
expect(a).not.toBe(c);
expect(a).toBeGreaterThanOrEqual(0);
expect(a).toBeLessThanOrEqual(2147483647);
});
it('getCookiesStr: joins cookies with "; "', async () => {
vi.mocked(oidb.runOidb).mockResolvedValueOnce(
protobuf_encode<OidbBase<OidbClientKeyResp>>({ body: { clientKey: 'ck', keyIndex: 19 } as any }),
);
vi.mocked(RequestUtil.HttpsGetCookies).mockResolvedValueOnce({ p_skey: 'p', skey: 's' });
const out = await getCookiesStr(bridge, 'qun.qq.com');
expect(out).toBe('p_skey=p; skey=s');
});
it('getCsrfToken: combines getSKey + getBknFromSKey', async () => {
vi.mocked(oidb.runOidb).mockResolvedValueOnce(
protobuf_encode<OidbBase<OidbClientKeyResp>>({ body: { clientKey: 'ck', keyIndex: 19 } as any }),
);
vi.mocked(RequestUtil.HttpsGetCookies).mockResolvedValueOnce({ skey: 'abc' });
const csrf = await getCsrfToken(bridge);
expect(csrf).toBe(getBknFromSKey('abc'));
});
it('getCredentials: returns { cookies, token, csrf_token } with bkn(p_skey)', async () => {
vi.mocked(oidb.runOidb).mockResolvedValueOnce(
protobuf_encode<OidbBase<OidbClientKeyResp>>({ body: { clientKey: 'ck', keyIndex: 19 } as any }),
);
vi.mocked(RequestUtil.HttpsGetCookies).mockResolvedValueOnce({ p_skey: 'p', skey: 's' });
const out = await getCredentials(bridge, 'qun.qq.com');
expect(out.cookies).toBe('p_skey=p; skey=s');
expect(out.token).toBe(getBknFromSKey('p'));
expect(out.csrf_token).toBe(out.token);
});
it('getCredentials: token is 0 when no skey/p_skey available', async () => {
vi.mocked(oidb.runOidb)
.mockResolvedValueOnce(
protobuf_encode<OidbBase<OidbClientKeyResp>>({ body: { clientKey: 'ck', keyIndex: 19 } as any }),
)
.mockRejectedValueOnce(new Error('no pskey'));
vi.mocked(RequestUtil.HttpsGetCookies).mockResolvedValueOnce({});
const out = await getCredentials(bridge, 'qun.qq.com');
expect(out.token).toBe(0);
expect(out.cookies).toBe('');
});
});
@@ -1,68 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../src/bridge/web-actions/cookies', () => ({
getCookies: vi.fn(async () => ({ p_skey: 'psk' })),
}));
vi.mock('../../src/bridge/web/group-essence', () => ({
getGroupEssenceMsg: vi.fn(async () => null),
getGroupEssenceMsgAll: vi.fn(async () => []),
}));
import * as cookies from '../../src/bridge/web-actions/cookies';
import * as essenceWeb from '../../src/bridge/web/group-essence';
import {
getGroupEssence,
getGroupEssenceAll,
} from '../../src/bridge/web-actions/group-essence';
const bridge = {} as any;
describe('group-essence', () => {
beforeEach(() => {
vi.mocked(cookies.getCookies).mockClear();
vi.mocked(essenceWeb.getGroupEssenceMsg).mockReset();
vi.mocked(essenceWeb.getGroupEssenceMsgAll).mockReset();
});
it('getGroupEssence: passes pageStart + pageLimit through to the web API', async () => {
vi.mocked(essenceWeb.getGroupEssenceMsg).mockResolvedValueOnce({
retcode: 0,
data: { is_end: true, msg_list: [{ a: 1 }] },
} as any);
const out = await getGroupEssence(bridge, 12345, 10, 20);
const args = vi.mocked(essenceWeb.getGroupEssenceMsg).mock.calls[0]!;
expect(args[1]).toBe('12345'); // groupCode
expect(args[2]).toBe(10); // pageStart
expect(args[3]).toBe(20); // pageLimit
expect((out as any).data.msg_list).toHaveLength(1);
});
it('getGroupEssence: defaults pageStart=0 + pageLimit=50', async () => {
vi.mocked(essenceWeb.getGroupEssenceMsg).mockResolvedValueOnce({
retcode: 0,
data: { is_end: true, msg_list: [] },
} as any);
await getGroupEssence(bridge, 12345);
const args = vi.mocked(essenceWeb.getGroupEssenceMsg).mock.calls[0]!;
expect(args[2]).toBe(0);
expect(args[3]).toBe(50);
});
it('getGroupEssence: falls back to an empty result envelope when the API returns null', async () => {
vi.mocked(essenceWeb.getGroupEssenceMsg).mockResolvedValueOnce(null as any);
const out = await getGroupEssence(bridge, 12345);
expect(out).toEqual({ retcode: -1, data: { is_end: true, msg_list: [] } });
});
it('getGroupEssenceAll: delegates to the all-pages web helper', async () => {
vi.mocked(essenceWeb.getGroupEssenceMsgAll).mockResolvedValueOnce([
{ retcode: 0, data: { is_end: false, msg_list: [{}] } },
{ retcode: 0, data: { is_end: true, msg_list: [{}, {}] } },
] as any);
const out = await getGroupEssenceAll(bridge, 12345);
expect(out).toHaveLength(2);
expect(vi.mocked(essenceWeb.getGroupEssenceMsgAll).mock.calls[0]![1]).toBe('12345');
});
});
@@ -1,86 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../src/bridge/web-actions/cookies', () => ({
getCookies: vi.fn(async () => ({ p_skey: 'psk' })),
}));
vi.mock('../../src/bridge/web/group-honor', async () => {
// Keep the real WebHonorType enum so the function-under-test still
// sees the same constants as production code; only the network call
// (`getHonorListWebAPI`) is faked.
const actual = await vi.importActual<typeof import('../../src/bridge/web/group-honor')>('../../src/bridge/web/group-honor');
return {
...actual,
getHonorListWebAPI: vi.fn(async () => [] as Array<unknown>),
};
});
import * as cookies from '../../src/bridge/web-actions/cookies';
import * as honorWeb from '../../src/bridge/web/group-honor';
import { WebHonorType } from '../../src/bridge/web/group-honor';
import { getGroupHonorInfo } from '../../src/bridge/web-actions/group-honor';
const bridge = {} as any;
describe('group-honor — getGroupHonorInfo', () => {
beforeEach(() => {
vi.mocked(cookies.getCookies).mockClear();
vi.mocked(honorWeb.getHonorListWebAPI).mockReset();
});
it('TALKATIVE: calls API only for type=1, populates current_talkative + talkative_list', async () => {
vi.mocked(honorWeb.getHonorListWebAPI).mockResolvedValueOnce([
{ uin: 10001, nick: 'alice', avatar: '', desc: '' },
{ uin: 10002, nick: 'bob', avatar: '', desc: '' },
]);
const out = await getGroupHonorInfo(bridge, 12345, WebHonorType.TALKATIVE);
expect(honorWeb.getHonorListWebAPI).toHaveBeenCalledTimes(1);
expect(vi.mocked(honorWeb.getHonorListWebAPI).mock.calls[0]![2]).toBe(1);
expect(out.current_talkative).toEqual({ uin: 10001, nick: 'alice', avatar: '', desc: '' });
expect(out.talkative_list).toHaveLength(2);
expect(out.performer_list).toEqual([]);
expect(out.legend_list).toEqual([]);
expect(out.emotion_list).toEqual([]);
});
it('TALKATIVE with empty list: current_talkative stays null', async () => {
vi.mocked(honorWeb.getHonorListWebAPI).mockResolvedValueOnce([]);
const out = await getGroupHonorInfo(bridge, 12345, WebHonorType.TALKATIVE);
expect(out.current_talkative).toBeNull();
expect(out.talkative_list).toEqual([]);
});
it('PERFORMER: dispatches API with type=2 only', async () => {
vi.mocked(honorWeb.getHonorListWebAPI).mockResolvedValueOnce([{ uin: 1, nick: 'p', avatar: '', desc: '' }]);
const out = await getGroupHonorInfo(bridge, 12345, WebHonorType.PERFORMER);
expect(vi.mocked(honorWeb.getHonorListWebAPI).mock.calls[0]![2]).toBe(2);
expect(out.performer_list).toHaveLength(1);
expect(out.talkative_list).toEqual([]);
});
it('LEGEND: type=3', async () => {
vi.mocked(honorWeb.getHonorListWebAPI).mockResolvedValueOnce([]);
await getGroupHonorInfo(bridge, 12345, WebHonorType.LEGEND);
expect(vi.mocked(honorWeb.getHonorListWebAPI).mock.calls[0]![2]).toBe(3);
});
it('EMOTION: type=6', async () => {
vi.mocked(honorWeb.getHonorListWebAPI).mockResolvedValueOnce([]);
await getGroupHonorInfo(bridge, 12345, WebHonorType.EMOTION);
expect(vi.mocked(honorWeb.getHonorListWebAPI).mock.calls[0]![2]).toBe(6);
});
it('ALL: fetches talkative + performer + legend + emotion (4 calls)', async () => {
vi.mocked(honorWeb.getHonorListWebAPI).mockResolvedValue([]);
await getGroupHonorInfo(bridge, 12345, WebHonorType.ALL);
expect(honorWeb.getHonorListWebAPI).toHaveBeenCalledTimes(4);
expect(vi.mocked(honorWeb.getHonorListWebAPI).mock.calls.map(c => c[2])).toEqual([1, 2, 3, 6]);
});
it('groupId is stringified to groupCode', async () => {
vi.mocked(honorWeb.getHonorListWebAPI).mockResolvedValueOnce([]);
await getGroupHonorInfo(bridge, 99999, WebHonorType.TALKATIVE);
expect(vi.mocked(honorWeb.getHonorListWebAPI).mock.calls[0]![1]).toBe('99999');
});
});
@@ -1,155 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../src/bridge/web-actions/cookies', () => ({
getCookies: vi.fn(async () => ({ p_skey: 'psk' })),
}));
vi.mock('../../src/bridge/web/group-notice', () => ({
setGroupNoticeWebAPI: vi.fn(async () => ({ ec: 0 })),
getGroupNoticeWebAPI: vi.fn(async () => ({ feeds: {} })),
uploadGroupNoticeImage: vi.fn(async () => null),
deleteGroupNotice: vi.fn(async () => true),
}));
import * as cookies from '../../src/bridge/web-actions/cookies';
import * as noticeWeb from '../../src/bridge/web/group-notice';
import {
sendGroupNotice,
getGroupNotice,
deleteGroupNoticeByFid,
} from '../../src/bridge/web-actions/group-notice';
const bridge = {} as any;
describe('group-notice — sendGroupNotice', () => {
beforeEach(() => {
vi.mocked(cookies.getCookies).mockClear();
vi.mocked(noticeWeb.setGroupNoticeWebAPI).mockReset();
vi.mocked(noticeWeb.uploadGroupNoticeImage).mockReset();
});
it('text-only: dispatches with empty picId and default image dims (540x300)', async () => {
vi.mocked(noticeWeb.setGroupNoticeWebAPI).mockResolvedValueOnce({ ec: 0 } as any);
await sendGroupNotice(bridge, 12345, 'hello');
expect(noticeWeb.uploadGroupNoticeImage).not.toHaveBeenCalled();
const args = vi.mocked(noticeWeb.setGroupNoticeWebAPI).mock.calls[0]!;
expect(args[1]).toBe('12345'); // groupCode
expect(args[2]).toBe('hello'); // content
expect(args[3]).toBe(0); // pinned default
expect(args[4]).toBe(1); // type default
expect(args[7]).toBe(1); // confirm_required default
expect(args[8]).toBe(''); // picId
expect(args[9]).toBe(540); // imgWidth default
expect(args[10]).toBe(300); // imgHeight default
});
it('with options: forwards pinned / type / confirm_required overrides', async () => {
vi.mocked(noticeWeb.setGroupNoticeWebAPI).mockResolvedValueOnce({ ec: 0 } as any);
await sendGroupNotice(bridge, 12345, 'pinned!', {
pinned: 1,
type: 2,
confirm_required: 0,
});
const args = vi.mocked(noticeWeb.setGroupNoticeWebAPI).mock.calls[0]!;
expect(args[3]).toBe(1);
expect(args[4]).toBe(2);
expect(args[7]).toBe(0);
});
it('with http image: fetches bytes, uploads, threads pic info into the post', async () => {
const realFetch = global.fetch;
global.fetch = vi.fn(async () => ({
ok: true,
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
})) as any;
vi.mocked(noticeWeb.uploadGroupNoticeImage).mockResolvedValueOnce({
id: 'pic-xyz', width: 1024, height: 768,
} as any);
vi.mocked(noticeWeb.setGroupNoticeWebAPI).mockResolvedValueOnce({ ec: 0 } as any);
await sendGroupNotice(bridge, 12345, 'image notice', { image: 'https://example.com/x.png' });
expect(global.fetch).toHaveBeenCalledOnce();
expect(noticeWeb.uploadGroupNoticeImage).toHaveBeenCalledOnce();
const args = vi.mocked(noticeWeb.setGroupNoticeWebAPI).mock.calls[0]!;
expect(args[8]).toBe('pic-xyz');
expect(args[9]).toBe(1024);
expect(args[10]).toBe(768);
global.fetch = realFetch;
});
it('image fetch failure: throws "Failed to download image"', async () => {
const realFetch = global.fetch;
global.fetch = vi.fn(async () => ({ ok: false, status: 503 })) as any;
await expect(sendGroupNotice(bridge, 12345, 'x', { image: 'https://bad' }))
.rejects.toThrow(/Failed to download image: 503/);
global.fetch = realFetch;
});
it('throws when the web API returns non-zero ec', async () => {
vi.mocked(noticeWeb.setGroupNoticeWebAPI).mockResolvedValueOnce({
ec: 42, em: 'permission denied',
} as any);
await expect(sendGroupNotice(bridge, 12345, 'x'))
.rejects.toThrow(/permission denied/);
});
it('throws with a generic message when ec≠0 and em is missing', async () => {
vi.mocked(noticeWeb.setGroupNoticeWebAPI).mockResolvedValueOnce({ ec: 7 } as any);
await expect(sendGroupNotice(bridge, 12345, 'x'))
.rejects.toThrow(/Cookie过期或权限不足/);
});
});
describe('group-notice — getGroupNotice', () => {
beforeEach(() => { vi.mocked(noticeWeb.getGroupNoticeWebAPI).mockReset(); });
it('flattens feeds into OneBot-shaped notice list', async () => {
vi.mocked(noticeWeb.getGroupNoticeWebAPI).mockResolvedValueOnce({
feeds: {
f1: {
fid: 'f1', u: 10001, pubt: 1700000000,
msg: { text: 'first', pics: [{ id: 'p1', h: 100, w: 200 }] },
settings: {}, read_num: 5,
} as any,
f2: {
fid: 'f2', u: 10002, pubt: 1700000100,
msg: { text: 'second' } as any,
settings: {}, read_num: 0,
} as any,
},
} as any);
const out = await getGroupNotice(bridge, 12345);
expect(out).toHaveLength(2);
expect(out[0]!.notice_id).toBe('f1');
expect(out[0]!.message.text).toBe('first');
expect(out[0]!.message.image).toEqual([{ id: 'p1', height: 100, width: 200 }]);
expect(out[0]!.message.images).toEqual(out[0]!.message.image); // alias
expect(out[1]!.message.image).toEqual([]); // no pics → empty
});
it('throws when the underlying web API returns null', async () => {
vi.mocked(noticeWeb.getGroupNoticeWebAPI).mockResolvedValueOnce(null as any);
await expect(getGroupNotice(bridge, 12345)).rejects.toThrow(/获取公告失败/);
});
it('returns an empty array when feeds is empty', async () => {
vi.mocked(noticeWeb.getGroupNoticeWebAPI).mockResolvedValueOnce({ feeds: {} } as any);
expect(await getGroupNotice(bridge, 12345)).toEqual([]);
});
});
describe('group-notice — deleteGroupNoticeByFid', () => {
it('delegates to deleteGroupNotice with cookieObject + groupCode + fid', async () => {
vi.mocked(noticeWeb.deleteGroupNotice).mockResolvedValueOnce(true);
const ok = await deleteGroupNoticeByFid(bridge, 12345, 'fid-abc');
expect(ok).toBe(true);
const args = vi.mocked(noticeWeb.deleteGroupNotice).mock.calls[0]!;
expect(args[1]).toBe('12345');
expect(args[2]).toBe('fid-abc');
});
});