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:
@@ -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';
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ───────────────
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user