Enforce no-explicit-any; allow in tests

Enable @typescript-eslint/no-explicit-any as an error in the ESLint config to tighten typing across the codebase, and add an override to disable this rule for test files. Also apply non-functional cleanup in packages/proton/src/runtime.ts: reorder/imports and adjust formatting/indentation and string alignment for readability — no behavioral changes.
This commit is contained in:
手瓜一十雪
2026-05-24 13:38:13 +08:00
parent acc5084647
commit 80ee12ecc2
36 changed files with 229 additions and 147 deletions
+13 -1
View File
@@ -48,7 +48,7 @@ export default defineConfig([
rules: {
'indent': ['error', 2, { SwitchCase: 1 }],
'prefer-const': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-this-alias': 'off',
'@typescript-eslint/no-unused-vars': [
@@ -61,6 +61,18 @@ export default defineConfig([
],
},
},
{
files: ['**/tests/**/*.{ts,tsx}', '**/*.test.{ts,tsx}'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
},
},
{
files: ['**/bench/**/*.{ts,tsx}', 'packages/proton/test/**/*.{ts,tsx}'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
},
},
{
// OIDB service layer is namespace-per-cmd by design — the
// structural-typing contract (namespace IS the OidbCallSpec) and
-10
View File
@@ -1,13 +1,3 @@
// Plain JSON value types — defined here (not in @snowluma/onebot/types
// where they used to live) so @snowluma/protocol can talk about JSON-shaped
// payloads without importing OneBot. The OneBot types module keeps the
// names exported via re-export, so existing call sites that say
// `import type { JsonValue } from '<…>/onebot/types'` continue to work
// unchanged after Phase 3.
//
// Anything actually OneBot-specific (ApiResponse, OneBotRequest, …)
// stays in @snowluma/onebot — only the plain JSON quartet moves here.
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonObject | JsonArray;
export interface JsonObject {
+13 -1
View File
@@ -17,7 +17,19 @@ import type { BridgeContext } from '../bridge-context';
// ─── Helpers (previously in bridge-contacts.ts) ───────────────────
export function buildFriendProperties(raw: any): Map<number, string> {
type FriendPropertySource = {
additional?: Array<{
type?: number;
layer1?: {
properties?: Array<{
code?: number;
value?: string;
}>;
};
}>;
};
export function buildFriendProperties(raw: FriendPropertySource): Map<number, string> {
const props = new Map<number, string>();
for (const additional of raw.additional ?? []) {
if ((additional.type ?? 0) !== 1 || !additional.layer1) continue;
+19 -14
View File
@@ -6,32 +6,35 @@ import type {
DoQunLikeRequest,
DoQunLikeResponse,
GetMediaListRequest,
MediaInfo,
GetMediaListResponse,
} from '@snowluma/proto-defs/oidb-actions/group-album';
import { getGroupAlbumList, uploadImageToGroupAlbum } from '@snowluma/protocol/web/group-album';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { JsonObject, JsonValue } from '@snowluma/common/json';
import type { BridgeContext } from '../bridge-context';
function convertBigIntToString(obj: any): any {
if (obj === null || obj === undefined) return obj;
function convertBigIntToString(obj: unknown): JsonValue {
if (obj === null || obj === undefined) return null;
if (typeof obj === 'bigint') return obj.toString();
if (Array.isArray(obj)) return obj.map(convertBigIntToString);
if (typeof obj === 'object') {
const result: any = {};
for (const key in obj) {
result[key] = convertBigIntToString(obj[key]);
const result: JsonObject = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = convertBigIntToString(value);
}
return result;
}
return obj;
if (typeof obj === 'string' || typeof obj === 'number' || typeof obj === 'boolean') return obj;
return null;
}
export class GroupAlbumApi {
constructor(private readonly ctx: BridgeContext) { }
/** List a group's albums (HTTP-cookie-based, qzone.qq.com). */
async list(groupId: number): Promise<any> {
// 列出群相册(基于 qzone.qq.com 的 HTTP Cookie)。
async list(groupId: number): Promise<GroupAlbumList> {
const groupCode = groupId.toString();
const uin = this.ctx.identity.uin;
const cookieObject = await this.ctx.apis.web.getCookies('qzone.qq.com');
@@ -39,7 +42,7 @@ export class GroupAlbumApi {
return albumData?.album || [];
}
/** Upload an image into an existing album (HTTP slice-upload). */
// 上传图片到现有相册(HTTP 分片上传)。
async upload(groupId: number, albumId: string, albumName: string, filePath: string): Promise<void> {
const groupCode = groupId.toString();
const uin = this.ctx.identity.uin;
@@ -85,7 +88,7 @@ export class GroupAlbumApi {
const mediaList = data.mediaList ?? [];
const nextAttachInfo = data.nextAttachInfo ?? '';
return convertBigIntToString({ mediaList, nextAttachInfo });
return convertBigIntToString({ mediaList, nextAttachInfo }) as unknown as GroupAlbumMediaResult;
}
async comment(groupId: number, albumId: string, lloc: string, content: string): Promise<GroupAlbumCommentResult> {
@@ -156,10 +159,10 @@ export class GroupAlbumApi {
content: commentData.content ?? [],
time: commentData.time ?? '0',
clientKey: commentData.clientKey ?? '',
});
}) as unknown as GroupAlbumCommentResult;
}
async like(groupId: number, albumId: string, batchId: string, lloc: string | undefined, isLike: boolean): Promise<any> {
async like(groupId: number, albumId: string, batchId: string, lloc: string | undefined, isLike: boolean): Promise<JsonValue> {
const uin = this.ctx.identity.uin;
const clientKey = `${uin}_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
@@ -221,7 +224,7 @@ export class GroupAlbumApi {
return convertBigIntToString(resp.body?.like ?? {});
}
async delete(groupId: number, albumId: string, lloc: string): Promise<any> {
async delete(groupId: number, albumId: string, lloc: string): Promise<{ success: true }> {
const uin = this.ctx.identity.uin;
const clientKey = `${uin}_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
@@ -261,10 +264,12 @@ export class GroupAlbumApi {
}
}
export interface GroupAlbumMediaResult {
mediaList: any[];
mediaList: Array<JsonValue & Partial<MediaInfo>>;
nextAttachInfo: string;
}
export type GroupAlbumList = NonNullable<Awaited<ReturnType<typeof getGroupAlbumList>>>['album'];
export interface GroupAlbumCommentResult {
id: string;
user: { uin: string };
+1 -1
View File
@@ -440,7 +440,7 @@ export class GroupFileApi {
: toInt(upload.uploadPort);
if (!uploadHost || uploadPort <= 0) {
const rtpDump = Array.isArray(upload.rtpMediaPlatformUploadAddress)
? JSON.stringify(upload.rtpMediaPlatformUploadAddress.map((e: any) => ({
? JSON.stringify(upload.rtpMediaPlatformUploadAddress.map((e) => ({
outIP: e.outIP, outPort: e.outPort, inIP: e.inIP, inPort: e.inPort,
iPType: e.iPType,
})))
+2 -2
View File
@@ -55,7 +55,7 @@ export class MessageApi {
richText: {
elems: protoElems,
},
} as any,
},
clientSequence: 0,
random,
syncCookie: new Uint8Array(0),
@@ -117,7 +117,7 @@ export class MessageApi {
richText: {
elems: protoElems,
},
} as any,
},
clientSequence: clientSeq,
random,
syncCookie: new Uint8Array(0),
+4 -3
View File
@@ -3,6 +3,7 @@ import type {
MiniAppShareResp,
} from '@snowluma/proto-defs/oidb-actions/base';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { JsonObject, JsonValue } from '@snowluma/common/json';
import type { BridgeContext } from '../bridge-context';
// Migrated OIDB cmds — facade methods are one-line forwarders.
import { ClickInlineKeyboardButton } from '@snowluma/protocol/oidb-services/misc/click-inline-keyboard-button';
@@ -16,7 +17,7 @@ export class MiscApi {
return TranslateEnToZh.invoke(this.ctx, { words });
}
async getMiniAppArk(type: string, title: string, desc: string, picUrl: string, jumpUrl: string): Promise<any> {
async getMiniAppArk(type: string, title: string, desc: string, picUrl: string, jumpUrl: string): Promise<JsonObject> {
let appid = '1109937557'; // default: bilibili
let iconUrl = 'http://miniapp.gtimg.cn/public/appicon/51f90239b78a2e4994c11215f4c4ba15_200.jpg';
@@ -45,7 +46,7 @@ export class MiscApi {
throw new Error('mini app share json empty');
}
const parsed = JSON.parse(jsonStr);
const parsed = JSON.parse(jsonStr) as Record<string, JsonObject | string | number | boolean | null>;
return {
data: {
@@ -67,7 +68,7 @@ export class MiscApi {
buttonId: string,
callbackData: string,
msgSeq: number,
): Promise<any> {
): Promise<JsonValue> {
return ClickInlineKeyboardButton.invoke(this.ctx, { groupId, botAppid, buttonId, callbackData, msgSeq });
}
+7 -7
View File
@@ -7,8 +7,8 @@ import type {
} from '@snowluma/proto-defs/oidb-actions/base';
import { fetchHighwaySession, uploadHighwayHttp } from '@snowluma/protocol/highway';
import { computeHashes, loadBinarySource } from '@snowluma/protocol/highway/utils';
import { GetLike } from '@snowluma/protocol/oidb-services/profile/get-like';
import { GetUnidirectionalFriendList } from '@snowluma/protocol/oidb-services/profile/get-unidirectional-friend-list';
import { GetLike, type LikeInfo } from '@snowluma/protocol/oidb-services/profile/get-like';
import { GetUnidirectionalFriendList, type UnidirectionalFriendEntry } from '@snowluma/protocol/oidb-services/profile/get-unidirectional-friend-list';
import { SetInputStatus } from '@snowluma/protocol/oidb-services/profile/set-input-status';
import { SetProfile } from '@snowluma/protocol/oidb-services/profile/set-profile';
import { SetSelfLongNick } from '@snowluma/protocol/oidb-services/profile/set-self-long-nick';
@@ -120,11 +120,11 @@ export class ProfileApi {
// ─────────────── queries on me / my contacts ───────────────
getLike(userId?: number, start = 0, limit = 10): Promise<any> {
getLike(userId?: number, start = 0, limit = 10): Promise<LikeInfo> {
return GetLike.invoke(this.ctx, { userId, start, limit });
}
getUnidirectionalFriendList(): Promise<any> {
getUnidirectionalFriendList(): Promise<UnidirectionalFriendEntry[]> {
return GetUnidirectionalFriendList.invoke(this.ctx);
}
@@ -141,10 +141,10 @@ export class ProfileApi {
throw new Error(result.errorMessage || 'fetch custom face failed');
}
const resp = protobuf_decode<FaceroamOpResp>(result.responseData);
if (!resp || (resp as any).retCode !== 0) {
throw new Error(`fetch custom face error: ${(resp as any)?.message || 'unknown'}`);
if (!resp || resp.retCode !== 0) {
throw new Error(`fetch custom face error: ${resp?.message || 'unknown'}`);
}
const faceIds = (resp as any).item?.faceIds || [];
const faceIds = resp.item?.faceIds || [];
return faceIds.slice(0, count).map((id: string) => `https://p.qpic.cn/qq_expression/${this.ctx.identity.uin}/${id}/0`);
}
}
+35 -9
View File
@@ -1,11 +1,12 @@
import { ForceFetchClientKey, type ClientKeyInfo as NamespaceClientKeyInfo } from '@snowluma/protocol/oidb-services/web/force-fetch-client-key';
import { GetPskey } from '@snowluma/protocol/oidb-services/web/get-pskey';
import { getGroupEssenceMsg, getGroupEssenceMsgAll } from '@snowluma/protocol/web/group-essence';
import { getHonorListWebAPI, WebHonorType } from '@snowluma/protocol/web/group-honor';
import { getGroupEssenceMsg, getGroupEssenceMsgAll, type GroupEssenceMsgRet } from '@snowluma/protocol/web/group-essence';
import { getHonorListWebAPI, WebHonorType, type WebHonorItem } from '@snowluma/protocol/web/group-honor';
import {
deleteGroupNotice as deleteGroupNoticeHttp,
getGroupNoticeWebAPI,
setGroupNoticeWebAPI,
type SetNoticeRetSuccess,
uploadGroupNoticeImage,
} from '@snowluma/protocol/web/group-notice';
import { RequestUtil } from '@snowluma/protocol/web/request-util';
@@ -16,6 +17,31 @@ function asBridge(ctx: BridgeContext): Bridge { return ctx as unknown as Bridge;
export type ClientKeyInfo = NamespaceClientKeyInfo;
export interface WebHonorInfo {
[key: string]: import('@snowluma/common/json').JsonValue;
group_id: number;
current_talkative: WebHonorItem | null;
talkative_list: WebHonorItem[];
performer_list: WebHonorItem[];
legend_list: WebHonorItem[];
emotion_list: WebHonorItem[];
strong_newbie_list: WebHonorItem[];
}
export interface WebNoticeInfo {
[key: string]: import('@snowluma/common/json').JsonValue;
notice_id: string;
sender_id: number;
publish_time: number;
message: {
text: string;
image: Array<{ id: string; height: number; width: number }>;
images: Array<{ id: string; height: number; width: number }>;
};
settings: import('@snowluma/common/json').JsonValue;
read_num: number;
}
// ─────────────── private helpers (cookie acquisition) ───────────────
function forceFetchClientKeyInner(bridge: Bridge): Promise<ClientKeyInfo> {
@@ -144,7 +170,7 @@ export class WebApi {
// ─────────────── group essence ───────────────
/** Paginated fetch — `pageStart` is 0-indexed, `pageLimit` is server-capped at 50. */
async getEssence(groupId: number, pageStart = 0, pageLimit = 50): Promise<any> {
async getEssence(groupId: number, pageStart = 0, pageLimit = 50): Promise<GroupEssenceMsgRet> {
const bridge = asBridge(this.ctx);
const groupCode = groupId.toString();
const cookieObject = await getCookies(bridge, 'qun.qq.com');
@@ -153,7 +179,7 @@ export class WebApi {
}
/** Walks every page and returns the concatenated result. */
async getEssenceAll(groupId: number): Promise<any> {
async getEssenceAll(groupId: number): Promise<GroupEssenceMsgRet[]> {
const bridge = asBridge(this.ctx);
const groupCode = groupId.toString();
const cookieObject = await getCookies(bridge, 'qun.qq.com');
@@ -162,12 +188,12 @@ export class WebApi {
// ─────────────── group honor ───────────────
async getHonorInfo(groupId: number, type: WebHonorType | string): Promise<any> {
async getHonorInfo(groupId: number, type: WebHonorType | string): Promise<WebHonorInfo> {
const bridge = asBridge(this.ctx);
const groupCode = groupId.toString();
const cookieObject = await getCookies(bridge, 'qun.qq.com');
const honorInfo: any = {
const honorInfo: WebHonorInfo = {
group_id: groupId,
current_talkative: null,
talkative_list: [],
@@ -211,7 +237,7 @@ export class WebApi {
type?: number;
confirm_required?: number;
},
): Promise<any> {
): Promise<SetNoticeRetSuccess> {
const bridge = asBridge(this.ctx);
const groupCode = groupId.toString();
const cookieObject = await getCookies(bridge, 'qun.qq.com');
@@ -261,7 +287,7 @@ export class WebApi {
return ret;
}
async getNotice(groupId: number): Promise<any[]> {
async getNotice(groupId: number): Promise<WebNoticeInfo[]> {
const bridge = asBridge(this.ctx);
const groupCode = groupId.toString();
const cookieObject = await getCookies(bridge, 'qun.qq.com');
@@ -271,7 +297,7 @@ export class WebApi {
throw new Error('获取公告失败');
}
const retNotices: any[] = [];
const retNotices: WebNoticeInfo[] = [];
if (ret.feeds) {
for (const key in ret.feeds) {
+2 -2
View File
@@ -151,7 +151,7 @@ describe('pipeline — runNtv2Upload', () => {
});
const upload = await runNtv2Upload(baseParams({ bridge }));
expect(upload.uKey).toBe('fake-ukey');
expect(upload.msgInfo.msgInfoBody).toHaveLength(1);
expect(upload.msgInfo?.msgInfoBody).toHaveLength(1);
});
it('throws on transport failure', async () => {
@@ -315,7 +315,7 @@ describe('pipeline — finalizeMediaMsgInfo', () => {
it('encodes msgInfoBody + extBizInfo from the server response', () => {
const out = finalizeMediaMsgInfo({
msgInfo: {
msgInfoBody: [{ index: { fileType: 1 }, fileExist: true }],
msgInfoBody: [{ index: { subType: 1 }, fileExist: true }],
extBizInfo: { busiType: 1 },
},
});
+3 -3
View File
@@ -139,7 +139,7 @@ export function register(h: ApiHandler, ctx: ApiActionContext): void {
try {
const essenceDataAll = await ctx.bridge.apis.web.getEssenceAll(groupId);
const allMsgs = essenceDataAll.flatMap((res: any) => res.data?.msg_list || []);
const allMsgs = essenceDataAll.flatMap((res) => res.data?.msg_list || []);
return okResponse(allMsgs);
} catch (e) {
@@ -347,7 +347,7 @@ export function register(h: ApiHandler, ctx: ApiActionContext): void {
message_id: 0,
};
if (groupId > 0) data.group_id = groupId;
return okResponse(data as any);
return okResponse(data as import('../types').JsonObject);
});
h.registerAction('upload_foward_msg', async (params) => {
@@ -985,7 +985,7 @@ export function register(h: ApiHandler, ctx: ApiActionContext): void {
}
});
const handleGroupSign = async (params: any) => {
const handleGroupSign = async (params: import('../types').JsonObject) => {
const groupId = asNumber(params.group_id);
if (!groupId) {
+3 -3
View File
@@ -26,10 +26,10 @@ export interface GroupEssenceMsgRet {
retcode: number;
data: {
is_end: boolean;
msg_list: any[];
[key: string]: any;
msg_list: JsonObject[];
[key: string]: JsonValue;
};
[key: string]: any;
[key: string]: JsonValue;
}
export interface ApiActionContext {
-2
View File
@@ -36,7 +36,6 @@ export class OneBotInstance {
private readonly reactionStore: ReactionStore;
private readonly networkManager: OneBotNetworkManager;
private readonly rkeyCache: RKeyCache;
private readonly ctx: OneBotInstanceContext;
private disposeEventPipeline: (() => void) | null = null;
private readonly pids = new Set<number>();
@@ -85,7 +84,6 @@ export class OneBotInstance {
cacheMessageMeta: (messageId, meta) => this.cacheMessageMeta(messageId, meta),
dispatchEvent: (event) => this.dispatchEvent(event),
};
this.ctx = ctx;
this.apiHandler = new ApiHandler(buildApiContext(ctx), uinNum > 0 ? uinNum : undefined);
this.networkManager = new OneBotNetworkManager();
+12 -12
View File
@@ -35,13 +35,13 @@ export interface SendContext {
function makeTextElem(text: string): ProtoElem {
return {
text: { str: text } as any,
text: { str: text },
};
}
function makeFaceElem(faceId: number): ProtoElem {
return {
face: { index: faceId } as any,
face: { index: faceId },
};
}
@@ -83,14 +83,14 @@ function makeMentionElem(element: MessageElement, ctx?: SendContext): ProtoElem
text: {
str,
pbReserve: extra,
} as any,
},
};
}
function makeReplyElem(element: MessageElement): ProtoElem {
const seq = element.replySeq! & 0xFFFFFFFF;
const srcMsg: any = {
const srcMsg: NonNullable<Elem['srcMsg']> = {
origSeqs: [seq],
};
@@ -116,7 +116,7 @@ function makeJsonElem(element: MessageElement): ProtoElem {
richMsg: {
serviceId: 1,
template1: payload,
} as any,
},
};
}
@@ -131,7 +131,7 @@ function makeXmlElem(element: MessageElement): ProtoElem {
richMsg: {
serviceId: element.subType === 0 ? 35 : (element.subType ?? 35),
template1: payload,
} as any,
},
};
}
@@ -143,7 +143,7 @@ function makeMarkdownElem(element: MessageElement): ProtoElem {
serviceType: 45,
pbElem: data,
businessType: 1,
} as any,
},
};
}
@@ -216,7 +216,7 @@ function makeForwardElem(element: MessageElement): ProtoElem {
return {
lightApp: {
data: payload,
} as any,
},
};
}
@@ -234,7 +234,7 @@ async function makeImageElem(ctx: SendContext, element: MessageElement): Promise
serviceType: 48,
pbElem: msgInfo,
businessType: isGroup ? 20 : 10,
} as any,
},
};
}
@@ -255,7 +255,7 @@ async function makePttElem(ctx: SendContext, element: MessageElement): Promise<P
serviceType: 48,
pbElem: msgInfo,
businessType: isGroup ? 22 : 12,
} as any,
},
};
}
@@ -324,7 +324,7 @@ function makeGroupFileElem(element: MessageElement, ctx?: SendContext): ProtoEle
transElem: {
elemType: 24,
elemValue,
} as any,
},
};
}
@@ -345,7 +345,7 @@ async function makeVideoElem(ctx: SendContext, element: MessageElement): Promise
serviceType: 48,
pbElem: msgInfo,
businessType: isGroup ? 21 : 11,
} as any,
},
};
}
@@ -4,7 +4,10 @@ import type { BridgeContext } from '../bridge-context';
import type {
HttpConn0x6FF501Request,
HttpConn0x6FF501Response,
HighwayMsgInfoBody,
NTV2IPv4,
NTV2RichMediaHighwayExt,
NTV2UploadRespMsgInfo,
ReqDataHighwayHead,
RespDataHighwayHead,
} from '@snowluma/proto-defs/highway';
@@ -91,8 +94,8 @@ function makeHighwayHead(
export function buildHighwayExtend(
uKey: string,
msgInfo: any,
ipv4s: any[],
msgInfo: NTV2UploadRespMsgInfo,
ipv4s: NTV2IPv4[],
sha1: Uint8Array | Uint8Array[],
fileIndex = 0,
): Uint8Array {
@@ -100,7 +103,7 @@ export function buildHighwayExtend(
if (msgInfoBody.length === 0) throw new Error('upload response missing msg_info body');
const selected = msgInfoBody[fileIndex] ?? msgInfoBody[0];
const networkIpv4s: any[] = [];
const networkIpv4s: NonNullable<NonNullable<NTV2RichMediaHighwayExt['network']>['ipv4s']> = [];
for (const ipv4 of ipv4s ?? []) {
const ip = ipv4.outIp ?? 0;
const port = ipv4.outPort ?? 0;
@@ -113,7 +116,7 @@ export function buildHighwayExtend(
fileUuid: selected?.index?.fileUuid ?? '',
uKey,
network: { ipv4s: networkIpv4s },
msgInfoBody: msgInfoBody.map((b: any) => ({
msgInfoBody: msgInfoBody.map((b: HighwayMsgInfoBody) => ({
index: b.index, picture: b.picture, fileExist: b.fileExist, hashSum: b.hashSum,
})),
blockSize: HIGHWAY_BLOCK_SIZE,
+15 -9
View File
@@ -5,8 +5,12 @@ import type { BridgeContext } from '../bridge-context';
import { makeOidbEnvelope } from '../bridge-oidb';
import type {
EncodableMediaMsgInfo,
HighwayMsgInfoBody,
NTV2ExtBizInfo,
NTV2UploadInfo,
NTV2UploadRichMediaReq,
NTV2UploadRichMediaResp,
NTV2UploadRespBody,
} from '@snowluma/proto-defs/highway';
import { OidbBase } from '@snowluma/proto-defs/oidb';
import { buildHighwayExtend, fetchHighwaySession, uploadHighwayHttp } from './highway-client';
@@ -64,11 +68,11 @@ export interface NtV2UploadParams {
/** `reqHead.scene.businessType` (1=image, 3=voice, 2=video). */
businessType: number;
/** `upload.uploadInfo` array. Each entry is `{ fileInfo, subFileType }`. */
uploadInfo: unknown[];
uploadInfo: NTV2UploadInfo[];
/** `upload.compatQmsgSceneType`. */
compatQmsgSceneType: number;
/** `upload.extBizInfo` — type-specific bytes/flags. */
extBizInfo: unknown;
extBizInfo: NTV2ExtBizInfo;
/** Sub-file Highway PUTs to perform after the OIDB response. */
uploads: MediaSubFileUpload[];
/** Used in error messages. Defaults to 'media'. */
@@ -107,7 +111,7 @@ export function hexToBytes(hex: string): Uint8Array {
* Sessions are cached across sub-file uploads — video does two PUTs but
* only fetches the Highway session once.
*/
export async function runNtv2Upload(params: NtV2UploadParams): Promise<any> {
export async function runNtv2Upload(params: NtV2UploadParams): Promise<NTV2UploadRespBody> {
const { bridge, isGroup, targetIdOrUid, oidbCmd, serviceCmd, uploads } = params;
const label = params.label ?? 'media';
const raw = bridge.identity?.uin;
@@ -118,7 +122,7 @@ export async function runNtv2Upload(params: NtV2UploadParams): Promise<any> {
// Build the full body once. Pulling this in here means each format file
// only specifies the seven fields that actually vary.
const body: any = {
const body: NTV2UploadRichMediaReq = {
reqHead: {
common: { requestId: params.requestId, command: 100 },
scene: {
@@ -157,7 +161,7 @@ export async function runNtv2Upload(params: NtV2UploadParams): Promise<any> {
throw new Error(`OIDB error ${resp.errorCode}: ${resp.errorMsg ?? ''}`);
}
const uploadBody: any = resp.body;
const uploadBody = resp.body;
if (!uploadBody) throw new Error(`${label} upload response body missing`);
if (uploadBody.respHead?.retCode && uploadBody.respHead.retCode !== 0) {
throw new Error(uploadBody.respHead.message ?? `${label} upload failed`);
@@ -186,11 +190,13 @@ export async function runNtv2Upload(params: NtV2UploadParams): Promise<any> {
continue;
}
if (!target) continue;
const extend = buildHighwayExtend(
uKey,
upload.msgInfo,
target.ipv4s ?? [],
sub.sha1 as any, // sha1 may be Uint8Array | Uint8Array[]; the helper accepts both
sub.sha1,
sub.subFileIndex ?? 0,
);
log.debug('%s OIDB requires bytes, PUT %d bytes (sub=%s)', label, sub.bytes.length, String(sub.source));
@@ -218,16 +224,16 @@ export async function runNtv2Upload(params: NtV2UploadParams): Promise<any> {
* pic alone unless the server populates it.
*/
export function finalizeMediaMsgInfo(
upload: any,
upload: NTV2UploadRespBody,
defaultPic?: { bizType: number; textSummary: string },
): Uint8Array {
if (!upload?.msgInfo) throw new Error('upload response missing msgInfo');
const msgInfoBody = (upload.msgInfo.msgInfoBody ?? []).map((b: any) => ({
const msgInfoBody = (upload.msgInfo.msgInfoBody ?? []).map((b: HighwayMsgInfoBody) => ({
index: b.index, picture: b.picture, fileExist: b.fileExist, hashSum: b.hashSum,
}));
const extBizInfo: any = {};
const extBizInfo: NonNullable<EncodableMediaMsgInfo['extBizInfo']> = {};
if (upload.msgInfo.extBizInfo?.pic) {
extBizInfo.pic = { ...upload.msgInfo.extBizInfo.pic };
if (defaultPic) {
@@ -23,7 +23,7 @@ export namespace FetchFriendListPage {
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): OidbFriendListRequest => {
const body: any = {
const body: OidbFriendListRequest = {
friendCount: 300,
field4: 0,
field6: 1,
@@ -25,7 +25,7 @@ export namespace FetchGroupMemberListPage {
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): OidbGroupMemberListRequest => {
const body: any = {
const body: OidbGroupMemberListRequest = {
groupUin: p.groupId,
field2: 5,
field3: 2,
@@ -41,7 +41,7 @@ export namespace GetStrangerStatus {
export const deserialize = (_ctx: Deps, body: OidbStrangerStatusResp): StrangerStatus | null => {
const raw = body.data?.status?.value;
if (raw === undefined || raw === null) return null;
const extBig = typeof raw === 'bigint' ? raw : BigInt(raw as any);
const extBig = typeof raw === 'bigint' ? raw : BigInt(raw);
if (extBig <= 10n) {
return { status: Number(extBig) * 10, ext_status: 0 };
}
@@ -31,7 +31,7 @@ export namespace GetGroupPttUrl {
client: { agentType: 2 },
},
download: {
node: normalizeMediaNode(p.node) as any,
node: normalizeMediaNode(p.node),
download: { video: { busiType: 0, sceneType: 0 } },
},
});
@@ -30,7 +30,7 @@ export namespace GetGroupVideoUrl {
client: { agentType: 2 },
},
download: {
node: normalizeMediaNode(p.node) as any,
node: normalizeMediaNode(p.node),
download: { video: { busiType: 0, sceneType: 0 } },
},
});
@@ -30,7 +30,7 @@ export namespace GetPrivatePttUrl {
client: { agentType: 2 },
},
download: {
node: normalizeMediaNode(p.node) as any,
node: normalizeMediaNode(p.node),
download: { video: { busiType: 0, sceneType: 0 } },
},
});
@@ -30,7 +30,7 @@ export namespace GetPrivateVideoUrl {
client: { agentType: 2 },
},
download: {
node: normalizeMediaNode(p.node) as any,
node: normalizeMediaNode(p.node),
download: { video: { busiType: 0, sceneType: 0 } },
},
});
@@ -9,7 +9,7 @@
// differ. The response is parsed into a `domain + urlPath +
// rKeyParam` triple → composed into `https://<domain><path><rKey>`.
import type { NTV2RichMediaResp } from '@snowluma/proto-defs/oidb-actions/media';
import type { NTV2IndexNode, NTV2RichMediaResp } from '@snowluma/proto-defs/oidb-actions/media';
import { ensureRetCodeZero } from '../shared';
export function parseNtv2DownloadUrl(body: NTV2RichMediaResp): string {
@@ -24,7 +24,7 @@ export function parseNtv2DownloadUrl(body: NTV2RichMediaResp): string {
/** Normalize a MediaIndexNode-shaped object into the wire-fields the
* NTV2RichMedia `download.node` slot expects: `info{...}` /
* `fileUuid` / `storeId` / `uploadTime` / `ttl` / `subType`. */
export function normalizeMediaNode(node: NtMediaIndex): Record<string, unknown> {
export function normalizeMediaNode(node: NtMediaIndex): NTV2IndexNode {
const fileUuid = typeof node.fileUuid === 'string' ? node.fileUuid : '';
if (!fileUuid) throw new Error('media node fileUuid is required');
@@ -8,6 +8,7 @@ import type { Oidb0x112eReq, Oidb0x112eResp } from '@snowluma/proto-defs/oidb-ac
import { invokeOidb, type OidbSender } from '../../oidb-service';
export interface InlineButtonClickResult {
[key: string]: import('@snowluma/common/json').JsonValue;
result: number;
errMsg: string;
status: 0;
@@ -10,15 +10,18 @@ import { invokeOidb, type OidbSender } from '../../oidb-service';
import type { BridgeContext } from '../../bridge-context';
export interface LikeInfo {
[key: string]: import('@snowluma/common/json').JsonValue;
uid: string;
time: number;
favoriteInfo: {
[key: string]: import('@snowluma/common/json').JsonValue;
total_count: number;
last_time: number;
today_count: number;
userInfos: never[];
};
voteInfo: {
[key: string]: import('@snowluma/common/json').JsonValue;
total_count: number;
new_count: number;
new_nearby_count: number;
@@ -13,6 +13,10 @@ import type { Oidb0xe17Req, Oidb0xe17Resp } from '@snowluma/proto-defs/oidb-acti
import { invokeOidb, type OidbSender } from '../../oidb-service';
import type { BridgeContext } from '../../bridge-context';
export interface UnidirectionalFriendEntry {
[key: string]: import('@snowluma/common/json').JsonValue;
}
export namespace GetUnidirectionalFriendList {
export const command = 0xE17;
export const subCommand = 0;
@@ -35,9 +39,9 @@ export namespace GetUnidirectionalFriendList {
return { jsonBody: JSON.stringify(reqObj) };
};
export const deserialize = (_ctx: Deps, body: Oidb0xe17Resp): unknown[] => {
export const deserialize = (_ctx: Deps, body: Oidb0xe17Resp): UnidirectionalFriendEntry[] => {
if (!body || !body.jsonBody) throw new Error('get unidirectional friend list empty');
const parsed = JSON.parse(body.jsonBody);
const parsed = JSON.parse(body.jsonBody) as { rpt_block_list?: UnidirectionalFriendEntry[] };
return parsed.rpt_block_list || [];
};
@@ -47,6 +51,6 @@ export namespace GetUnidirectionalFriendList {
export const decode = (bytes: Uint8Array): OidbBase<Oidb0xe17Resp> =>
protobuf_decode<OidbBase<Oidb0xe17Resp>>(bytes);
export const invoke = (deps: Deps): Promise<unknown[]> =>
export const invoke = (deps: Deps): Promise<UnidirectionalFriendEntry[]> =>
invokeOidb(deps, GetUnidirectionalFriendList, {});
}
+5 -4
View File
@@ -3,6 +3,7 @@ import { createHash } from 'crypto';
import { statSync, createReadStream, writeFileSync, unlinkSync } from 'fs';
import { basename, join } from 'path';
import { tmpdir } from 'os';
import type { JsonValue } from '@snowluma/common/json';
// 群相册信息
export interface GroupAlbumInfo {
@@ -10,13 +11,13 @@ export interface GroupAlbumInfo {
name: string;
picNum: number;
createTime: number;
[key: string]: any;
[key: string]: JsonValue;
}
// 获取群相册列表返回
export interface GroupAlbumListRet {
album: GroupAlbumInfo[];
[key: string]: any;
[key: string]: JsonValue | GroupAlbumInfo[];
}
// 相册媒体信息
@@ -24,7 +25,7 @@ export interface AlbumMediaInfo {
photoId: string;
url: string;
uploadTime: number;
[key: string]: any;
[key: string]: JsonValue;
}
/**
@@ -150,7 +151,7 @@ async function uploadAlbumSlice(
session: string,
filePath: string,
// 移除 offset 和 chunkSize 参数,改为在内部控制
imgMd5: string,
_imgMd5: string,
skey: string,
pskey: string,
uin: string
+4 -3
View File
@@ -1,5 +1,6 @@
import { RequestUtil, cookieToString, getBknFromCookie } from './request-util';
import { createLogger } from '@snowluma/common/logger';
import type { JsonObject } from '@snowluma/common/json';
const log = createLogger('Bridge.Web');
@@ -8,10 +9,10 @@ export interface GroupEssenceMsgRet {
retcode: number;
data: {
is_end: boolean;
msg_list: any[]; // 具体结构视需要补充
[key: string]: any;
msg_list: JsonObject[]; // 具体结构视需要补充
[key: string]: JsonObject[] | boolean | string | number | null;
};
[key: string]: any;
[key: string]: JsonObject | JsonObject[] | boolean | string | number | null;
}
+29 -9
View File
@@ -3,6 +3,26 @@ import { createLogger } from '@snowluma/common/logger';
const log = createLogger('Bridge.Web');
export interface WebHonorItem {
[key: string]: import('@snowluma/common/json').JsonValue;
user_id: number | string | null;
nickname: string;
avatar: string;
description: string;
}
interface RawHonorItem {
uin?: number | string;
name?: string;
avatar?: string;
desc?: string;
}
interface HonorInitialState {
talkativeList?: RawHonorItem[];
actorList?: RawHonorItem[];
}
export enum WebHonorType {
TALKATIVE = 'talkative',
PERFORMER = 'performer',
@@ -12,8 +32,8 @@ export enum WebHonorType {
}
async function fetchHonorData(cookieObject: Record<string, string>, groupCode: string, type: number) {
let resJson;
async function fetchHonorData(cookieObject: Record<string, string>, groupCode: string, type: number): Promise<RawHonorItem[] | undefined> {
let resJson: HonorInitialState | undefined;
try {
const res = await RequestUtil.HttpGetText(
`https://qun.qq.com/interactive/honorlist?${new URLSearchParams({
@@ -26,7 +46,7 @@ async function fetchHonorData(cookieObject: Record<string, string>, groupCode: s
);
const match = /window\.__INITIAL_STATE__=(.*?);/.exec(res);
if (match?.[1]) {
resJson = JSON.parse(match[1].trim());
resJson = JSON.parse(match[1].trim()) as HonorInitialState;
}
return type === 1 ? resJson?.talkativeList : resJson?.actorList;
} catch (e) {
@@ -34,16 +54,16 @@ async function fetchHonorData(cookieObject: Record<string, string>, groupCode: s
}
}
export async function getHonorListWebAPI(cookieObject: Record<string, string>, groupCode: string, type: number) {
export async function getHonorListWebAPI(cookieObject: Record<string, string>, groupCode: string, type: number): Promise<WebHonorItem[]> {
try {
const data = await fetchHonorData(cookieObject, groupCode, type);
if (!data) return [];
return data.map((item: any) => ({
user_id: item?.uin,
nickname: item?.name,
avatar: item?.avatar,
description: item?.desc,
return data.map((item) => ({
user_id: item?.uin ?? null,
nickname: item?.name ?? '',
avatar: item?.avatar ?? '',
description: item?.desc ?? '',
}));
} catch (e) {
log.warn('getHonorListWebAPI failed (group=%s type=%d): %s',
+6 -5
View File
@@ -1,19 +1,20 @@
import { RequestUtil, cookieToString } from './request-util';
import https from 'node:https';
import { createLogger } from '@snowluma/common/logger';
import type { JsonValue } from '@snowluma/common/json';
const log = createLogger('Bridge.Web');
export interface SetNoticeRetSuccess {
ec?: number;
em?: string;
[key: string]: any;
[key: string]: JsonValue | undefined;
}
export interface UploadImageRetSuccess {
ec?: number;
id?: string;
[key: string]: any;
[key: string]: JsonValue | undefined;
}
export interface WebApiGroupNoticeFeed {
@@ -24,16 +25,16 @@ export interface WebApiGroupNoticeFeed {
text: string;
pics?: Array<{ id: string; w: number; h: number }>;
};
settings: any;
settings: JsonValue;
read_num: number;
[key: string]: any;
[key: string]: JsonValue;
}
export interface WebApiGroupNoticeRet {
ec: number;
em?: string;
feeds?: Record<string, WebApiGroupNoticeFeed>;
[key: string]: any;
[key: string]: JsonValue | Record<string, WebApiGroupNoticeFeed> | undefined;
}
export function calculateBkn(key: string): string {
+6 -4
View File
@@ -1,6 +1,8 @@
import https from 'node:https';
import http from 'node:http';
type RequestBody = string | Buffer | Uint8Array | Record<string, unknown> | undefined;
export class RequestUtil {
static async HttpsGetCookies(url: string): Promise<{ [key: string]: string; }> {
const client = url.startsWith('https') ? https : http;
@@ -50,7 +52,7 @@ export class RequestUtil {
});
}
static async HttpGetJson<T>(url: string, method: string = 'GET', data?: any, headers: {
static async HttpGetJson<T>(url: string, method: string = 'GET', data?: RequestBody, headers: {
[key: string]: string;
} = {}, isJsonRet: boolean = true, isArgJson: boolean = true, maxRedirects: number = 5): Promise<T> {
const option = new URL(url);
@@ -91,8 +93,8 @@ export class RequestUtil {
} else {
reject(new Error(`Unexpected status code: ${res.statusCode}`));
}
} catch (parseError: any) {
reject(new Error(parseError.message));
} catch (parseError: unknown) {
reject(new Error(parseError instanceof Error ? parseError.message : String(parseError)));
}
});
});
@@ -105,7 +107,7 @@ export class RequestUtil {
});
}
static async HttpGetText(url: string, method: string = 'GET', data?: any, headers: { [key: string]: string; } = {}) {
static async HttpGetText(url: string, method: string = 'GET', data?: RequestBody, headers: { [key: string]: string; } = {}) {
return this.HttpGetJson<string>(url, method, data, headers, false, false);
}
}
+13 -19
View File
@@ -1,11 +1,5 @@
/**
* Runtime fallback functions.
* If the Vite plugin is active, calls to protobuf_encode/decode are replaced
* at compile-time with generated type-specific functions.
* If NOT transformed, these fallbacks throw to alert the developer.
*/
import type { MessageRegistry } from './ast/types.js';
import { buildDependencyRegistry } from './ast/dependency-graph.js';
import type { MessageRegistry } from './ast/types.js';
import { generateCode } from './codegen/generator.js';
import {
normalizeRuntimeMapPath,
@@ -19,14 +13,14 @@ type DynamicEncode = (params: unknown) => Uint8Array;
type DynamicDecode = (data: Uint8Array) => unknown;
interface DynamicTypeFns {
encode?: DynamicEncode;
decode?: DynamicDecode;
encode?: DynamicEncode;
decode?: DynamicDecode;
}
interface StackFrame {
file: string;
line: number;
column: number;
file: string;
line: number;
column: number;
}
const DYNAMIC_MISS = Symbol('snowluma-proton-dynamic-miss');
@@ -64,7 +58,7 @@ export function protobuf_encode<T>(_params: T): Uint8Array {
throw new Error(
'protobuf_encode<T>() was not transformed by the @snowluma/proton Vite plugin. ' +
'Make sure protobufVitePlugin() is added to your vite.config.ts plugins array.',
'Make sure protobufVitePlugin() is added to your vite.config.ts plugins array.',
);
}
@@ -74,7 +68,7 @@ export function protobuf_decode<T>(_data: Uint8Array): T {
throw new Error(
'protobuf_decode<T>() was not transformed by the @snowluma/proton Vite plugin. ' +
'Make sure protobufVitePlugin() is added to your vite.config.ts plugins array.',
'Make sure protobufVitePlugin() is added to your vite.config.ts plugins array.',
);
}
@@ -111,9 +105,9 @@ function ensureTypeFunctions(typeName: string): DynamicTypeFns | null {
const factory = new Function(
`${generated}\nreturn {\n` +
` encode: typeof protobuf_encode_${typeName} === 'function' ? protobuf_encode_${typeName} : undefined,\n` +
` decode: typeof protobuf_decode_${typeName} === 'function' ? protobuf_decode_${typeName} : undefined,\n` +
`};`,
` encode: typeof protobuf_encode_${typeName} === 'function' ? protobuf_encode_${typeName} : undefined,\n` +
` decode: typeof protobuf_decode_${typeName} === 'function' ? protobuf_decode_${typeName} : undefined,\n` +
`};`,
) as () => DynamicTypeFns;
const compiled = factory();
@@ -126,8 +120,8 @@ function resolveCallSite(fnName: RuntimeMapFnName, caller: StackFrame): RuntimeM
const candidates = runtimeMapCallSites.filter(cs =>
cs.fnName === fnName &&
cs.line === caller.line &&
pathsLikelySame(callerFile, cs.file),
cs.line === caller.line &&
pathsLikelySame(callerFile, cs.file),
);
if (candidates.length === 0) return null;
+1 -1
View File
@@ -24,7 +24,7 @@ export interface MessageChainSender {
const SINGLE_USE_TYPES = new Set(['reply']);
export class MessageChain<THasReply extends boolean = false> implements MessageChainLike {
private readonly replyStateBrand?: THasReply;
declare private readonly replyStateBrand?: THasReply;
private constructor(
private readonly items: AnyMessageSegment[] = [],
+3 -7
View File
@@ -73,16 +73,12 @@ function loadAddon(): unknown {
type BetterSqlite3Type = typeof BetterSqlite3;
type DatabaseInstance = InstanceType<BetterSqlite3Type>;
type DatabaseOptions = ConstructorParameters<BetterSqlite3Type>[1];
type BetterSqlite3Constructor = new (filename?: string | Buffer, options?: DatabaseOptions) => DatabaseInstance;
const Database = function Database(filename?: string | Buffer, options?: DatabaseOptions): DatabaseInstance {
const opts = { ...(options ?? {}), nativeBinding: loadAddon() } as DatabaseOptions;
// The `as any` shrug here is unavoidable — better-sqlite3's
// overload set declares `new Database(...)` returns the class type
// but its default export is a callable that *also* returns it via
// an internal `new.target == null` branch. TS can't reconcile the
// two without `as any`.
return new (BetterSqlite3 as any)(filename, opts);
// better-sqlite3 的默认导出同时支持函数调用和 new 调用;这里只需要构造器形态。
return new (BetterSqlite3 as unknown as BetterSqlite3Constructor)(filename, opts);
} as unknown as BetterSqlite3Type;
export default Database;
+6
View File
@@ -7,6 +7,12 @@
"ES2022"
],
"strict": true,
"noImplicitAny": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,