SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
# Media transfer settings
## Video upload connections
Video uploads use one connection by default. In **Settings → Global configuration →
Video upload connections**, choose 1, 2, 3 or 4 connections. The equivalent setting
in `config/snowluma.json` is:
```json
{
"videoUploadConcurrency": 1
}
```
The setting applies to all accounts. Saving through the WebUI affects the next
video upload; uploads already preparing or sending retain their original value.
Restart SnowLuma after editing the file manually. Missing values in older
configurations mean one connection. Invalid values are reported, not clamped.
Partial configuration saves and configuration backups preserve this setting.
Parallelism applies only to video bodies. Covers, images, voice messages and file
uploads retain their existing behavior. Videos no larger than 2 MiB still use one
connection. The final video chunk waits until all preceding chunks are confirmed.
The setting does not change OneBot actions, message segments or response shapes.
An interrupted connection retries only its unconfirmed chunk, within a bounded
retry budget. Known server backpressure reduces that upload to one active request
and retries after a delay. A server rejection, invalid acknowledgement or exhausted
retry budget fails the upload. A failed upload is never silently reported as sent.
Debug logs separate video preparation from transfer duration and report requested
and effective concurrency, connections and retries. Trace logs include per-chunk
attempts, acknowledgement progress and failure context. Higher concurrency does
not guarantee a particular speed; compare the same file and network conditions,
excluding cached uploads, before drawing performance conclusions.
Regression tests use isolated transports and cover concurrency bounds,
out-of-order acknowledgements, a stalled early chunk, retry exhaustion, backpressure
and cleanup. They do not establish real QQ delivery or upload throughput.
## Upload timeouts and node selection
All Highway HTTP uploads (including images, voice, video and files) snapshot
these deployment-wide environment variables when each file starts transferring:
| Variable | Default | Meaning |
| --- | --- | --- |
| `SNOWLUMA_HIGHWAY_UPLOAD_IDLE_TIMEOUT_MS` | `30000` | Maximum wait without response bytes during one request, in milliseconds. Resets when response bytes arrive. |
| `SNOWLUMA_HIGHWAY_UPLOAD_TIMEOUT_MS` | `1800000` | Total transfer budget for one file, in milliseconds (30 minutes). Shared by all chunks, connections, writes, response waits and retry delays. |
For example, a deployment sending small images can use:
```sh
SNOWLUMA_HIGHWAY_UPLOAD_IDLE_TIMEOUT_MS=5000
SNOWLUMA_HIGHWAY_UPLOAD_TIMEOUT_MS=120000
```
Restart the process/container with these variables. Both values must be integers
from 1 to 2147483647; invalid values fail the upload visibly rather than silently
using a default. The total budget does not reset after a successful chunk, after
incoming bytes, or on a different node. The idle timer starts when the HTTP request
begins, including writes; slow connections may need a larger value. Source downloads,
hashing and QQ upload preparation precede this transfer budget. Video bodies and
their covers are separate files with separate budgets.
Transport failures retry only the unconfirmed chunk. Each chunk allows at least
three attempts, or enough attempts to cover all distinct supplied candidates when
there are more than three, subject to the file's total budget. Each connection has
a three-second handshake limit. An expired total budget aborts all workers and
closes their connections; outstanding filesystem reads are drained before closing
the source, so cleanup can finish after that deadline. Explicit server rejection
and invalid acknowledgements still fail immediately. Backpressure retries remain
bounded separately and share the same file deadline.
Node observations belong to the account session and upload type, keyed by the
complete host and port. A validated successful upload acknowledgement is preferred
for five minutes; a transport failure lowers that endpoint's priority for thirty
seconds. Unknown endpoints remain eligible ahead of recent failures, and failures
never permanently exclude a candidate. Only destinations supplied for the current
upload can be selected. The history holds at most 128 observations per account
session, contains no tickets or payloads, and is released when that account's
identity object is discarded. A process restart clears it.
TRACE logs show the effective timeouts and candidate order. Read timeouts include
the current write/read phase, completed write byte count, received byte count and
whether response headers were seen. Total-budget exhaustion is reported separately
as `upload_timeout`; failed transfers remain failed OneBot actions.
## Media download limits
Two deployment-wide knobs bound HTTP media downloads in `highway/utils.ts`. They are
environment variables rather than per-account OneBot settings because every caller
reaches the same download engine (`loadBinarySource` / `stageSourceToDisk`):
```sh
# One HTTP attempt, in milliseconds. Default 60000.
SNOWLUMA_HTTP_DOWNLOAD_TIMEOUT_MS=180000
# Cap on one binary source buffered in memory, in bytes. Default 1073741824 (1 GiB).
SNOWLUMA_MAX_BINARY_DOWNLOAD_BYTES=2147483648
```
Both default to their previous hardcoded values, so an unconfigured deployment
behaves exactly as before. Invalid values are logged and ignored, falling back to the
default. Callers that already pass an explicit ceiling keep it: group/private files
and flash transfer still use 4 GiB, and inline `base64://` / `data:` flash sources
keep the 1 GiB buffered-load ceiling.
`SNOWLUMA_HTTP_DOWNLOAD_TIMEOUT_MS` is not related to
`SNOWLUMA_DOWNLOAD_FILE_TIMEOUT_MS` (used by the `download_file` OneBot action): the
former bounds highway media downloads, the latter the action's own file staging.
+199
View File
@@ -0,0 +1,199 @@
// Receive-chain micro-benchmark (RoadMap #5).
//
// Measured paths:
// 1. `parseMsgPush` — pure decode + decoder dispatch (no SQLite, no
// event emit). The protobuf workload only.
// 2. `IncomingPacketPipeline.process` — full receive pipeline:
// decode + decoder + handleSideEffects (identity-service SQLite
// writes) + event emit through BridgeEventBus.
//
// Run via: pnpm -F @snowluma/protocol bench
//
// Output is `tinybench` ops/sec — vitest's built-in `bench()` is just a
// thin wrapper. Run before AND after a perf commit; the delta is what
// matters (RoadMap #5 commit messages quote the % shift).
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { bench, describe } from 'vitest';
import { protobuf_encode } from '@snowluma/proton';
import type { PacketInfo } from '@snowluma/common/protocol-types';
import type { PushMsg } from '@snowluma/proto-defs/message';
import type { GroupChange } from '@snowluma/proto-defs/notify';
import { parseMsgPush, MSG_PUSH_CMD } from '../src/msg-push/index';
import { IdentityService } from '../src/identity-service';
import { BridgeEventBus } from '../src/event-bus';
import { GroupInviteTracker } from '../src/group-invite-tracker';
import { IncomingPacketPipeline } from '../src/packet-pipeline';
const SELF_UIN = '10001';
const GROUP_ID = 123456789;
const SENDER_UIN = 22222;
const SENDER_UID = 'u_sender';
// ─── Synthetic packet builders ───
function makeGroupTextPacket(): PacketInfo {
const body = protobuf_encode<PushMsg>({
message: {
responseHead: { fromUin: SENDER_UIN, fromUid: SENDER_UID },
contentHead: {
msgType: 82,
subType: 1,
sequence: 1234,
timestamp: 1710000000,
msgId: 999999,
},
body: {
richText: {
elems: [{ text: { str: 'hello world from bench' } }],
},
},
},
status: 0,
grpInfo: { groupUin: GROUP_ID },
} as any);
return {
pid: 1,
uin: SELF_UIN,
serviceCmd: MSG_PUSH_CMD,
seqId: 1,
retCode: 0,
fromClient: false,
body,
};
}
function makeGroupMixedPacket(): PacketInfo {
const body = protobuf_encode<PushMsg>({
message: {
responseHead: { fromUin: SENDER_UIN, fromUid: SENDER_UID },
contentHead: {
msgType: 82,
subType: 1,
sequence: 1235,
timestamp: 1710000000,
msgId: 999998,
},
body: {
richText: {
elems: [
{ text: { str: '@some_user ' } },
{ face: { index: 76 } },
{ text: { str: 'hello ' } },
{ srcMsg: { origSeqs: [4242] } },
{ text: { str: ' world' } },
],
},
},
},
status: 0,
grpInfo: { groupUin: GROUP_ID },
} as any);
return {
pid: 1,
uin: SELF_UIN,
serviceCmd: MSG_PUSH_CMD,
seqId: 2,
retCode: 0,
fromClient: false,
body,
};
}
function makeGroupJoinPacket(): PacketInfo {
const content = protobuf_encode<GroupChange>({
groupUin: GROUP_ID,
memberUid: 'u_newcomer',
});
const body = protobuf_encode<PushMsg>({
message: {
responseHead: { fromUin: GROUP_ID },
contentHead: { msgType: 33, timestamp: 1710000000 },
body: { msgContent: content },
},
status: 0,
} as any);
return {
pid: 1,
uin: SELF_UIN,
serviceCmd: MSG_PUSH_CMD,
seqId: 3,
retCode: 0,
fromClient: false,
body,
};
}
// ─── Setup helpers ───
// In-memory identity (no SQLite writes) — used for the "pure decode"
// bench to isolate proton + decoder cost from DB cost.
function makeIdentity(): IdentityService {
return IdentityService.memory(SELF_UIN);
}
// SQLite-backed identity in a temp file — used for the full-pipeline
// bench so we capture per-event identity writes. Temp dir is cleaned
// implicitly on process exit (OS tmp policy); we don't need to unlink
// because each `bench()` recreates it.
function makeIdentityWithDb(): IdentityService {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-bench-recv-'));
return new IdentityService(SELF_UIN, path.join(dir, 'identity.db'));
}
function makePipeline(identity: IdentityService): IncomingPacketPipeline {
const events = new BridgeEventBus();
events.on('group_message', () => {
/* drain */
});
events.on('group_member_join', () => {
/* drain */
});
const pipeline = new IncomingPacketPipeline({
identity,
events,
fetchGroupList: async () => {},
fetchGroupMemberList: async () => {},
resolveGroupJoinRequest: async () => null,
groupInvites: new GroupInviteTracker(),
});
pipeline.registerCmd(MSG_PUSH_CMD, parseMsgPush);
return pipeline;
}
// ─── Benches ───
const groupText = makeGroupTextPacket();
const groupMixed = makeGroupMixedPacket();
const groupJoin = makeGroupJoinPacket();
describe('parseMsgPush — pure decode + dispatch (no SQLite, no emit)', () => {
const identityPure = makeIdentity();
bench('group_message (text only)', () => {
parseMsgPush(groupText, identityPure);
});
bench('group_message (5 mixed elements)', () => {
parseMsgPush(groupMixed, identityPure);
});
bench('group_member_join', () => {
parseMsgPush(groupJoin, identityPure);
});
});
describe('IncomingPacketPipeline.process — full pipeline (with SQLite identity writes)', () => {
const pipelineText = makePipeline(makeIdentityWithDb());
const pipelineMixed = makePipeline(makeIdentityWithDb());
const pipelineJoin = makePipeline(makeIdentityWithDb());
bench('group_message (text only)', () => {
pipelineText.process(groupText);
});
bench('group_message (5 mixed elements)', () => {
pipelineMixed.process(groupMixed);
});
bench('group_member_join', () => {
pipelineJoin.process(groupJoin);
});
});
+134
View File
@@ -0,0 +1,134 @@
{
"license": "SEE LICENSE IN LICENSE",
"name": "@snowluma/protocol",
"version": "2.4.2",
"private": true,
"type": "module",
"description": "SnowLuma protocol — the protocol-layer between QQ wire bytes and typed events. Owns: identity service (uid↔uin cache with SQLite persistence), event bus, packet pipeline (incoming SSO packet → QQEventVariant), msg-push decoders, element-builder (MessageElement → wire elems[]), highway upload protocol (file/image/video/ptt), OIDB envelope helper, and the event/notice/request type vocabulary. Consumers (@snowluma/core, @snowluma/onebot) talk to this package through `BridgeContext` for runtime calls and the standalone classes (IdentityService, BridgeEventBus, IncomingPacketPipeline) for composition. Per-file subpath exports mirror the legacy `core/src/bridge/*` layout 1:1 so the previous `import { …} from '../bridge/<x>'` collapses to `import { … } from '@snowluma/protocol/<x>'`.",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./events": {
"types": "./src/events.ts",
"default": "./src/events.ts"
},
"./event-bus": {
"types": "./src/event-bus.ts",
"default": "./src/event-bus.ts"
},
"./identity-service": {
"types": "./src/identity-service.ts",
"default": "./src/identity-service.ts"
},
"./group-invite-tracker": {
"types": "./src/group-invite-tracker.ts",
"default": "./src/group-invite-tracker.ts"
},
"./packet-pipeline": {
"types": "./src/packet-pipeline.ts",
"default": "./src/packet-pipeline.ts"
},
"./kick-nt": {
"types": "./src/kick-nt.ts",
"default": "./src/kick-nt.ts"
},
"./element-builder": {
"types": "./src/element-builder.ts",
"default": "./src/element-builder.ts"
},
"./element-manifest": {
"types": "./src/element-manifest.ts",
"default": "./src/element-manifest.ts"
},
"./self-uid": {
"types": "./src/self-uid.ts",
"default": "./src/self-uid.ts"
},
"./sys-face-store": {
"types": "./src/sys-face-store.ts",
"default": "./src/sys-face-store.ts"
},
"./sys-face-storage": {
"types": "./src/sys-face-storage.ts",
"default": "./src/sys-face-storage.ts"
},
"./bridge-oidb": {
"types": "./src/bridge-oidb.ts",
"default": "./src/bridge-oidb.ts"
},
"./oidb-service": {
"types": "./src/oidb-service.ts",
"default": "./src/oidb-service.ts"
},
"./oidb-services/*": {
"types": "./src/oidb-services/*.ts",
"default": "./src/oidb-services/*.ts"
},
"./bridge-context": {
"types": "./src/bridge-context.ts",
"default": "./src/bridge-context.ts"
},
"./qq-info": {
"types": "./src/qq-info.ts",
"default": "./src/qq-info.ts"
},
"./format": {
"types": "./src/format.ts",
"default": "./src/format.ts"
},
"./msg-push": {
"types": "./src/msg-push/index.ts",
"default": "./src/msg-push/index.ts"
},
"./msg-push/*": {
"types": "./src/msg-push/*.ts",
"default": "./src/msg-push/*.ts"
},
"./highway": {
"types": "./src/highway/highway-client.ts",
"default": "./src/highway/highway-client.ts"
},
"./highway/*": {
"types": "./src/highway/*.ts",
"default": "./src/highway/*.ts"
},
"./services/*": {
"types": "./src/services/*.ts",
"default": "./src/services/*.ts"
},
"./web/*": {
"types": "./src/web/*.ts",
"default": "./src/web/*.ts"
},
"./dataline/*": {
"types": "./src/dataline/*.ts",
"default": "./src/dataline/*.ts"
}
},
"scripts": {
"test": "vitest run --exclude=\"tests/native/**\"",
"test:native": "vitest run tests/native",
"bench": "vitest bench --run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@snowluma/common": "workspace:*",
"@snowluma/proto-defs": "workspace:*",
"@snowluma/proton": "workspace:*",
"@snowluma/sqlite": "workspace:*",
"htmlparser2": "10.0.0"
},
"devDependencies": {
"@types/node": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "4.1.12"
},
"engines": {
"node": "^24.22.1 || >=26.12.1"
}
}
+87
View File
@@ -0,0 +1,87 @@
// BridgeContext — the protocol-layer surface that anything inside
// @snowluma/protocol (highway uploaders, OIDB envelope helper, element
// builder, msg-push parsers) accepts when it needs `bridge.identity`
// or `bridge.sendRawPacket` plus the upload-metadata cache.
//
// Deliberately apis-less: @snowluma/protocol knows nothing about the
// `apis.<area>.method()` hub that lives in @snowluma/core, so the
// type that bridge code accepts must NOT mention `ApiHub`. The api-
// layer's own extended context (with `apis: ApiHub`) lives at
// @snowluma/core's same-named file and `extends` this one — Api
// classes import from THERE, bridge helpers import from HERE.
//
// The concrete `Bridge` class (in @snowluma/core) implements the
// extended interface, which is a superset of this one. Passing a
// `Bridge` where a slim `BridgeContext` is expected is a free upcast.
import type { IdentityService } from './identity-service';
import type { BridgeEventBus } from './event-bus';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
/**
* Metadata remembered after `upload_group_file` / `upload_private_file`
* succeeds. Lets the OneBot send-message path reconstruct the full
* payload when the caller only echoes the `file_id` back later. Lives
* here (not on @snowluma/core's `bridge.ts`) so element-builder + the
* forward-builder — both inside @snowluma/protocol — can read the cache
* without circular-importing @snowluma/core.
*/
export interface UploadedFileMeta {
fileId: string;
scope: 'group' | 'private';
/** Group id if scope='group', else `undefined`. */
groupId?: number;
/** Server-selected group storage type: 102 permanent, 104 temporary. */
busId?: number;
/** Friend uin if scope='private', else `undefined`. */
userId?: number;
fileName: string;
fileSize: number;
fileMd5: Uint8Array;
fileSha1: Uint8Array;
/** Server-issued hash returned alongside the upload (private only). */
fileHash?: string;
/** Insert time — used to evict the oldest entry when the cache fills. */
rememberedAt: number;
}
export interface BridgeContext {
// ── Per-instance state (one set per QQ account in a multi-account
// runtime; `BridgeManager` allocates one `Bridge` per uin and
// each Bridge owns its own identity/events/pipeline/apis). ──
readonly identity: IdentityService;
readonly events: BridgeEventBus;
// ── Raw I/O ──
/** Escape hatch for arbitrary protocol calls (used by Api classes
* and by the `send_packet` OneBot action). Every typed wrapper on an
* Api class eventually routes through here. */
sendRawPacket(
serviceCmd: string,
body: Uint8Array,
timeoutMs?: number,
): Promise<SendPacketResult>;
/** uin → uid lookup (cached in IdentityService). `groupId` is an
* optional hint that lets the resolver fall back to a group-member
* roster when the friend list doesn't have the uin. */
resolveUserUid(uin: number, groupId?: number): Promise<string>;
// ── Send-side protocol helpers (Api classes that build a
// `SendMessageRequest` need these monotonic generators to fill in
// `clientSequence` and `random`). They were previously `private`
// on Bridge — promoted to public so the new Api classes can call
// them without `(bridge as any).nextXxx()`. ──
nextMessageRandom(): number;
nextClientSequence(): number;
// ── Uploaded-file metadata cache ──
//
// Bookkeeping shared between `GroupFileApi.upload*File` (writes the
// tuple at upload time) and `MessageApi.sendPrivateMessage` (reads
// it on later `send_msg` calls that carry only a file_id). Lives at
// this layer rather than inside `GroupFileApi` because the message
// send path is what queries the cache, and it shouldn't have to
// reach into a different Api class for an infrastructure lookup.
rememberUploadedFile(meta: UploadedFileMeta): void;
recallUploadedFile(fileId: string): UploadedFileMeta | undefined;
}
+27
View File
@@ -0,0 +1,27 @@
import type { OidbBase } from '@snowluma/proto-defs/oidb';
/**
* Build the OidbBase<T>-shaped TS value. Pure helper, no protobuf
* encoding happens here — pair with `encodeOidbEnv<T>` to produce the
* wire bytes.
*
* The `isUid` flag sets the envelope `reserved` field to 1; despite the
* name (kept for back-compat with the legacy API), `reserved = 1`
* empirically signals the UIN-form variant of an OIDB call. Omit
* (default false) for genuinely UID-keyed calls.
*/
export function makeOidbEnvelope<T>(
oidbCmd: number,
subCmd: number,
body: T,
isUid: boolean = false,
): OidbBase<T> {
return {
command: oidbCmd,
subCommand: subCmd,
errorCode: 0,
body,
errorMsg: '',
reserved: isUid ? 1 : 0,
};
}
+136
View File
@@ -0,0 +1,136 @@
import { toHex } from '@snowluma/common/hex';
import type { DatalineMsgBody, DatalineTextMsg } from '@snowluma/proto-defs/dataline';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { MessageElement } from '../events';
import { appIdForTerType, DATALINE_SELF_TER_TYPE } from './device-contacts';
const DATALINE_SUBCMD_FTN = 1;
const DATALINE_SUBCMD_GENERIC = 4;
const DATALINE_GENERIC_TEXT = 1;
const DATALINE_TEXT_ITEM = 1;
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
export function encodeDatalineText(params: {
selfUin: number;
srcTerType?: number;
dstTerType: number;
text: string;
}): Uint8Array {
const srcTerType = params.srcTerType ?? DATALINE_SELF_TER_TYPE;
const textBytes = textEncoder.encode(params.text);
const buf = protobuf_encode<DatalineTextMsg>({
items: [{ type: DATALINE_TEXT_ITEM, text: textBytes }],
});
const selfUin = BigInt(params.selfUin);
return protobuf_encode<DatalineMsgBody>({
subCmd: DATALINE_SUBCMD_GENERIC,
header: {
srcAppId: appIdForTerType(srcTerType),
srcInstId: 1,
dstAppId: appIdForTerType(params.dstTerType),
dstInstId: 1,
dstUin: selfUin,
srcUin: selfUin,
srcTerType,
dstTerType: params.dstTerType,
},
generic: {
sessionId: BigInt(Date.now()),
size: 1,
index: 0,
type: DATALINE_GENERIC_TEXT,
buf,
},
});
}
export function decodeDatalineMsgBody(bytes: Uint8Array): DatalineMsgBody | undefined {
return protobuf_decode<DatalineMsgBody>(bytes);
}
export function elementsFromDatalineBody(body: DatalineMsgBody): MessageElement[] {
const elements: MessageElement[] = [];
if (body.subCmd === DATALINE_SUBCMD_GENERIC || body.generic) {
const genericType = body.generic?.type ?? 0;
if (genericType === 0 || genericType === DATALINE_GENERIC_TEXT) {
const text = decodeGenericText(body.generic?.buf);
if (text.length > 0) {
elements.push({ type: 'text', text });
}
}
}
if (body.subCmd === DATALINE_SUBCMD_FTN || (body.ftn && body.ftn.length > 0)) {
for (const notify of body.ftn ?? []) {
const fileName = notify.fileName?.trim();
if (!fileName && notify.fileIndex == null && notify.sessionId == null) {
continue;
}
const fileSize = notify.fileLen != null ? Number(notify.fileLen) : undefined;
elements.push({
type: 'file',
fileId:
notify.fileIndex ||
(notify.sessionId != null ? String(notify.sessionId) : undefined),
fileName: fileName || undefined,
fileSize: fileSize != null && Number.isFinite(fileSize) ? fileSize : undefined,
md5Hex:
notify.fileMd5 && notify.fileMd5.length > 0 ? toHex(notify.fileMd5) : undefined,
});
}
}
return elements;
}
function decodeGenericText(buf: Uint8Array | undefined): string {
if (!buf || buf.length === 0) {
return '';
}
const parsed = protobuf_decode<DatalineTextMsg>(buf);
const parts: string[] = [];
for (const item of parsed?.items ?? []) {
if (!item.text || item.text.length === 0) {
continue;
}
parts.push(textDecoder.decode(item.text));
}
return parts.join('');
}
export function datalineTextFromElements(elements: readonly MessageElement[]): string {
if (elements.length === 0) {
throw new Error('message is empty');
}
const unsupported = elements.find((element) => element.type !== 'text');
if (unsupported) {
throw new Error(`this contact only accepts text messages, got ${unsupported.type}`);
}
const text = elements
.filter(
(element): element is Extract<MessageElement, { type: 'text' }> =>
element.type === 'text',
)
.map((element) => element.text)
.join('');
if (text.length === 0) {
throw new Error('message is empty');
}
return text;
}
@@ -0,0 +1,72 @@
import type { FriendMessage } from '../events';
import type { MsgPushContext } from '../msg-push/context';
import { decodeDatalineMsgBody, elementsFromDatalineBody } from './codec';
import { DATALINE_SELF_TER_TYPE, findDatalineDeviceByTerType } from './device-contacts';
/**
* Incoming my-device chats reuse the private-file push type but carry a
* different body. Rewrite the conversation peer onto the matching device
* contact — both sides of the wire use the logged-in account number.
*/
export function tryDecodeDatalineFriendMessage(ctx: MsgPushContext): FriendMessage | null {
if (ctx.head.msgType !== 529 || ctx.head.subType !== 7) {
return null;
}
const raw = ctx.body?.msgContent;
if (!raw || raw.length === 0) {
return null;
}
const body = decodeDatalineMsgBody(raw);
if (!body) {
return null;
}
if (
body.subCmd == null &&
!body.header &&
!body.generic &&
!(body.ftn && body.ftn.length > 0)
) {
return null;
}
const elements = elementsFromDatalineBody(body);
const srcTer = body.header?.srcTerType ?? 0;
const dstTer = body.header?.dstTerType ?? 0;
const sentBySelf = srcTer === DATALINE_SELF_TER_TYPE;
const peerTer = peerTerType(srcTer, dstTer);
const peer = findDatalineDeviceByTerType(peerTer);
const sequenceAuthoritative = ctx.head.ntMsgSeq > 0;
return {
kind: 'friend_message',
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
senderUin: sentBySelf ? ctx.selfUin : (peer?.uin ?? ctx.fromUin),
senderUid: sentBySelf ? ctx.fromUid : (peer?.uid ?? ctx.fromUid),
...(peer ? { peerUin: peer.uin } : {}),
msgSeq: ctx.head.sequence,
ntMsgSeq: ctx.head.ntMsgSeq,
clientSeq: ctx.head.sequence,
sequenceAuthoritative,
msgId: ctx.head.msgId & 0x7fffffff,
elements,
senderNick: sentBySelf ? '' : (peer?.name ?? ''),
};
}
function peerTerType(srcTer: number, dstTer: number): number {
if (srcTer !== DATALINE_SELF_TER_TYPE && srcTer > 0) {
return srcTer;
}
if (dstTer !== DATALINE_SELF_TER_TYPE && dstTer > 0) {
return dstTer;
}
return dstTer || srcTer || DATALINE_SELF_TER_TYPE;
}
@@ -0,0 +1,69 @@
/** Official "my computer / my phone / my Pad" device contacts. */
export const DATALINE_SELF_TER_TYPE = 1;
export const DATALINE_TER_PC = 1;
export const DATALINE_TER_PHONE = 2;
export const DATALINE_TER_PAD = 3;
/** Stable OneBot user_ids reserved for the three official device peers. */
export const DATALINE_UIN_PC = 0x7e000001;
export const DATALINE_UIN_PHONE = 0x7e000002;
export const DATALINE_UIN_PAD = 0x7e000003;
export interface DatalineDeviceContact {
readonly terType: number;
readonly uid: string;
readonly name: string;
readonly uin: number;
}
export interface DatalineFriendEntry {
user_id: number;
nickname: string;
remark: string;
}
export const DATALINE_DEVICES: readonly DatalineDeviceContact[] = [
{
terType: DATALINE_TER_PC,
uid: 'u_rK7NMsbv2ZjEGPdCuOiCfw',
name: '我的电脑',
uin: DATALINE_UIN_PC,
},
{
terType: DATALINE_TER_PHONE,
uid: 'u_Wcc5rknRRqRO8y5gxMD6sA',
name: '我的手机',
uin: DATALINE_UIN_PHONE,
},
{
terType: DATALINE_TER_PAD,
uid: 'u_l7jpPIZxQo0mzJwoEt-SKw',
name: '我的Pad',
uin: DATALINE_UIN_PAD,
},
];
const BY_UIN = new Map(DATALINE_DEVICES.map((device) => [device.uin, device]));
const BY_TER = new Map(DATALINE_DEVICES.map((device) => [device.terType, device]));
export function findDatalineDeviceByUin(uin: number): DatalineDeviceContact | undefined {
return BY_UIN.get(uin);
}
export function findDatalineDeviceByTerType(terType: number): DatalineDeviceContact | undefined {
return BY_TER.get(terType);
}
export function datalineFriendListEntries(): DatalineFriendEntry[] {
return DATALINE_DEVICES.map((device) => ({
user_id: device.uin,
nickname: device.name,
remark: device.name,
}));
}
export function appIdForTerType(terType: number): number {
return terType === DATALINE_TER_PC ? 1 : 1001;
}
+891
View File
@@ -0,0 +1,891 @@
import type { MarkdownData, MentionExtraSend, SendRichText } from '@snowluma/proto-defs/action';
import type {
Elem,
GroupFileExtra,
MarketFacePbReserve,
MsgInfo,
PokeExtra,
QFaceExtra,
QSmallFaceExtra,
} from '@snowluma/proto-defs/element';
import { type bytes, type pb, protobuf_decode, protobuf_encode } from '@snowluma/proton';
import { createLogger } from '@snowluma/common/logger';
import { randomUUID } from 'crypto';
import { deflateSync } from 'zlib';
import type { BridgeContext } from './bridge-context';
import { sysFaceStore, type FaceWire } from './sys-face-store';
import type { MessageElement } from './events';
import {
assertVideoSendPolicy,
assertWindowShakeSendPolicy,
assertValidMessageElements,
MessageElementValidationError,
type OutboundMessageScene,
} from './element-manifest';
import { uploadImageMsgInfo } from './highway/image-upload';
import { hexToBytes } from './highway/pipeline';
import { uploadPtt } from './highway/ptt-upload';
import { uploadVideoMsgInfo } from './highway/video-upload';
type ProtoElem = Partial<Elem>;
const log = createLogger('Protocol.Elements');
export interface SendContext {
bridge: BridgeContext;
groupId?: number;
userUid?: string;
/** Source group of an already authorized passive temp-session reply. */
tempGroupId?: number;
/** Explicit transport scene used by scene-limited message elements. */
scene?: OutboundMessageScene;
/**
* Set by the forward-message upload path. When `true`, file segments
* are encoded as receive-side wire shapes (group → `transElem(24)`,
* c2c → caller writes `body.msgContent` separately) instead of being
* dropped. The QQ-NT server rejects outgoing PbSendMsg with
* `transElem(24)` (result=79), but the long-msg upload service does
* NOT — it stores the gzipped protobuf verbatim, so the receiver
* can walk it back through the regular decoder. Mirrors NapCat's
* `PacketMsgFileElement.buildElement` + `buildContent` split (see
* `dev/napcatQQ/.../packet/message/element.ts:530-610`).
*/
forwardFake?: boolean;
}
function makeTextElem(text: string): ProtoElem {
return {
text: { str: text },
};
}
// QQ-NT splits face ids across three wire encodings. The legacy FaceElem only
// renders classic small faces; newer "super" / animated faces sent that way are
// silently remapped by the server (e.g. 424→168, issue #168). The split is
// data-driven off the system-face catalog (0x9154_1, see sys-face-store):
// animated (unless large=false) → CommonElem 37 + QFaceExtra
// other id ≥ 260 → CommonElem 33 + QSmallFaceExtra
// classic id < 260 → legacy FaceElem
async function makeFaceElem(
faceId: number,
large: boolean,
resultId: string | undefined,
ctx?: SendContext,
): Promise<ProtoElem> {
// With a live bridge, wait for the authoritative catalog. Login normally
// preloads it, while this await closes the reconnect / first-send race.
const resolved = ctx
? await sysFaceStore.resolveWire(ctx.bridge, faceId)
: sysFaceStore.classify(faceId);
const wire: FaceWire = large ? resolved : { kind: faceId < 260 ? 'classic' : 'small' };
if (wire.kind === 'super') {
return {
commonElem: {
serviceType: 37,
pbElem: protobuf_encode<QFaceExtra>({
packId: wire.packId,
stickerId: wire.stickerId,
qsid: faceId,
sourceType: 1,
stickerType: wire.stickerType,
resultId,
randomType: 1,
}),
businessType: wire.stickerType < 4 ? wire.stickerType : 1,
},
};
}
if (resultId !== undefined) {
throw new MessageElementValidationError(
'INVALID_FIELD',
`face.resultId requires animation metadata for face ${faceId}`,
'face',
'resultId',
);
}
if (wire.kind === 'small') {
return {
commonElem: {
serviceType: 33,
pbElem: protobuf_encode<QSmallFaceExtra>({ faceId, preview: '', preview2: '' }),
businessType: 1,
},
};
}
return { face: { index: faceId } };
}
function resolveMentionDisplay(ctx: SendContext | undefined, targetUin: number): string {
if (!ctx || !targetUin) {
return '';
}
if (ctx.groupId !== undefined) {
const member = ctx.bridge.identity.findGroupMember(ctx.groupId, targetUin);
return member?.card?.trim() || member?.nickname?.trim() || '';
}
const friend = ctx.bridge.identity.findFriend(targetUin);
return friend?.remark?.trim() || friend?.nickname?.trim() || '';
}
function makeMentionElem(element: MessageElement, ctx?: SendContext): ProtoElem {
const mentionAll = element.uid === 'all' || element.targetUin === 0;
const targetUin = element.targetUin ?? 0;
const extra = protobuf_encode<MentionExtraSend>({
type: mentionAll ? 1 : 2,
uin: mentionAll ? 0 : targetUin,
field5: 0,
uid: mentionAll ? 'all' : (element.uid ?? ''),
});
// Prefer an explicit display string from the caller; otherwise look the
// target up in the roster so QQ renders `@昵称` instead of `@QQ号`.
// Falls back to the bare uin when the roster doesn't know them yet.
let str = element.text;
if (!str) {
if (mentionAll) {
str = '@全体成员 ';
} else {
const name = resolveMentionDisplay(ctx, targetUin);
str = name ? `@${name} ` : `@${targetUin} `;
}
}
return {
text: {
str,
pbReserve: extra,
},
};
}
function makeReplyElem(element: MessageElement): ProtoElem {
const seq = element.replySeq! & 0xffffffff;
const srcMsg: NonNullable<Elem['srcMsg']> = {
origSeqs: [seq],
};
// Add additional fields if available for better reply display
if (element.replySenderUin) {
srcMsg.senderUin = BigInt(element.replySenderUin);
}
if (element.replyTime) {
srcMsg.time = element.replyTime;
}
const previews = (element.replyElements ?? []).filter((e) => e.type === 'text' && e.text);
if (previews.length) {
srcMsg.elemsRaw = previews.map((e) => protobuf_encode<Elem>({ text: { str: e.text! } }));
}
return { srcMsg };
}
// The replied sender, encoded as a mention element placed right after srcMsg.
// QQ NT's group reply wire shape expects this; without it a following
// user-supplied @ is silently not honored by the server (issue #129).
async function makeReplyMentionElem(ctx: SendContext, uin: number): Promise<ProtoElem | null> {
if (ctx.groupId === undefined || uin <= 0) {
return null;
}
const member = ctx.bridge.identity.findGroupMember(ctx.groupId, uin);
let uid = member?.uid ?? '';
if (!uid) {
uid = await ctx.bridge.identity.resolveUid(uin, ctx.groupId).catch(() => '');
}
if (!uid) {
return null;
}
const name = member?.card?.trim() || member?.nickname?.trim() || String(uin);
// uin=0 matches QQ NT's native reply auto-mention (the real uid lives in
// `uid`). It does not itself notify — the user's own @ does — and the receive
// side strips a type=2/uin=0 mention as structural (#127), so the bot's own
// replies don't surface a spurious @ either.
const extra = protobuf_encode<MentionExtraSend>({ type: 2, uin: 0, field5: 0, uid });
return { text: { str: `@${name} `, pbReserve: extra } };
}
function makeDeflatedPayload(content: string): Uint8Array {
const deflated = deflateSync(Buffer.from(content, 'utf8'));
const payload = new Uint8Array(deflated.length + 1);
payload[0] = 0x01;
payload.set(deflated, 1);
return payload;
}
function makeJsonElem(element: MessageElement): ProtoElem {
const content = element.text ?? '';
return {
lightApp: {
data: makeDeflatedPayload(content),
},
};
}
function makeXmlElem(element: MessageElement): ProtoElem {
const content = element.text ?? '';
return {
richMsg: {
serviceId: element.subType === 0 ? 35 : (element.subType ?? 35),
template1: makeDeflatedPayload(content),
},
};
}
function makeMarkdownElem(element: MessageElement): ProtoElem {
const data = protobuf_encode<MarkdownData>({ content: element.text ?? '' });
return {
commonElem: {
serviceType: 45,
pbElem: data,
businessType: 1,
},
};
}
function makePokeElem(element: MessageElement): ProtoElem {
const pokeType = element.subType;
return {
commonElem: {
serviceType: 2,
pbElem: protobuf_encode<PokeExtra>({ type: pokeType }),
businessType: pokeType,
},
};
}
/**
* Build a market-face (商城表情) wire element from the markers carried on the
* receive-side `image` segment (`emoji_id`/`emoji_package_id`/`key`/summary).
*
* The constants (`itemType=6`, `faceInfo=1`, `subType=3`, 300×300,
* `pbReserve.field8=1`) mirror NapCat's `PacketMsgMarkFaceElement.buildElement`
* (`dev/NapCatQQ/.../packet/message/element.ts:318-335`) — the QQ-NT server
* pairs them with the faceId/tabId/key to relay the sticker. `faceId` is the
* raw GUID bytes, recovered from the hex `emojiId`.
*/
function makeMarketFaceElem(element: MessageElement): ProtoElem {
const emojiId = (element.emojiId ?? '').trim();
return {
marketFace: {
faceName: element.text ?? '',
itemType: 6,
faceInfo: 1,
faceId: hexToBytes(emojiId),
tabId: element.emojiPackageId ?? 0,
subType: 3,
key: element.emojiKey ?? '',
imageWidth: 300,
imageHeight: 300,
pbReserve: protobuf_encode<MarketFacePbReserve>({ field8: 1 }),
},
};
}
function escapeXml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;')
.replaceAll('\t', '&#x9;')
.replaceAll('\n', '&#xA;')
.replaceAll('\r', '&#xD;');
}
function xmlPreviewText(value: string): string {
// Unicode mode preserves supplementary characters and replaces lone surrogates.
return value.replace(
// eslint-disable-next-line no-control-regex
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/gu,
'\uFFFD',
);
}
function escapeForwardPreview(value: string, field: string): string {
const normalized = xmlPreviewText(value);
if (normalized !== value) {
log.debug('forward preview characters normalized: field=%s', field);
}
return escapeXml(normalized);
}
function makeForwardElem(element: MessageElement): ProtoElem {
const resId = (element.resId ?? '').trim();
if (!resId) {
throw new Error('forward resId is required');
}
// Nested forwards must round-trip this value between the LightApp preview
// and the outer upload's piggyback `actionCommand`. Flat forwards have no
// piggyback entry and use the generated value as the RichMsg `m_fileName`.
const uniseq = (element.forwardUuid ?? '').trim() || randomUUID();
const source =
element.forwardSource && element.forwardSource.length > 0
? element.forwardSource
: '聊天记录';
const summary =
element.forwardSummary && element.forwardSummary.length > 0
? element.forwardSummary
: '查看转发消息';
const prompt =
element.forwardPrompt && element.forwardPrompt.length > 0
? element.forwardPrompt
: '[聊天记录]';
const news = Array.isArray(element.forwardNews) ? element.forwardNews : [];
const tSum =
element.forwardTSum && element.forwardTSum > 0
? element.forwardTSum
: Math.max(news.length, 1);
// Flat outer forwards use serviceID=35 RichMsg. Nested previews keep
// LightApp because their forwardUuid must match a piggyback actionCommand
// in the outer long-message body.
if (!element.forwardUuid) {
if (xmlPreviewText(resId) !== resId) {
throw new MessageElementValidationError(
'INVALID_FIELD',
'forward resId contains invalid characters',
'forward',
'resId',
);
}
const escapedSource = escapeForwardPreview(source, 'source');
const titles = news
.map(
(item, index) =>
'<title color="#777777" size="26">' +
escapeForwardPreview(item.text ?? '', `news[${index}]`) +
'</title>',
)
.join('');
const xml =
"<?xml version='1.0' encoding='UTF-8' standalone='yes'?> " +
'<msg serviceID="35" templateID="1" action="viewMultiMsg"' +
' brief="' +
escapeForwardPreview(prompt, 'prompt') +
'"' +
' m_fileName="' +
escapeXml(uniseq) +
'"' +
' m_resid="' +
escapeXml(resId) +
'"' +
' tSum="' +
tSum +
'" flag="3">' +
'<item layout="1"> ' +
'<title color="#000000" size="34">' +
escapedSource +
'</title>' +
titles +
' <hr></hr> <summary color="#808080">' +
escapeForwardPreview(summary, 'summary') +
'</summary>' +
'</item> <source name="' +
escapedSource +
'"></source> </msg>';
return {
richMsg: {
template1: makeDeflatedPayload(xml),
serviceId: 35,
},
};
}
const lightApp = {
app: 'com.tencent.multimsg',
config: {
autosize: 1,
forward: 1,
round: 1,
type: 'normal',
width: 300,
},
desc: prompt,
extra: JSON.stringify({ filename: uniseq, tsum: tSum }),
meta: {
detail: {
news: news.map((n) => ({ text: n.text ?? '' })),
resid: resId,
source,
summary,
uniseq,
},
},
prompt,
ver: '0.0.0.5',
view: 'contact',
};
const json = JSON.stringify(lightApp);
return {
lightApp: {
data: makeDeflatedPayload(json),
},
};
}
function md5BytesFromHex(hex: string | undefined): Uint8Array | null {
if (!hex || !/^[0-9a-fA-F]{32}$/.test(hex)) {
return null;
}
const bytes = hexToBytes(hex);
return bytes.length === 16 ? bytes : null;
}
function fingerprintsFromEncodedMsgInfo(msgInfo: Uint8Array): {
md5Hex: string;
fileName: string;
fileSize: number;
width: number;
height: number;
} | null {
let info: MsgInfo;
try {
info = protobuf_decode<MsgInfo>(msgInfo);
} catch {
return null;
}
const fi = info.msgInfoBody?.[0]?.index?.info;
if (!fi?.fileHash || !/^[0-9a-fA-F]{32}$/.test(fi.fileHash)) {
return null;
}
return {
md5Hex: fi.fileHash,
fileName: fi.fileName || `${fi.fileHash.toLowerCase()}.png`,
fileSize: fi.fileSize ?? 0,
width: fi.width ?? 0,
height: fi.height ?? 0,
};
}
/**
* Long-msg storage often strips NT image download paths. A CustomFace /
* NotOnlineImage sibling with the md5 lets get_forward_msg rebuild a
* fetchable URL the way native forwards do (#441).
*/
function makeLegacyForwardImageElem(
isGroup: boolean,
msgInfo: Uint8Array,
element: MessageElement,
): ProtoElem | null {
const fromInfo = fingerprintsFromEncodedMsgInfo(msgInfo);
const md5 = md5BytesFromHex(element.md5Hex) ?? md5BytesFromHex(fromInfo?.md5Hex);
if (!md5) {
return null;
}
const md5Hex = element.md5Hex || fromInfo!.md5Hex;
const fileName = element.fileName || fromInfo?.fileName || `${md5Hex.toLowerCase()}.png`;
const fileSize = element.fileSize ?? fromInfo?.fileSize ?? 0;
const width = element.width ?? fromInfo?.width ?? 0;
const height = element.height ?? fromInfo?.height ?? 0;
if (isGroup) {
return { customFace: { filePath: fileName, md5, size: fileSize, width, height } };
}
return {
notOnlineImage: {
filePath: fileName,
picMd5: md5,
fileLen: fileSize,
picWidth: width,
picHeight: height,
},
};
}
async function makeImageElem(ctx: SendContext, element: MessageElement): Promise<ProtoElem[]> {
const isGroup = ctx.groupId !== undefined;
const targetIdOrUid = isGroup ? ctx.groupId! : (ctx.userUid ?? '');
if (!isGroup && !targetIdOrUid) {
throw new Error('private image target uid is missing');
}
const tempGroupId = ctx.scene === 'group-temp' ? ctx.tempGroupId : undefined;
if (ctx.scene === 'group-temp' && tempGroupId === undefined) {
throw new Error('temp-session image source group is missing');
}
const msgInfo = await uploadImageMsgInfo(
ctx.bridge,
isGroup,
targetIdOrUid,
element,
tempGroupId,
);
const nt: ProtoElem = {
commonElem: {
serviceType: 48,
pbElem: msgInfo,
businessType: isGroup ? 20 : 10,
},
};
if (!ctx.forwardFake) {
return [nt];
}
const legacy = makeLegacyForwardImageElem(isGroup, msgInfo, element);
return legacy ? [nt, legacy] : [nt];
}
async function makePttElem(ctx: SendContext, element: MessageElement) {
const isGroup = ctx.groupId !== undefined;
const targetIdOrUid = isGroup ? ctx.groupId! : (ctx.userUid ?? '');
if (!isGroup && !targetIdOrUid) {
throw new Error('private record target uid is missing');
}
const { msgInfo, compatQMsg } = await uploadPtt(ctx.bridge, isGroup, targetIdOrUid, element);
// commonElem.businessType is the QQ NT scene tag the receive-side
// decoder pairs with: 12=c2c, 22=group. Sending the group tag on a
// c2c message bounces with PbSendMsg result=79.
return {
elem: { commonElem: { serviceType: 48, pbElem: msgInfo, businessType: isGroup ? 22 : 12 } },
compatQMsg,
};
}
/**
* Receive-side group file element shape — `transElem(elemType=24,
* elemValue: 0x01 | BE16(len) | GroupFileExtra)`.
*
* NOT used for outgoing PbSendMsg (the QQ-NT server rejects that with
* result=79); the group-file send-side flow goes through dedicated
* OIDB 0x6d9_4 via `bridge.apis.groupFile.publish`. This shape IS used
* inside the long-msg upload (forward / multi-msg) — the long-msg
* service stores the gzipped protobuf verbatim, and the receiver
* decodes file entities from `transElem(24)` (see
* `msg-push/rich-body-decoder.ts:169-187`).
*
* `ctx` is consulted to fill in size/name/md5/sha1 from the upload
* metadata cache when the caller only threaded the file_id through.
* Mirrors the c2c forward path in `apis/forward.ts::buildForwardPushBody`
* which already does this lookup — without it the receiver sees a
* "0 B" file with no name (OneBot11 `{type:'file', file_id}` segments
* typically omit the metadata since the upload action returned only
* the id).
*/
function makeGroupFileElem(element: MessageElement, ctx?: SendContext): ProtoElem {
if (!element.fileId) {
throw new Error('file element missing fileId');
}
const cached = ctx?.bridge.recallUploadedFile(element.fileId);
const busId =
cached?.scope === 'group' && cached.groupId === ctx?.groupId ? (cached.busId ?? 102) : 102;
const fileSize = element.fileSize ?? cached?.fileSize ?? 0;
const fileName = element.fileName ?? cached?.fileName ?? '';
const md5 = element.md5Hex
? hexToBytes(element.md5Hex)
: (cached?.fileMd5 ?? new Uint8Array(0));
const sha1 = element.sha1Hex
? hexToBytes(element.sha1Hex)
: (cached?.fileSha1 ?? new Uint8Array(0));
// Outer field1 is hardcoded to 6 in NapCat's encoder
// (`packet/message/element.ts:589`). Inner GroupFileExtraInfo carries
// the storage type + file_id + name + size + sha1/md5 hashes.
const extraBytes = protobuf_encode<GroupFileExtra>({
field1: 6,
fileName,
inner: {
info: {
busId,
fileId: element.fileId,
fileSize: BigInt(fileSize),
fileName,
fileSha: sha1,
extInfoString: '',
fileMd5: md5,
},
},
});
if (extraBytes.length > 0xffff) {
// The 16-bit length prefix caps the payload at 64 KiB; even the
// densest GroupFileExtra (fileId/name/two hashes) is well under.
throw new Error(`group file extra too large (${extraBytes.length} > 65535)`);
}
const elemValue = new Uint8Array(3 + extraBytes.length);
elemValue[0] = 0x01;
elemValue[1] = (extraBytes.length >> 8) & 0xff;
elemValue[2] = extraBytes.length & 0xff;
elemValue.set(extraBytes, 3);
return {
transElem: {
elemType: 24,
elemValue,
},
};
}
async function makeVideoElem(ctx: SendContext, element: MessageElement): Promise<ProtoElem> {
const isGroup = ctx.groupId !== undefined;
const targetIdOrUid = isGroup ? ctx.groupId! : (ctx.userUid ?? '');
if (!isGroup && !targetIdOrUid) {
throw new Error('private video target uid is missing');
}
const msgInfo = await uploadVideoMsgInfo(ctx.bridge, isGroup, targetIdOrUid, element);
// commonElem.businessType is the QQ NT scene tag the receive-side
// decoder pairs with: 11=c2c, 21=group. Sending the group tag on a
// c2c message bounces with PbSendMsg result=79.
//
// Oversize videos throw here; the OneBot layer catches that and falls
// back to file upload (see message-actions.ts video fallback). A file
// element can't be built here — there's no uploaded file_id yet.
return {
commonElem: {
serviceType: 48,
pbElem: msgInfo,
businessType: isGroup ? 21 : 11,
},
};
}
/**
* Build proto Elem objects from an array of MessageElements.
* Supports: text, face, mface, at, reply, json, xml, markdown, image, record, video, forward, poke.
* Image, record and video elements trigger NTV2 highway upload via the SendContext.
*/
async function buildSendContent(
elements: MessageElement[],
ctx?: SendContext,
): Promise<{ elems: ProtoElem[]; voiceCompatibility?: Uint8Array }> {
// Validate the whole message before the first upload or packet build. This
// preserves all-or-nothing semantics: a malformed later segment cannot make
// an earlier media segment perform side effects before the send is rejected.
assertValidMessageElements(elements, 'W');
// Canonical MessageElements cannot contain empty text. OneBot's explicit
// empty-text compatibility placeholders are removed before this boundary,
// so the array length here is the effective on-wire segment count.
assertVideoSendPolicy(
elements.filter((element) => element.type === 'video').length,
elements.length,
);
assertWindowShakeSendPolicy(
elements.filter((element) => element.type === 'poke').length,
elements.length,
ctx?.scene,
);
for (const element of elements) {
if (
(element.type === 'image' || element.type === 'record' || element.type === 'video') &&
!ctx
) {
throw new MessageElementValidationError(
'MISSING_FIELD',
`message element "${element.type}" requires SendContext`,
element.type,
);
}
if (element.type === 'file' && !ctx?.forwardFake) {
throw new MessageElementValidationError(
'UNSENDABLE_TYPE',
'message element "file" must use the group/private file upload pipeline',
element.type,
);
}
}
const result: ProtoElem[] = [];
let voiceCompatibility: Uint8Array | undefined;
const singlePrivateVoice =
ctx?.scene === 'direct-private' && elements.filter((e) => e.type === 'record').length === 1;
// A user-supplied @ that lands immediately after a reply's srcMsg isn't
// honored by the QQ NT group server (the @ shows but never notifies, #129).
// When a reply and an @ coexist, emit the replied sender as a trailing
// mention right after srcMsg (mirrors Lagrange ForwardEntity.PackElement),
// which restores the wire shape the server expects.
const hasUserAt = elements.some((e) => e.type === 'at');
for (const elem of elements) {
switch (elem.type) {
case 'text':
result.push(makeTextElem(elem.text));
break;
case 'face':
result.push(
await makeFaceElem(elem.faceId, elem.large ?? true, elem.resultId, ctx),
);
break;
case 'poke':
result.push(makePokeElem(elem));
break;
case 'mface':
result.push(makeMarketFaceElem(elem));
break;
case 'at':
result.push(makeMentionElem(elem, ctx));
break;
case 'reply':
result.push(makeReplyElem(elem));
if (hasUserAt && ctx?.groupId !== undefined && elem.replySenderUin) {
const replyMention = await makeReplyMentionElem(ctx, elem.replySenderUin);
if (replyMention) {
result.push(replyMention);
}
}
break;
case 'json':
result.push(makeJsonElem(elem));
break;
case 'xml':
result.push(makeXmlElem(elem));
break;
case 'markdown':
result.push(makeMarkdownElem(elem));
break;
case 'image':
result.push(...(await makeImageElem(ctx!, elem)));
break;
case 'forward':
result.push(makeForwardElem(elem));
break;
case 'record': {
const voice = await makePttElem(ctx!, elem);
result.push(voice.elem);
if (singlePrivateVoice) {
voiceCompatibility = voice.compatQMsg;
}
break;
}
case 'video':
result.push(await makeVideoElem(ctx!, elem));
break;
case 'file':
// Files split into two routes depending on whether this is a
// live send or a forward-message fake-send:
//
// 1. LIVE send (PbSendMsg / OIDB 0x6d9_4) — never carried on
// elems[]. The OneBot layer splits file segments off before
// calling buildSendElems:
// * c2c (private): split → `bridge.sendC2cFileMessage`
// (routingHead.trans0x211 + msgContent FileExtra).
// * group: split → `bridge.sendGroupFileMessage`
// (OIDB 0x6d9_4 publish).
// The QQ-NT server REJECTS outgoing PbSendMsg with a
// transElem(24) (result=79), so the all-message preflight above
// raises a typed validation error when a live file reaches here.
//
// 2. FORWARD-FAKE upload (long-msg) — `ctx.forwardFake===true`.
// The long-msg service stores the gzipped protobuf verbatim
// and the receiver decodes file entities from transElem(24)
// + msgContent FileExtra. Mirror NapCat's split (see
// `dev/napcatQQ/.../packet/message/element.ts:530-610`):
// * group → emit transElem(24) via makeGroupFileElem
// * c2c → emit NOTHING here; the forward-builder pulls
// the file segment off and writes msgContent
// separately (because RichText.notOnlineFile +
// FileExtra both live outside elems[]).
if (!ctx?.forwardFake) {
throw new Error(
'element-builder file routing invariant failed after preflight',
);
}
if (ctx.groupId !== undefined) {
result.push(makeGroupFileElem(elem, ctx));
}
// c2c forwardFake intentionally emits no Elem — handled by the
// forward-builder at the msgContent level.
break;
default:
// assertValidMessageElements above rejects every non-W type before any
// side effect. Reaching this branch means the manifest and dispatcher
// drifted despite their reconciliation test.
throw new Error(`element-builder dispatch invariant failed: ${elem.type}`);
}
}
return { elems: result, voiceCompatibility };
}
export async function buildSendElems(
elements: MessageElement[],
ctx?: SendContext,
): Promise<ProtoElem[]> {
return (await buildSendContent(elements, ctx)).elems;
}
export async function encodePrivateRichText(
elements: MessageElement[],
ctx: SendContext,
): Promise<Uint8Array> {
const { elems, voiceCompatibility } = await buildSendContent(elements, ctx);
const modern = protobuf_encode<SendRichText>({ elems });
// Both parts encode the same RichText message. Preserve the server's playback
// data verbatim, including extensions our receive schema does not interpret.
if (!voiceCompatibility?.length) {
return modern;
}
if (!protobuf_decode<{ ptt?: pb<4, bytes> }>(voiceCompatibility).ptt?.length) {
throw new Error('private voice upload returned incomplete playback information');
}
return Buffer.concat([modern, voiceCompatibility]);
}
+954
View File
@@ -0,0 +1,954 @@
/**
* 消息元素「四向」对账清单 —— 单一权威。
*
* 一种消息元素(文字/图片/窗口抖动/商城表情…)要在两个流向、共 4 处处理,散在
* 不同文件、不同包。历史上这 4 处各写各的、没人对账,导致字段名对不上、漏写
* 方向无人知(都走运行时兜底 `default`,悄悄失败)。
*
* 本清单声明「每种元素类型应支持哪几个方向」,配套的两道对账测试
* (`protocol/tests/element-manifest.test.ts` 与 `onebot/tests/element-manifest.test.ts`,
* 因包边界拆两处、共用本清单)拿它逐一核对那 4 处代码 —— 漏写或多写一个方向
* 立即报红,而不再是运行时静默。
*
* 四个方向:
* D 收·解 QQ 原始数据 → MessageElement `protocol/msg-push/rich-body-decoder.ts`
* S 收·转 MessageElement → OneBot 段 `onebot/event-converter/to-segment.ts`
* P 发·解 OneBot 段 → MessageElement `onebot/message-parser.ts`
* W 发·打包 MessageElement → QQ 原始数据 `protocol/element-builder.ts`
*
* 注:D(收·解)按 proto 字段分派、一字段可扇出多种元素、多字段可收敛为一种,
* 与另外三个「按 element.type 分派」的方向异构,故它保持独立开关、不进 onebot
* 侧的合并表;但它产出的元素类型集合仍受本清单核对。
*
* 取值语义:
* 'yes' 该方向有对应处理。
* 'no' 该方向暂无(记录在案的缺口,本次重构不补,另立任务再议)。
* 'by-design-no' 该方向按设计不支持(QQ 协议限制),缺席是正确的。
*/
import type { MessageElement, MessageElementOf, MessageElementType } from './events';
export const ELEMENT_DIRECTIONS = ['D', 'S', 'P', 'W'] as const;
export type ElementDirection = (typeof ELEMENT_DIRECTIONS)[number];
export type DirectionSupport = 'yes' | 'no' | 'by-design-no';
/** The outbound transport selected before any resolver, upload, or packet send. */
export type OutboundMessageScene = 'direct-private' | 'group' | 'group-temp' | 'forward';
/**
* Window shake is an action-like CommonElem, not ordinary message content.
* Keep its narrow send contract in the protocol package so every caller
* rejects unsupported destinations and mixed payloads before side effects.
*/
export function assertWindowShakeSendPolicy(
windowShakeCount: number,
segmentCount: number,
scene?: OutboundMessageScene,
): void {
if (windowShakeCount === 0) {
return;
}
if (scene !== 'direct-private') {
throw new MessageElementValidationError(
'UNSENDABLE_TYPE',
'message element "poke" can only be sent in a direct private chat',
'poke',
);
}
if (windowShakeCount !== 1 || segmentCount !== 1) {
throw new MessageElementValidationError(
'UNSENDABLE_TYPE',
'message element "poke" must be the only segment in a private window-shake request',
'poke',
);
}
}
/**
* QQ clients treat a video as a standalone message: sibling elements remain
* on the wire but are not rendered, and additional videos cannot be opened.
* Reject that ambiguous wire shape instead of reporting a successful send
* whose visible content differs from the caller's request.
*/
export function assertVideoSendPolicy(videoCount: number, effectiveSegmentCount: number): void {
if (videoCount === 0) {
return;
}
if (videoCount !== 1) {
throw new MessageElementValidationError(
'UNSENDABLE_TYPE',
'message element "video" may appear only once in a message',
'video',
);
}
if (effectiveSegmentCount !== 1) {
throw new MessageElementValidationError(
'UNSENDABLE_TYPE',
'message element "video" must be the only segment in a message',
'video',
);
}
}
type ElementField<T extends MessageElementType> = Exclude<keyof MessageElementOf<T>, 'type'> &
string;
export interface ElementSpec<T extends MessageElementType> {
/** 每个方向的支持状态。 */
readonly directions: Readonly<Record<ElementDirection, DirectionSupport>>;
/**
* 该元素类型允许的全部字段。`fieldsFor` 在编译期要求它与判别联合完全一致,
* `validateMessageElement` 在运行时据此拒绝越界字段。
*/
readonly fields: readonly ElementField<T>[];
/** 运行时必须存在(不能为 undefined/null)的字段。 */
readonly requiredFields: readonly ElementField<T>[];
/** 备注:媒体上传、三角映射、协议限制等需要跨方向知道的事。 */
readonly note?: string;
}
type ManifestShape = { readonly [T in MessageElementType]: ElementSpec<T> };
/**
* Compile-time exact-field guard: an unknown field is rejected by the tuple's
* element type; omitting a legal field makes the synthetic __missingFields
* property unsatisfied. The returned tuple remains available at runtime.
*/
function fieldsFor<T extends MessageElementType>() {
return <const F extends readonly ElementField<T>[]>(
fields: F &
(Exclude<ElementField<T>, F[number]> extends never
? unknown
: { readonly __missingFields: Exclude<ElementField<T>, F[number]> }),
): F => fields;
}
/**
* 全部规范元素类型 × 四向支持。此表须与那 4 处派发代码逐字一致,否则对账测试报红。
*/
export const ELEMENT_MANIFEST = {
text: {
directions: { D: 'yes', S: 'yes', P: 'yes', W: 'yes' },
fields: fieldsFor<'text'>()(['text']),
requiredFields: ['text'],
},
at: {
directions: { D: 'yes', S: 'yes', P: 'yes', W: 'yes' },
fields: fieldsFor<'at'>()(['targetUin', 'uid', 'text']),
requiredFields: ['targetUin'],
},
face: {
directions: { D: 'yes', S: 'yes', P: 'yes', W: 'yes' },
fields: fieldsFor<'face'>()(['faceId', 'large', 'resultId']),
requiredFields: ['faceId'],
},
reply: {
directions: { D: 'yes', S: 'yes', P: 'yes', W: 'yes' },
fields: fieldsFor<'reply'>()([
'replySeq',
'replyMessageId',
'replySenderUin',
'replyTime',
'replyRandom',
'replyElements',
]),
requiredFields: ['replySeq'],
},
json: {
directions: { D: 'yes', S: 'yes', P: 'yes', W: 'yes' },
fields: fieldsFor<'json'>()(['text']),
requiredFields: ['text'],
},
xml: {
directions: { D: 'yes', S: 'yes', P: 'yes', W: 'yes' },
fields: fieldsFor<'xml'>()(['text', 'subType']),
requiredFields: ['text'],
},
forward: {
directions: { D: 'yes', S: 'yes', P: 'yes', W: 'yes' },
fields: fieldsFor<'forward'>()([
'resId',
'forwardSource',
'forwardSummary',
'forwardPrompt',
'forwardNews',
'forwardTSum',
'forwardUuid',
]),
requiredFields: ['resId'],
},
image: {
directions: { D: 'yes', S: 'yes', P: 'yes', W: 'yes' },
fields: fieldsFor<'image'>()([
'imageUrl',
'fileId',
'fileName',
'fileSize',
'url',
'subType',
'summary',
'width',
'height',
'flash',
'md5Hex',
'sha1Hex',
'picFormat',
'noByteFallback',
]),
requiredFields: [],
note: 'media:W(打包)经 highway 上传、异步,需 SendContext',
},
record: {
directions: { D: 'yes', S: 'yes', P: 'yes', W: 'yes' },
fields: fieldsFor<'record'>()([
'fileName',
'fileId',
'fileSize',
'fileHash',
'url',
'duration',
'md5Hex',
'sha1Hex',
'voiceFormat',
'noByteFallback',
'mediaNode',
]),
requiredFields: [],
note: 'media',
},
video: {
directions: { D: 'yes', S: 'yes', P: 'yes', W: 'yes' },
fields: fieldsFor<'video'>()([
'fileName',
'fileId',
'fileSize',
'fileHash',
'url',
'thumbUrl',
'duration',
'width',
'height',
'md5Hex',
'sha1Hex',
'videoFormat',
'noByteFallback',
'mediaNode',
]),
requiredFields: [],
note: 'media',
},
mface: {
directions: { D: 'yes', S: 'yes', P: 'yes', W: 'yes' },
fields: fieldsFor<'mface'>()(['emojiId', 'emojiPackageId', 'emojiKey', 'text']),
requiredFields: ['emojiId'],
note: '商城表情三角:S(转 OneBot)输出 image 段并挂 emoji_id/emoji_package_id/key 标记,P(解)再从该 image 段认回',
},
file: {
directions: { D: 'yes', S: 'yes', P: 'yes', W: 'yes' },
fields: fieldsFor<'file'>()([
'fileId',
'fileName',
'fileSize',
'fileHash',
'url',
'md5Hex',
'sha1Hex',
]),
requiredFields: [],
note: 'W 特殊:live-send 在 OneBot 层被拆走走独立上传管线,仅 forwardFake 时落 transElem(24)',
},
poke: {
directions: { D: 'yes', S: 'yes', P: 'yes', W: 'yes' },
fields: fieldsFor<'poke'>()(['subType']),
requiredFields: ['subType'],
note: 'W 仅支持普通好友私聊窗口抖动;send_poke Action 仍表示拍一拍',
},
markdown: {
directions: { D: 'yes', S: 'yes', P: 'yes', W: 'yes' },
fields: fieldsFor<'markdown'>()(['text']),
requiredFields: ['text'],
},
inline_keyboard: {
directions: { D: 'yes', S: 'yes', P: 'no', W: 'no' },
fields: fieldsFor<'inline_keyboard'>()(['botAppid', 'rows']),
requiredFields: ['botAppid', 'rows'],
note: '机器人消息的交互按钮;当前仅解码并上报,不支持作为普通消息发送',
},
flash_file: {
directions: { D: 'yes', S: 'yes', P: 'by-design-no', W: 'by-design-no' },
fields: fieldsFor<'flash_file'>()(['filesetId', 'sceneType', 'fileName', 'thumbUrl']),
requiredFields: ['filesetId'],
note: '闪传文件:收侧解码 markdown commonElem(旧卡 JSON data.fileSetId,现网 extType=1/extInfo + open_fileset scheme,#199/#200/#358);发送走 send_flash_msg,故 P/W 按设计不支持',
},
} as const satisfies ManifestShape;
export type ElementType = keyof typeof ELEMENT_MANIFEST;
export type MessageElementValidationCode =
'UNKNOWN_TYPE' | 'UNSENDABLE_TYPE' | 'MISSING_FIELD' | 'UNEXPECTED_FIELD' | 'INVALID_FIELD';
/** Stable typed error for callers that map invalid messages to BAD_REQUEST. */
export class MessageElementValidationError extends Error {
override readonly name = 'MessageElementValidationError';
constructor(
readonly code: MessageElementValidationCode,
message: string,
readonly elementType?: string,
readonly field?: string,
) {
super(message);
}
}
export type MessageElementValidationResult =
| { readonly ok: true; readonly element: MessageElement }
| { readonly ok: false; readonly error: MessageElementValidationError };
const STRING_FIELDS: ReadonlySet<string> = new Set([
'text',
'uid',
'imageUrl',
'fileId',
'fileName',
'fileHash',
'url',
'thumbUrl',
'summary',
'emojiId',
'emojiKey',
'resId',
'filesetId',
'forwardSource',
'forwardSummary',
'forwardPrompt',
'forwardUuid',
'md5Hex',
'sha1Hex',
'botAppid',
'resultId',
]);
const NUMBER_FIELDS: ReadonlySet<string> = new Set([
'faceId',
'targetUin',
'fileSize',
'replySeq',
'replyMessageId',
'replySenderUin',
'replyTime',
'replyRandom',
'subType',
'duration',
'width',
'height',
'emojiPackageId',
'sceneType',
'forwardTSum',
'picFormat',
'videoFormat',
'voiceFormat',
]);
const BOOLEAN_FIELDS: ReadonlySet<string> = new Set(['flash', 'noByteFallback', 'large']);
function throwValidation(
code: MessageElementValidationCode,
message: string,
elementType?: string,
field?: string,
): never {
throw new MessageElementValidationError(code, message, elementType, field);
}
function validateFieldValue(type: ElementType, field: string, value: unknown): void {
if (value === undefined) {
return;
}
if (STRING_FIELDS.has(field)) {
if (typeof value !== 'string') {
throwValidation(
'INVALID_FIELD',
`message element "${type}" field "${field}" must be a string`,
type,
field,
);
}
return;
}
if (NUMBER_FIELDS.has(field)) {
if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
throwValidation(
'INVALID_FIELD',
`message element "${type}" field "${field}" must be a safe integer`,
type,
field,
);
}
return;
}
if (BOOLEAN_FIELDS.has(field)) {
if (typeof value !== 'boolean') {
throwValidation(
'INVALID_FIELD',
`message element "${type}" field "${field}" must be a boolean`,
type,
field,
);
}
return;
}
if (field === 'replyElements') {
if (!Array.isArray(value)) {
throwValidation(
'INVALID_FIELD',
'message element "reply" field "replyElements" must be an array',
type,
field,
);
}
for (const quoted of value) {
assertValidMessageElement(quoted);
}
return;
}
if (field === 'forwardNews') {
if (
!Array.isArray(value) ||
value.some(
(item) =>
typeof item !== 'object' ||
item === null ||
Array.isArray(item) ||
typeof (item as { text?: unknown }).text !== 'string' ||
Object.keys(item).some((key) => key !== 'text'),
)
) {
throwValidation(
'INVALID_FIELD',
'message element "forward" field "forwardNews" must contain only { text: string } entries',
type,
field,
);
}
return;
}
if (field === 'rows') {
const stringFields = new Set(['id', 'label', 'visitedLabel', 'unsupportedTips', 'data']);
const numberFields = new Set(['style', 'type', 'clickLimit', 'permissionType', 'anchor']);
const booleanFields = new Set(['atBotShowChannelList', 'isReply', 'enter']);
const stringArrayFields = new Set(['specifyRoleIds', 'specifyUserIds']);
const buttonFields = new Set([
...stringFields,
...numberFields,
...booleanFields,
...stringArrayFields,
]);
const valid =
Array.isArray(value) &&
value.every((row) => {
if (typeof row !== 'object' || row === null || Array.isArray(row)) {
return false;
}
const rowRecord = row as Record<string, unknown>;
if (
Object.keys(rowRecord).some((key) => key !== 'buttons') ||
!Array.isArray(rowRecord.buttons)
) {
return false;
}
return rowRecord.buttons.every((button) => {
if (typeof button !== 'object' || button === null || Array.isArray(button)) {
return false;
}
const buttonRecord = button as Record<string, unknown>;
if (Object.keys(buttonRecord).some((key) => !buttonFields.has(key))) {
return false;
}
return (
[...stringFields].every((key) => typeof buttonRecord[key] === 'string') &&
[...numberFields].every(
(key) =>
typeof buttonRecord[key] === 'number' &&
Number.isSafeInteger(buttonRecord[key]) &&
(buttonRecord[key] as number) >= 0,
) &&
[...booleanFields].every((key) => typeof buttonRecord[key] === 'boolean') &&
[...stringArrayFields].every(
(key) =>
Array.isArray(buttonRecord[key]) &&
(buttonRecord[key] as unknown[]).every(
(entry) => typeof entry === 'string',
),
)
);
});
});
if (!valid) {
throwValidation(
'INVALID_FIELD',
'message element "inline_keyboard" field "rows" must contain normalized button rows',
type,
field,
);
}
return;
}
if (field === 'mediaNode') {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throwValidation(
'INVALID_FIELD',
`message element "${type}" field "mediaNode" must be an object`,
type,
field,
);
}
return;
}
// This is an invariant failure in the executable contract, not bad user
// input: adding a manifest field without a runtime validator must be loud.
throw new Error(`element manifest field has no runtime validator: ${type}.${field}`);
}
function requireNonEmptyString(
element: Record<string, unknown>,
type: ElementType,
field: string,
): void {
const value = element[field];
if (typeof value !== 'string' || value.length === 0) {
throwValidation(
'INVALID_FIELD',
`message element "${type}" field "${field}" must not be empty`,
type,
field,
);
}
}
function validateSemantics(element: MessageElement, direction: ElementDirection | undefined): void {
const nonNegativeFields = [
'targetUin',
'faceId',
'fileSize',
'replySeq',
'replySenderUin',
'replyTime',
'subType',
'duration',
'width',
'height',
'emojiPackageId',
'sceneType',
'forwardTSum',
'picFormat',
'videoFormat',
'voiceFormat',
] as const;
const record = element as unknown as Record<string, unknown>;
for (const field of nonNegativeFields) {
const value = record[field];
if (typeof value === 'number' && value < 0) {
throwValidation(
'INVALID_FIELD',
`message element "${element.type}" field "${field}" must be non-negative`,
element.type,
field,
);
}
}
if (element.md5Hex !== undefined && !/^[0-9a-fA-F]{32}$/.test(element.md5Hex)) {
throwValidation(
'INVALID_FIELD',
`message element "${element.type}" field "md5Hex" must be exactly 32 hexadecimal characters`,
element.type,
'md5Hex',
);
}
if (element.sha1Hex !== undefined && !/^[0-9a-fA-F]{40}$/.test(element.sha1Hex)) {
throwValidation(
'INVALID_FIELD',
`message element "${element.type}" field "sha1Hex" must be exactly 40 hexadecimal characters`,
element.type,
'sha1Hex',
);
}
switch (element.type) {
case 'text':
case 'xml':
case 'markdown':
requireNonEmptyString(
element as unknown as Record<string, unknown>,
element.type,
'text',
);
return;
case 'inline_keyboard':
requireNonEmptyString(
element as unknown as Record<string, unknown>,
element.type,
'botAppid',
);
if (!/^\d+$/.test(element.botAppid)) {
throwValidation(
'INVALID_FIELD',
'message element "inline_keyboard" field "botAppid" must be an unsigned decimal integer',
element.type,
'botAppid',
);
}
return;
case 'json': {
requireNonEmptyString(
element as unknown as Record<string, unknown>,
element.type,
'text',
);
if (direction !== 'P' && direction !== 'W') {
return;
}
try {
const parsed = JSON.parse(element.text) as unknown;
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('not an object');
}
} catch {
throwValidation(
'INVALID_FIELD',
'message element "json" field "text" must contain a JSON object',
element.type,
'text',
);
}
return;
}
case 'at':
if (!Number.isInteger(element.targetUin) || element.targetUin < 0) {
throwValidation(
'INVALID_FIELD',
'message element "at" field "targetUin" must be a non-negative integer',
element.type,
'targetUin',
);
}
return;
case 'face':
if (!Number.isInteger(element.faceId) || element.faceId < 0) {
throwValidation(
'INVALID_FIELD',
'message element "face" field "faceId" must be a non-negative integer',
element.type,
'faceId',
);
}
if (element.resultId !== undefined && (direction === 'P' || direction === 'W')) {
if (!element.resultId.trim()) {
throwValidation(
'INVALID_FIELD',
'face.resultId must not be empty',
element.type,
'resultId',
);
}
if (element.large === false) {
throwValidation(
'INVALID_FIELD',
'face.resultId requires an animated face; large cannot be false',
element.type,
'resultId',
);
}
}
return;
case 'reply':
if (!Number.isInteger(element.replySeq) || element.replySeq <= 0) {
throwValidation(
'INVALID_FIELD',
'message element "reply" field "replySeq" must be a positive integer',
element.type,
'replySeq',
);
}
return;
case 'mface':
if (!/^[0-9a-fA-F]{32}$/.test(element.emojiId)) {
throwValidation(
'INVALID_FIELD',
'message element "mface" field "emojiId" must be exactly 32 hexadecimal characters',
element.type,
'emojiId',
);
}
return;
case 'forward':
requireNonEmptyString(
element as unknown as Record<string, unknown>,
element.type,
'resId',
);
return;
case 'image': {
if (direction !== 'P' && direction !== 'W') {
return;
}
if (
element.noByteFallback === true &&
(!element.md5Hex?.trim() || !element.sha1Hex?.trim())
) {
throwValidation(
'MISSING_FIELD',
`message element "${element.type}" with noByteFallback requires md5Hex + sha1Hex`,
element.type,
'md5Hex',
);
}
const hasSource = Boolean(
element.url?.trim() || element.imageUrl?.trim() || element.fileId?.trim(),
);
const hasFingerprint =
element.noByteFallback === true &&
Boolean(element.md5Hex?.trim() && element.sha1Hex?.trim());
if (!hasSource && !hasFingerprint) {
throwValidation(
'MISSING_FIELD',
`message element "${element.type}" requires a file/url source or a complete fast-upload fingerprint`,
element.type,
'url',
);
}
return;
}
case 'record':
case 'video': {
if (direction !== 'P' && direction !== 'W') {
return;
}
if (
element.noByteFallback === true &&
(!element.md5Hex?.trim() || !element.sha1Hex?.trim())
) {
throwValidation(
'MISSING_FIELD',
`message element "${element.type}" with noByteFallback requires md5Hex + sha1Hex`,
element.type,
'md5Hex',
);
}
const hasSource = Boolean(element.url?.trim() || element.fileId?.trim());
const hasFingerprint =
element.noByteFallback === true &&
Boolean(element.md5Hex?.trim() && element.sha1Hex?.trim());
if (!hasSource && !hasFingerprint) {
throwValidation(
'MISSING_FIELD',
`message element "${element.type}" requires a file/url source or a complete fast-upload fingerprint`,
element.type,
'url',
);
}
return;
}
case 'file':
if (
(direction === 'P' || direction === 'W') &&
!element.url?.trim() &&
!element.fileId?.trim()
) {
throwValidation(
'MISSING_FIELD',
'message element "file" requires file_id or file/url',
element.type,
'fileId',
);
}
return;
case 'poke':
case 'flash_file':
return;
}
}
/**
* Validate a runtime value against the same field table checked by TypeScript.
* P/W additionally enforce sendability and source requirements.
*/
export function assertValidMessageElement(
value: unknown,
direction?: ElementDirection,
): asserts value is MessageElement {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throwValidation('INVALID_FIELD', 'message element must be an object');
}
const element = value as Record<string, unknown>;
const rawType = element.type;
if (typeof rawType !== 'string' || !Object.hasOwn(ELEMENT_MANIFEST, rawType)) {
throwValidation(
'UNKNOWN_TYPE',
`unknown message element type: ${String(rawType)}`,
String(rawType),
);
}
const type = rawType as ElementType;
const spec = ELEMENT_MANIFEST[type];
if (direction && spec.directions[direction] !== 'yes') {
const hint =
type === 'flash_file'
? 'use the send_flash_msg Action instead'
: `direction ${direction} is not supported`;
throwValidation(
'UNSENDABLE_TYPE',
`message element "${type}" cannot be sent; ${hint}`,
type,
);
}
const allowed = new Set<string>(['type', ...spec.fields]);
for (const key of Object.keys(element)) {
if (!allowed.has(key)) {
throwValidation(
'UNEXPECTED_FIELD',
`message element "${type}" does not allow field "${key}"`,
type,
key,
);
}
}
for (const field of spec.requiredFields) {
if (element[field] === undefined || element[field] === null) {
throwValidation(
'MISSING_FIELD',
`message element "${type}" requires field "${field}"`,
type,
field,
);
}
}
for (const field of spec.fields) {
validateFieldValue(type, field, element[field]);
}
validateSemantics(element as unknown as MessageElement, direction);
}
export function validateMessageElement(
value: unknown,
direction?: ElementDirection,
): MessageElementValidationResult {
try {
assertValidMessageElement(value, direction);
return { ok: true, element: value };
} catch (error) {
if (error instanceof MessageElementValidationError) {
return { ok: false, error };
}
throw error;
}
}
export function assertValidMessageElements(
values: readonly unknown[],
direction?: ElementDirection,
): asserts values is readonly MessageElement[] {
for (const value of values) {
assertValidMessageElement(value, direction);
}
}
/**
* 某方向应被处理的元素类型集合(`directions[dir] === 'yes'`)—— 对账测试的基准。
*/
export function typesForDirection(dir: ElementDirection): Set<string> {
const out = new Set<string>();
for (const [type, spec] of Object.entries(ELEMENT_MANIFEST)) {
if (spec.directions[dir] === 'yes') {
out.add(type);
}
}
return out;
}
/**
* 发·解(P)侧还识别一批「纯 OneBot 输入词」:可执行的会塌缩成 json / face,
* node 仅允许在 forward node list 的专用解析器中使用,shake 会规范化为 poke,
* anonymous 因没有合法消息元素语义而返回 typed validation error。它们没有收侧
* 对应,也没有专属 wire 形态,故不进本清单;对账 P 时须先从代码里剔除这些类型。
*/
export const INPUT_SUGAR_SEGMENTS: ReadonlySet<string> = new Set([
'node',
'share',
'music',
'location',
'contact',
'rps',
'dice',
'shake',
'anonymous',
]);
+125
View File
@@ -0,0 +1,125 @@
import { createLogger } from '@snowluma/common/logger';
import type { QQEventVariant } from './events';
type EventKind = QQEventVariant['kind'];
type EventOf<K extends EventKind> = Extract<QQEventVariant, { kind: K }>;
export type EventHandler<K extends EventKind> = (event: EventOf<K>) => void | Promise<void>;
export type AnyEventHandler = (event: QQEventVariant) => void | Promise<void>;
export interface EventBusOptions {
/** Logger label for handler failures. Defaults to `Bridge.Events`. */
loggerName?: string;
/** Override the default error reporter (e.g. for tests). */
onError?: (kind: EventKind | '*', err: unknown) => void;
}
const DEFAULT_LOGGER = createLogger('Bridge.Events');
export class BridgeEventBus {
private readonly handlers = new Map<EventKind, Set<{ handler: unknown }>>();
private readonly anyHandlers = new Set<{ handler: AnyEventHandler }>();
private readonly onError: (kind: EventKind | '*', err: unknown) => void;
constructor(opts: EventBusOptions = {}) {
if (opts.onError) {
this.onError = opts.onError;
} else {
const log = opts.loggerName ? createLogger(opts.loggerName) : DEFAULT_LOGGER;
this.onError = (kind, err) => {
log.error(
'event handler error [%s]: %s',
kind,
err instanceof Error ? (err.stack ?? err.message) : String(err),
);
};
}
}
/** Each subscription owns its unsubscribe function. */
on<K extends EventKind>(kind: K, handler: EventHandler<K>): () => void {
let set = this.handlers.get(kind);
if (!set) {
set = new Set();
this.handlers.set(kind, set);
}
const subscription = { handler };
set.add(subscription);
return () => {
this.handlers.get(kind)?.delete(subscription);
};
}
/** Subscribe to every kind (low-volume callers like loggers). */
onAny(handler: AnyEventHandler): () => void {
const subscription = { handler };
this.anyHandlers.add(subscription);
return () => {
this.anyHandlers.delete(subscription);
};
}
/** Remove every registration of this handler for the given kind. */
off<K extends EventKind>(kind: K, handler: EventHandler<K>): void {
const subscriptions = this.handlers.get(kind);
if (subscriptions) {
for (const subscription of subscriptions) {
if (subscription.handler === handler) {
subscriptions.delete(subscription);
}
}
}
}
has(kind: EventKind): boolean {
return (this.handlers.get(kind)?.size ?? 0) + this.anyHandlers.size > 0;
}
/** Drop every subscriber. Mostly useful for tests. */
clear(): void {
this.handlers.clear();
this.anyHandlers.clear();
}
/**
* Dispatch an event in parallel to every subscriber for its kind plus every
* onAny handler. Rejections are isolated per-handler — one bad subscriber
* never blocks the others, and the returned promise resolves once every
* subscriber settles. Subscription changes apply to the next event.
*/
async emit<K extends EventKind>(event: EventOf<K>): Promise<void> {
const kindHandlers = [...(this.handlers.get(event.kind) ?? [])];
const anyHandlers = [...this.anyHandlers];
const tasks: Promise<void>[] = [];
for (const { handler } of kindHandlers) {
// Registration keys guarantee this handler accepts the event's kind.
const typed = handler as EventHandler<K>;
tasks.push(invoke(this.onError, event.kind, () => typed(event)));
}
for (const { handler } of anyHandlers) {
tasks.push(invoke(this.onError, '*', () => handler(event)));
}
await Promise.allSettled(tasks);
}
}
async function invoke(
onError: (kind: EventKind | '*', err: unknown) => void,
kind: EventKind | '*',
fn: () => unknown,
): Promise<void> {
try {
await fn();
} catch (err) {
onError(kind, err);
}
}
+638
View File
@@ -0,0 +1,638 @@
/** Re-upload metadata carried by received record/video elements. */
export interface MediaNode {
fileUuid?: string;
storeId?: number;
uploadTime?: number;
ttl?: number;
subType?: number;
info?: {
fileSize?: number;
fileHash?: string;
fileSha1?: string;
fileName?: string;
width?: number;
height?: number;
time?: number;
original?: number;
type?: {
type?: number;
picFormat?: number;
videoFormat?: number;
voiceFormat?: number;
};
};
}
export interface TextElement {
type: 'text';
text: string;
}
export interface AtElement {
type: 'at';
/** 0 denotes @all. */
targetUin: number;
uid?: string;
text?: string;
}
export interface FaceElement {
type: 'face';
faceId: number;
/** Send the catalog animation when available (default true). */
large?: boolean;
/** Selected animation result; omitted to keep the default animation choice. */
resultId?: string;
}
export interface ReplyElement {
type: 'reply';
replySeq: number;
replyMessageId?: number;
replySenderUin?: number;
replyTime?: number;
replyRandom?: number;
/** Decoded quoted elements, used to backfill get_msg locally. */
replyElements?: MessageElement[];
}
export interface JsonElement {
type: 'json';
text: string;
}
export interface XmlElement {
type: 'xml';
text: string;
subType?: number;
}
export interface MarkdownElement {
type: 'markdown';
text: string;
}
export interface InlineKeyboardButton {
id: string;
label: string;
visitedLabel: string;
style: number;
type: number;
clickLimit: number;
unsupportedTips: string;
data: string;
atBotShowChannelList: boolean;
permissionType: number;
specifyRoleIds: string[];
specifyUserIds: string[];
isReply: boolean;
enter: boolean;
anchor: number;
}
export interface InlineKeyboardRow {
buttons: InlineKeyboardButton[];
}
/** Receive-side bot keyboard embedded in a CommonElem. */
export interface InlineKeyboardElement {
type: 'inline_keyboard';
/** Kept as decimal text because the wire identifier is uint64. */
botAppid: string;
rows: InlineKeyboardRow[];
}
export interface ForwardElement {
type: 'forward';
resId: string;
forwardSource?: string;
forwardSummary?: string;
forwardPrompt?: string;
forwardNews?: Array<{ text: string }>;
forwardTSum?: number;
/** Links a nested preview to its piggybacked actionCommand. */
forwardUuid?: string;
}
interface FingerprintFields {
md5Hex?: string;
sha1Hex?: string;
/** Disallow falling back to source bytes when a fast upload misses. */
noByteFallback?: boolean;
}
export interface ImageElement extends FingerprintFields {
type: 'image';
imageUrl?: string;
fileId?: string;
fileName?: string;
fileSize?: number;
url?: string;
subType?: number;
summary?: string;
width?: number;
height?: number;
flash?: boolean;
picFormat?: number;
}
export interface RecordElement extends FingerprintFields {
type: 'record';
fileName?: string;
fileId?: string;
fileSize?: number;
fileHash?: string;
url?: string;
duration?: number;
voiceFormat?: number;
mediaNode?: MediaNode;
}
export interface VideoElement extends FingerprintFields {
type: 'video';
fileName?: string;
fileId?: string;
fileSize?: number;
fileHash?: string;
url?: string;
thumbUrl?: string;
duration?: number;
width?: number;
height?: number;
videoFormat?: number;
mediaNode?: MediaNode;
}
/** Market face (商城表情); emojiId is the hex wire face GUID. */
export interface MarketFaceElement {
type: 'mface';
emojiId: string;
emojiPackageId?: number;
emojiKey?: string;
text?: string;
}
export interface FileElement {
type: 'file';
fileId?: string;
fileName?: string;
fileSize?: number;
fileHash?: string;
url?: string;
md5Hex?: string;
sha1Hex?: string;
}
/** Window-shake element; outbound use is limited to ordinary friend C2C. */
export interface PokeElement {
type: 'poke';
subType: number;
}
/** Receive-side flash-transfer card; sending uses send_flash_msg. */
export interface FlashFileElement {
type: 'flash_file';
filesetId: string;
sceneType?: number;
fileName?: string;
/** Cover URL from the incoming card, when present. */
thumbUrl?: string;
}
type MessageElementVariant =
| TextElement
| AtElement
| FaceElement
| ReplyElement
| JsonElement
| XmlElement
| MarkdownElement
| InlineKeyboardElement
| ForwardElement
| ImageElement
| RecordElement
| VideoElement
| MarketFaceElement
| FileElement
| PokeElement
| FlashFileElement;
type UnionKeys<T> = T extends T ? keyof T : never;
type StrictUnionMember<T, All> = T extends T
? T & Partial<Record<Exclude<UnionKeys<All>, keyof T>, never>>
: never;
/**
* Closed message-element vocabulary. The optional-never fields make excess
* fields fail at compile time even when assigning an object to the union
* directly (for example, `{ type: 'poke', faceId: 1 }` is illegal).
*/
export type MessageElement = StrictUnionMember<MessageElementVariant, MessageElementVariant>;
export type MessageElementType = MessageElementVariant['type'];
export type MessageElementOf<T extends MessageElementType> = Extract<
MessageElementVariant,
{ type: T }
>;
export interface ForwardNodePayload {
userUin: number;
nickname: string;
elements: MessageElement[];
// Optional context preserved when known (download path or upload-via-id),
// so `get_forward_msg` can emit OneBot11-compatible OB11Message objects.
time?: number;
msgId?: number;
msgSeq?: number;
groupId?: number;
senderCard?: string;
messageType?: 'group' | 'private';
// When set, this node's `content` is a nested forward chain. The
// upload pipeline (see `actions/forward.ts::uploadForwardNodes`)
// recursively uploads the inner chain and replaces this node's
// `elements` with an ARK preview element pointing at the inner
// res_id. We also piggyback the inner chain's msgBody onto the
// outer long-msg upload as an extra `actionCommand` slot so the
// NapCat-compatible receiver can walk the whole tree from one
// server fetch without resolving each layer's res_id separately
// (modelled after `dev/NapCatQQ/.../SendMsg.uploadForwardedNodesPacket`).
// Caller never sets this on top-level OneBot input — `parseForward
// Nodes` synthesises it when it detects a nested-node array.
innerForward?: ForwardNodePayload[];
// Optional preview news lines carried on this node (OneBot
// `data.news`, `Array<{ text: string }>`). When this node embeds a
// nested forward (`innerForward`), the upload pipeline prefers these
// lines over auto-generated ones for the inner forward's bubble
// preview, matching NapCat / LLOneBot behaviour.
news?: Array<{ text: string }>;
// Optional bubble-preview metadata carried on this node (OneBot
// `data.title` / `data.source` / `data.summary` / `data.prompt`).
// When this node embeds a nested forward, the upload pipeline uses
// these to label the inner forward's card (source line, summary,
// prompt) instead of deriving them from the inner nodes. title is
// exposed back through get_forward_msg for OneBot compatibility.
title?: string;
source?: string;
summary?: string;
prompt?: string;
}
export interface QQEvent {
time: number;
selfUin: number;
}
export interface FriendMessage extends QQEvent {
kind: 'friend_message';
autoReply?: true;
senderUin: number;
/** The other participant in this private conversation. Differs from
* senderUin for messages sent by the logged-in account. */
peerUin?: number;
/** Stable QQ UID carried by ResponseHead. Kept internally so the identity
* index can preserve the exact C2C peer instead of re-resolving its UIN. */
senderUid?: string;
senderNick: string;
/** Sender-local sequence exposed as OneBot message_seq and used by replies. */
msgSeq: number;
/** Bidirectional C2C sequence used for history/actions and canonical message ids. */
ntMsgSeq?: number;
/** Sender-local sequence used by private recall requests. */
clientSeq?: number;
/** False only when an older packet omitted ContentHead field 11. */
sequenceAuthoritative?: boolean;
msgId: number;
elements: MessageElement[];
/** Parsed `qun.invite` card, if this private message is one. Live
* IncomingPacketPipeline copies these onto the pending-application
* store. Not OneBot wire. */
inviteCardGroupUin?: number;
inviteCardSequence?: number;
}
export interface GroupMessage extends QQEvent {
kind: 'group_message';
groupId: number;
/** Group display name. From the message's own wire field (ResponseGrp
* field 7 — accurate even right after a rename), falling back to the
* identity group cache, else '' when neither is available. */
groupName: string;
senderUin: number;
senderNick: string;
senderCard: string;
senderRole: string;
msgSeq: number;
msgId: number;
elements: MessageElement[];
}
export interface TempMessage extends QQEvent {
kind: 'temp_message';
autoReply?: true;
senderUin: number;
/** Source group of the temp session (from responseHead.forward), or 0. */
groupId: number;
senderNick: string;
msgSeq: number;
ntMsgSeq?: number;
clientSeq?: number;
sequenceAuthoritative?: boolean;
elements: MessageElement[];
}
export interface GroupMemberJoin extends QQEvent {
kind: 'group_member_join';
groupId: number;
userUin: number;
operatorUin: number;
userUid?: string;
operatorUid?: string;
/** Independent invitation origin; never inferred from the operator. */
invitorUid?: string;
invitorUin?: number;
/**
* Admission mode derived from GroupChange field 4. This is deliberately
* separate from QQ's nested invitation-source enum, which has different
* wire semantics. It is absent on the separate self-join push path.
*/
joinType?: 'approve' | 'invite';
}
export interface GroupMemberLeave extends QQEvent {
kind: 'group_member_leave';
groupId: number;
userUin: number;
operatorUin: number;
userUid?: string;
operatorUid?: string;
/**
* Protocol-level reason the member left, derived from
* GroupChange.decreaseType. `kick` is split into kick / kick_me
* downstream (OneBot converter) by comparing against selfId.
*/
leaveType: 'leave' | 'kick' | 'disband';
}
export interface GroupMuteEvent extends QQEvent {
kind: 'group_mute';
groupId: number;
userUin: number;
operatorUin: number;
duration: number;
}
export interface GroupAdminEvent extends QQEvent {
kind: 'group_admin';
groupId: number;
userUin: number;
set: boolean;
}
export interface FriendRecall extends QQEvent {
kind: 'friend_recall';
userUin: number;
/** Legacy alias retained for converters; this is the sender-local sequence. */
msgSeq: number;
/** Sender-local sequence carried by the recall protocol. */
clientSeq?: number;
/** True for subtype 139, where the logged-in account recalled its own send. */
recalledBySelf?: boolean;
}
export interface GroupRecallEvent extends QQEvent {
kind: 'group_recall';
groupId: number;
operatorUin: number;
authorUin: number;
msgSeq: number;
}
export interface FriendRequestEvent extends QQEvent {
kind: 'friend_request';
fromUin: number;
fromUid?: string;
message: string;
flag: string;
}
export interface GroupInviteEvent extends QQEvent {
kind: 'group_invite';
groupId: number;
fromUin: number;
fromUid?: string;
/** Person being invited. Present on admin-side invitation notices
* and on the pending-request list; for a self-invite this is the
* logged-in account. */
invitedUin?: number;
invitedUid?: string;
subType: 'add' | 'invite';
message: string;
flag: string;
}
export interface FriendPokeEvent extends QQEvent {
kind: 'friend_poke';
/** The other participant whose private conversation contains this notice. */
peerUin: number;
/** The account that performed the poke, which may be the logged-in account. */
senderUin: number;
targetUin: number;
action: string;
suffix: string;
actionImgUrl: string;
}
export interface GroupPokeEvent extends QQEvent {
kind: 'group_poke';
groupId: number;
userUin: number;
targetUin: number;
action: string;
suffix: string;
actionImgUrl: string;
}
export interface GroupEssenceEvent extends QQEvent {
kind: 'group_essence';
groupId: number;
senderUin: number;
operatorUin: number;
msgSeq: number;
random: number;
set: boolean;
}
export interface GroupFileUploadEvent extends QQEvent {
kind: 'group_file_upload';
groupId: number;
userUin: number;
fileId: string;
fileName: string;
fileSize: number;
busId: number;
}
export interface FriendAddEvent extends QQEvent {
kind: 'friend_add';
userUin: number;
}
export interface GroupMsgEmojiLikeEvent extends QQEvent {
kind: 'group_msg_emoji_like';
groupId: number;
operatorUin: number;
operatorUid: string;
/** Sequence of the message that was reacted to (server-assigned msg_seq). */
msgSeq: number;
/** Emoji ID. QQ system faces are short numeric strings; market faces
* are alphanumeric. We keep the wire string verbatim. */
emojiId: string;
/** Multiplicity of the reaction event. Usually 1. */
count: number;
/** True when the reaction is being added; false when removed. */
isAdd: boolean;
}
/**
* Async voice-to-text result, pushed by the server (Event 0x210 subType 61)
* after a `pttTrans.Trans{C2C,Group}PttReq`. `msgId` echoes the request's
* msgId — the correlation key a pending `fetch_ptt_text` waits on. Internal
* (not surfaced to OneBot clients).
*/
export interface PttTransResultEvent extends QQEvent {
kind: 'ptt_trans_result';
msgId: bigint;
text: string;
}
/**
* The bot account was forced offline (kicked / logged in elsewhere / risk-control)
* — SSO push `StatusService.KickNT`. Mirrors NapCat's OB11BotOfflineEvent →
* `notice_type:'bot_offline'`. `tag` = short title, `message` = description.
*/
export interface BotOfflineEvent extends QQEvent {
kind: 'bot_offline';
tag: string;
message: string;
}
/**
* A group member's card (群名片) changed — detected from message traffic when
* the sender's live card differs from the cached one (same mechanism as NapCat's
* `parseCardChangedEvent`). Maps to OB11 `notice_type:'group_card'`.
*/
export interface GroupCardChangeEvent extends QQEvent {
kind: 'group_card_change';
groupId: number;
userUin: number;
cardNew: string;
cardOld: string;
}
/**
* A group member was granted a special title (群头衔) — Event 0x2DC subType 16,
* field13 == 6. Maps to OB11 `notice/notify` `sub_type:'title'`.
*/
export interface GroupTitleChangeEvent extends QQEvent {
kind: 'group_title_change';
groupId: number;
userUin: number; // the member who received the title
title: string;
}
/**
* Group name changed (Event 0x2DC subType 16, field13 == 12). Mirrors NapCat's
* OB11GroupNameEvent → `notice/notify` `sub_type:'group_name'`.
*/
export interface GroupNameChangeEvent extends QQEvent {
kind: 'group_name_change';
groupId: number;
operatorUin: number; // who renamed the group
name: string; // the new group name
}
/**
* Someone liked the bot's profile card ("名片赞") — Event 0x210 subType 39,
* inner ProfileLikeTip msgType 0 / subType 203. Mirrors NapCat's
* OB11ProfileLikeEvent → `notice/notify` `sub_type:'profile_like'`.
*/
export interface FriendProfileLikeEvent extends QQEvent {
kind: 'friend_profile_like';
operatorUin: number; // who liked
operatorNick: string;
times: number; // like count from this event
}
/**
* C2C "对方正在输入…" input-status push (Event 0x210 subType 0x115 / 277).
* `eventType` 1 = typing, 3 = recording a voice message. Mirrors NapCat's
* `onInputStatusPush` → OB11 `notice/notify` `sub_type:'input_status'`.
*/
export interface FriendInputStatusEvent extends QQEvent {
kind: 'friend_input_status';
userUin: number; // the peer whose input status changed (the typer)
userUid: string;
eventType: number; // 1 = 正在输入, 3 = 正在讲话(录音)
statusText: string;
}
export type OnlineDeviceKind = 'computer' | 'pad' | 'phone' | 'unknown';
/** One entry from QQ NT's online-device cache notification. */
export interface OnlineDeviceInfo {
appId: number;
instanceId: number;
clientType: number;
platform: number;
deviceName: string;
deviceKind: OnlineDeviceKind;
}
/**
* Full online-device snapshot pushed through Event 0x210 / subType 349.
* Internal-only: Bridge consumes this to maintain the query snapshot; it is
* not forwarded as an OneBot event.
*/
export interface OnlineDevicesChangedEvent extends QQEvent {
kind: 'online_devices_changed';
devices: OnlineDeviceInfo[];
}
/**
* A friend or stranger remark changed on another client (Event 0x210 /
* subType 364). Internal-only: the packet pipeline consumes this event to
* keep the identity cache synchronized; it is not forwarded to OneBot.
*/
export interface FriendRemarkChangedEvent extends QQEvent {
kind: 'friend_remark_changed';
userUid: string;
userUin: number;
remark: string;
}
export type QQEventVariant =
| FriendMessage
| GroupMessage
| TempMessage
| GroupMemberJoin
| GroupMemberLeave
| GroupMuteEvent
| GroupAdminEvent
| FriendRecall
| GroupRecallEvent
| FriendRequestEvent
| GroupInviteEvent
| FriendPokeEvent
| GroupPokeEvent
| GroupEssenceEvent
| GroupFileUploadEvent
| FriendAddEvent
| GroupMsgEmojiLikeEvent
| PttTransResultEvent
| FriendInputStatusEvent
| GroupNameChangeEvent
| GroupCardChangeEvent
| GroupTitleChangeEvent
| FriendProfileLikeEvent
| OnlineDevicesChangedEvent
| FriendRemarkChangedEvent
| BotOfflineEvent;
+408
View File
@@ -0,0 +1,408 @@
import type { QQEventVariant } from './events';
import type { IdentityService } from './identity-service';
import type { JsonObject, JsonValue } from '@snowluma/common/json';
/**
* Minimal shape of the OneBot-side `MessageStore` that `formatReply`
* needs — just `findEvent(id)`. Defined inline (instead of importing
* the full `MessageStore` class from @snowluma/onebot) so this
* package doesn't depend on the OneBot layer above it. The OneBot
* `MessageStore` is structurally a superset of this interface, so
* callers pass it through with zero adaptation.
*/
export interface ReplyEventLookup {
findEvent(messageId: number): JsonObject | null | Promise<JsonObject | null>;
}
const MAX_TEXT_PREVIEW = 50;
const MAX_REPLY_BODY_PREVIEW = 30;
const MAX_CARD_TITLE_PREVIEW = 30;
/** "[群名(12345)]" if known, else "12345" (or "0" if missing). */
export function formatGroup(identity: IdentityService, groupId: number): string {
if (!groupId || groupId <= 0) {
return String(groupId || 0);
}
try {
const g = identity.findGroup(groupId);
const name = g?.groupName?.trim();
return name ? `[${name}(${groupId})]` : String(groupId);
} catch {
return String(groupId);
}
}
/**
* "[card-or-nick(uin)]" when the uin is in the group-member or friend
* roster; falls back to the bare uin string. When only a uid is known
* (no uin yet) the uid is returned as-is so the line still references
* someone — better than logging "0".
*/
export function formatUser(
identity: IdentityService,
groupId: number | undefined,
uin: number,
uid?: string,
): string {
try {
if (!uin || uin <= 0) {
return uid ? uid : '0';
}
if (groupId !== undefined && groupId > 0) {
const member = identity.findGroupMember(groupId, uin);
const name = member?.card?.trim() || member?.nickname?.trim();
if (name) {
return `[${name}(${uin})]`;
}
}
const friend = identity.findFriend(uin);
const fname = friend?.remark?.trim() || friend?.nickname?.trim();
if (fname) {
return `[${fname}(${uin})]`;
}
return String(uin);
} catch {
return String(uin);
}
}
/**
* One-line preview of an OneBot-shape message (array of segments or
* raw string). Element rendering matches the existing logReceivedMessage
* vocabulary so this is a drop-in replacement, with a few additions
* (json / xml / markdown / forward / poke / file).
*/
export function formatMessageSegments(message: JsonValue): string {
try {
if (typeof message === 'string') {
return truncate(message, MAX_TEXT_PREVIEW) || '[空消息]';
}
if (!Array.isArray(message)) {
return '[空消息]';
}
const parts: string[] = [];
for (const seg of message) {
if (typeof seg !== 'object' || seg === null || Array.isArray(seg)) {
continue;
}
const obj = seg as JsonObject;
const type = String(obj.type ?? '');
const data =
typeof obj.data === 'object' && obj.data !== null && !Array.isArray(obj.data)
? (obj.data as Record<string, unknown>)
: {};
parts.push(renderSegment(type, data));
}
return parts.join(' ').trim() || '[空消息]';
} catch {
return '[消息渲染异常]';
}
}
function renderSegment(type: string, data: Record<string, unknown>): string {
switch (type) {
case 'text':
return truncate(String(data.text ?? ''), MAX_TEXT_PREVIEW);
// Market faces (商城表情) are surfaced as `image` segments carrying an
// `emoji_id` marker (+ `summary` = face name). Keep the sticker readable in
// previews instead of collapsing it to a generic "[图片]".
case 'image':
return data.emoji_id
? data.summary
? `[${String(data.summary)}]`
: '[表情]'
: '[图片]';
case 'face':
return '[表情]';
case 'mface':
return data.text ? `[${String(data.text)}]` : '[表情]';
case 'at':
return data.qq === 'all' ? '@全体成员' : `@${data.qq ?? ''}`;
case 'reply':
return `[回复:${data.id ?? ''}]`;
case 'record':
return '[语音]';
case 'video':
return '[视频]';
case 'file': {
// OneBot file segments carry the filename under `data.name`
// (see to-segment.ts). Keep `data.file` as a fallback for any
// upstream that still uses the older field name.
const fileName = data.name ?? data.file;
return fileName ? `[文件:${truncate(String(fileName), 20)}]` : '[文件]';
}
case 'json': {
// OneBot json segments carry Tencent's ARK / mini-program / share card
// payload as a JSON string under data.data. For the log preview we try
// to surface a human-readable title — "[卡片:某某分享]" instead of just
// "[JSON]". The full payload stays intact in the OneBot segment for any
// downstream consumer that wants to parse it.
// For QQ's server-side fallback (e.g. cardId -896954183 "当前QQ版本不支持
// 此应用,请升级"), the `prompt` field carries that message — surfaces it
// so the operator sees the actual reason instead of "[JSON]".
const title = extractCardTitle(data.data);
return title ? `[卡片:${truncate(title, MAX_CARD_TITLE_PREVIEW)}]` : '[JSON]';
}
case 'xml':
return '[XML]';
case 'markdown':
return '[Markdown]';
case 'inline_keyboard':
return '[交互按钮]';
case 'forward':
return '[聊天记录]';
case 'poke':
return '[窗口抖动]';
case 'flash_file': {
const title = data.title ? String(data.title) : '';
return title ? `[闪传文件:${truncate(title, 20)}]` : '[闪传文件]';
}
default:
return `[${type}]`;
}
}
function truncate(s: string, max: number): string {
return s.length > max ? `${s.slice(0, max)}...` : s;
}
/**
* Best-effort extraction of an ARK / mini-program / share card's title for the
* log preview. The OneBot json segment's `data.data` is the raw JSON string
* Tencent embeds in the lightApp element; its shape varies by card type, but
* the common locations for a human-readable headline are:
*
* - meta.detail_1.title — mini-program shares
* - meta.detail.title — news / image shares
* - meta.news.title — news shares (alt schema)
* - meta.{music,video,mannounce}.title — domain-specific shares
* - meta.<single-key>.title — generic single-section ARK
* - prompt — QQ's server-side fallback cards (e.g. cardId
* -896954183 "当前QQ版本不支持此应用,请升级")
* - desc — some richer cards
*
* Returns null on parse failure / no recognised field — caller falls back to
* "[JSON]". Pure best-effort: this is a log-preview hint, not a contract.
*/
function extractCardTitle(raw: unknown): string | null {
if (typeof raw !== 'string' || raw.length === 0) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null;
}
const root = parsed as Record<string, unknown>;
const meta = root.meta;
if (meta && typeof meta === 'object' && !Array.isArray(meta)) {
const m = meta as Record<string, unknown>;
const sections = ['detail_1', 'detail', 'news', 'mannounce', 'music', 'video'];
for (const key of sections) {
const t = readTitle(m[key]);
if (t) {
return t;
}
}
// Fallback: meta has exactly one section — use its title.
const keys = Object.keys(m);
if (keys.length === 1) {
const t = readTitle(m[keys[0]]);
if (t) {
return t;
}
}
}
if (typeof root.prompt === 'string' && root.prompt.trim()) {
return root.prompt.trim();
}
if (typeof root.desc === 'string' && root.desc.trim()) {
return root.desc.trim();
}
return null;
}
function readTitle(node: unknown): string | null {
if (!node || typeof node !== 'object' || Array.isArray(node)) {
return null;
}
const t = (node as Record<string, unknown>).title;
if (typeof t !== 'string') {
return null;
}
const trimmed = t.trim();
return trimmed.length > 0 ? trimmed : null;
}
/**
* Resolve a `reply` segment's referenced message and return a short
* "[回复 user: body...]" string. On miss (older than store retention,
* never seen, lookup throws) falls back to "[回复:<id>]" so the chain
* reference is at least preserved.
*
* Deliberately one level deep — we don't recurse into the reply's own
* reply. Long chains aren't worth the runtime cost in the hot path,
* and a 1-level peek catches 90% of real-world "what's this referring
* to" questions.
*/
export async function formatReply(
messageStore: ReplyEventLookup,
identity: IdentityService,
replyId: number,
): Promise<string> {
try {
if (!replyId) {
return '[回复:0]';
}
const event = await messageStore.findEvent(replyId);
if (!event) {
return `[回复:${replyId}]`;
}
const isGroup = event.message_type === 'group';
const uin = toIntOrZero(event.user_id);
const groupId = isGroup ? toIntOrZero(event.group_id) : undefined;
// Prefer cache; if cache miss, use the sender fields baked into the
// stored event itself (those came from the original receive path
// when the roster was warm).
let userPart = formatUser(identity, groupId, uin);
if (userPart === String(uin) && uin > 0) {
const sender =
typeof event.sender === 'object' &&
event.sender !== null &&
!Array.isArray(event.sender)
? (event.sender as JsonObject)
: {};
const fallback = ((sender.card as string) || (sender.nickname as string) || '').trim();
if (fallback) {
userPart = `[${fallback}(${uin})]`;
}
}
const body = truncate(
formatMessageSegments(event.message as JsonValue),
MAX_REPLY_BODY_PREVIEW,
);
return `[回复 ${userPart}: ${body}]`;
} catch {
return `[回复:${replyId}]`;
}
}
function toIntOrZero(v: unknown): number {
if (typeof v === 'number' && Number.isFinite(v)) {
return v;
}
if (typeof v === 'string') {
const n = Number.parseInt(v, 10);
return Number.isFinite(n) ? n : 0;
}
return 0;
}
/**
* Readable one-line rendering of a notice-class QQEventVariant. Returns
* null for kinds the OneBot layer's message log already renders
* (group_message / friend_message / temp_message / group_file_upload /
* friend_add — those have richer dedicated paths).
*/
export function formatEvent(identity: IdentityService, event: QQEventVariant): string | null {
try {
switch (event.kind) {
case 'group_message':
case 'friend_message':
case 'temp_message':
case 'group_file_upload':
case 'friend_add':
return null;
case 'group_recall':
return `群撤回 ${formatGroup(identity, event.groupId)} | ${formatUser(identity, event.groupId, event.authorUin)} 被 ${formatUser(identity, event.groupId, event.operatorUin)} 撤回`;
case 'friend_recall':
return `私聊撤回 ${formatUser(identity, undefined, event.userUin)} 撤回了消息`;
case 'group_member_join':
return `入群 ${formatUser(identity, event.groupId, event.userUin, event.userUid)} 加入 ${formatGroup(identity, event.groupId)}`;
case 'group_member_leave': {
const leaveAction =
event.leaveType === 'disband'
? '随群解散'
: event.leaveType === 'kick'
? '被踢出'
: '退出';
return `退群 ${formatUser(identity, event.groupId, event.userUin, event.userUid)} ${leaveAction} ${formatGroup(identity, event.groupId)}`;
}
case 'group_mute':
return `禁言 ${formatGroup(identity, event.groupId)} | ${formatUser(identity, event.groupId, event.userUin)} ${event.duration}秒`;
case 'group_admin':
return `管理 ${formatGroup(identity, event.groupId)} | ${formatUser(identity, event.groupId, event.userUin)} ${event.set ? '+' : '-'}管理员`;
case 'friend_poke':
return `私聊戳 ${formatUser(identity, undefined, event.peerUin)} | ${formatUser(identity, undefined, event.senderUin)} -> ${formatUser(identity, undefined, event.targetUin)}`;
case 'group_poke':
return `群戳 ${formatGroup(identity, event.groupId)} | ${formatUser(identity, event.groupId, event.userUin)} -> ${formatUser(identity, event.groupId, event.targetUin)}`;
case 'friend_request':
return `好友请求 ${formatUser(identity, undefined, event.fromUin)}: ${event.message}`;
case 'group_invite': {
const invitee =
event.invitedUin && event.invitedUin > 0
? ` 邀 ${formatUser(identity, event.groupId, event.invitedUin, event.invitedUid)}`
: '';
return `群邀请 ${formatUser(identity, undefined, event.fromUin)}${invitee} -> ${formatGroup(identity, event.groupId)}`;
}
case 'group_essence':
return `精华 ${formatGroup(identity, event.groupId)} | ${event.set ? '+' : '-'}精华`;
case 'group_msg_emoji_like':
return `表情回应 ${formatGroup(identity, event.groupId)} | ${formatUser(identity, event.groupId, event.operatorUin, event.operatorUid)} ${event.isAdd ? '+' : '-'}[${event.emojiId}] msgSeq=${event.msgSeq}`;
default:
return null;
}
} catch {
return null;
}
}
@@ -0,0 +1,142 @@
const PAIR_WINDOW_MS = 3_000;
const MAX_INVITES = 1_024;
interface Card {
groupId: number;
sequence: number;
}
interface SelfInviteNotice {
cardSequence?: number;
ambiguous?: boolean;
}
interface UnpairedInvite {
key: string;
notice?: SelfInviteNotice;
source: 'card' | 'notice';
expiresAt: number;
}
function remember<T>(entries: Map<string, T>, key: string, value: T): void {
entries.delete(key);
if (entries.size >= MAX_INVITES) {
const oldest = entries.keys().next();
if (!oldest.done) {
entries.delete(oldest.value);
}
}
entries.set(key, value);
}
/** Account-local state shared by incoming invitations and their approval actions. */
export class GroupInviteTracker {
private readonly cards = new Map<string, Card>();
private readonly notices = new Map<string, SelfInviteNotice>();
private unpaired: UnpairedInvite[] = [];
receiveNotice(groupId: number, uid: string): boolean {
const key = `${groupId}:${uid}`;
const notice: SelfInviteNotice = {};
if (this.pair(key, 'notice', notice)) {
return false;
}
// Earlier notices with the same legacy flag cannot lend this one their card.
remember(this.notices, key, notice);
return true;
}
receiveCard(groupId: number, sequence: number, uid?: string): boolean {
const key = `${groupId}:${sequence}`;
if (this.cards.has(key)) {
return false;
}
const paired = uid ? this.pair(`${groupId}:${uid}`, 'card') : undefined;
if (paired?.notice) {
paired.notice.cardSequence = sequence;
}
remember(this.cards, key, { groupId, sequence });
return !paired;
}
private pair(
key: string,
source: UnpairedInvite['source'],
notice?: SelfInviteNotice,
): UnpairedInvite | undefined {
const now = Date.now();
this.unpaired = this.unpaired.filter((entry) => entry.expiresAt > now);
const match = this.unpaired.findIndex(
(entry) => entry.key === key && entry.source !== source,
);
if (match !== -1) {
return this.unpaired.splice(match, 1)[0];
}
if (this.unpaired.length >= MAX_INVITES) {
this.unpaired.shift();
}
if (
notice &&
this.unpaired.some((entry) => entry.key === key && entry.source === 'notice')
) {
notice.ambiguous = true;
}
this.unpaired.push({ key, source, notice, expiresAt: now + PAIR_WINDOW_MS });
return undefined;
}
getSelfInviteNotice(groupId: number, uid: string): Readonly<SelfInviteNotice> | undefined {
const notice = this.notices.get(`${groupId}:${uid}`);
if (notice?.ambiguous) {
throw new Error('multiple pending self invitations share this flag');
}
return notice;
}
getCardSequence(groupId: number): number | undefined {
let sequence: number | undefined;
for (const card of this.cards.values()) {
if (card.groupId === groupId) {
sequence = card.sequence;
}
}
return sequence;
}
findCardGroup(sequence: number, groupId?: number): number | undefined {
if (groupId !== undefined) {
return this.cards.get(`${groupId}:${sequence}`)?.groupId;
}
let match: number | undefined;
for (const card of this.cards.values()) {
if (card.sequence === sequence) {
if (match !== undefined) {
throw new Error('multiple groups match this invitation card sequence');
}
match = card.groupId;
}
}
return match;
}
}
@@ -0,0 +1,80 @@
import { createLogger } from '@snowluma/common/logger';
import type { ResponseGrp, RichGroupCard } from '@snowluma/proto-defs/message';
import { protobuf_decode } from '@snowluma/proton';
const log = createLogger('Protocol.GroupMemberCard');
const utf8 = new TextDecoder('utf-8', { fatal: true });
/** Keep ordinary text whitespace, but never publish binary framing as a name. */
export function isBinaryCardText(text: string): boolean {
// eslint-disable-next-line no-control-regex -- These are the binary framing bytes we must detect.
return /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(text);
}
function displayText(bytes: Uint8Array): string {
let text: string;
try {
text = utf8.decode(bytes);
} catch (cause) {
throw new Error('group card contains invalid UTF-8', { cause });
}
if (isBinaryCardText(text)) {
throw new Error('group card text contains binary control characters');
}
return text;
}
export function decodeGroupMemberCard(bytes?: Uint8Array): string {
if (!bytes?.length) {
return '';
}
// Each part has tag 0x0a, but version/unknown fields can precede the parts.
if (!bytes.includes(0x0a)) {
return displayText(bytes);
}
let parts: RichGroupCard['parts'];
try {
parts = protobuf_decode<RichGroupCard>(bytes).parts;
} catch {
// Both formats occupy the same wire field. A newline in a plain name
// is also 0x0a; accept it only if the entire value is valid display text.
return displayText(bytes);
}
if (!parts?.length) {
return displayText(bytes);
}
// A control field, even an empty one, takes precedence over text in the same part.
const text = parts.flatMap((part) =>
part.control == null && part.text != null ? [part.text] : [],
);
return displayText(Buffer.concat(text));
}
/** A malformed optional display name must not discard the message itself. */
export function decodeGroupDisplayName(group?: ResponseGrp): string | undefined {
if (!group?.memberCard?.length) {
return undefined;
}
const bytes = group.memberCard;
try {
return group.memberCardType === 2 ? displayText(bytes) : decodeGroupMemberCard(bytes);
} catch (error) {
log.warn(
'invalid group message display name: group=%d bytes=%d reason=%s',
group.groupUin ?? 0,
bytes.length,
error instanceof Error ? error.message : String(error),
);
return undefined;
}
}
@@ -0,0 +1,54 @@
import { positiveIntEnv } from '@snowluma/common/env';
/** Default timeout for one HTTP media download attempt (ms). */
export const DEFAULT_HTTP_DOWNLOAD_TIMEOUT_MS = 60_000;
/** Default cap on one binary load that is buffered in memory (bytes). */
export const DEFAULT_MAX_BINARY_DOWNLOAD_BYTES = 1024 * 1024 * 1024;
/** Node's timer ceiling; a larger value would be silently clamped anyway. */
const MAX_TIMER_MS = 2_147_483_647;
/**
* Deliberate ceiling for the configurable cap. It is not a Node limit (Node 24
* reports `buffer.constants.MAX_LENGTH` as `Number.MAX_SAFE_INTEGER`); buffering a
* single download larger than this is not a realistic media source and would
* exhaust the process long before it completes.
*/
const MAX_BINARY_DOWNLOAD_BYTES = 4 * 1024 * 1024 * 1024;
/**
* Timeout for one HTTP media download attempt.
*
* Deployment-wide rather than per-adapter because every caller reaches this
* through the same download engine (`loadBinarySource` / `stageSourceToDisk`),
* and it is configured the same way as the other transport timeouts
* (`SNOWLUMA_PACKET_TIMEOUT_MS`, `SNOWLUMA_HOOK_ACK_TIMEOUT_MS`) instead of
* growing the per-account OneBot config surface.
*
* The 60 s default is generous for images but tight for a large media file over
* a slow link, where the abort is reported as a generic fetch failure.
*
* Not to be confused with `SNOWLUMA_DOWNLOAD_FILE_TIMEOUT_MS`, which bounds the
* `download_file` OneBot action's own staging.
*/
export function httpDownloadTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
return positiveIntEnv('SNOWLUMA_HTTP_DOWNLOAD_TIMEOUT_MS', DEFAULT_HTTP_DOWNLOAD_TIMEOUT_MS, {
env,
max: MAX_TIMER_MS,
});
}
/**
* Cap on one binary source loaded fully into memory.
*
* Applies to the helpers whose size bound is implicit (image, voice, avatar,
* custom face, group-notice image). Callers that pass their own ceiling — group
* files, flash transfer, video — keep it, so raising this never lifts those.
*/
export function maxBinaryDownloadBytes(env: NodeJS.ProcessEnv = process.env): number {
return positiveIntEnv('SNOWLUMA_MAX_BINARY_DOWNLOAD_BYTES', DEFAULT_MAX_BINARY_DOWNLOAD_BYTES, {
env,
max: MAX_BINARY_DOWNLOAD_BYTES,
});
}
@@ -0,0 +1,279 @@
import { errorMessage } from '@snowluma/common/errors';
import { resolveRuntimeNativePath } from '@snowluma/common/native-path';
import crypto from 'crypto';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { createLogger } from '@snowluma/common/logger';
const log = createLogger('Highway.Audio');
export interface FFmpegVideoInfo {
width: number;
height: number;
duration: number;
format: string;
videoCodec: string;
image: Buffer;
}
interface FFmpegNativeAddon {
convertFile(
inputFile: string,
outputFile: string,
format: string,
): Promise<{ success: boolean }>;
getVideoInfo(filePath: string, format?: 'bmp' | 'bmp24'): Promise<FFmpegVideoInfo>;
getDuration(filePath: string): Promise<number>;
convertToNTSilkTct(inputPath: string, outputPath: string): Promise<void>;
decodeAudioToPCM(
filePath: string,
pcmPath: string,
sampleRate?: number,
): Promise<{ result: boolean; sampleRate: number }>;
decodeAudioToFmt(
filePath: string,
outputPath: string,
format: string,
): Promise<{ result: boolean; channels: number; sampleRate: number; format: string }>;
}
let cachedAddon: FFmpegNativeAddon | null = null;
let cachedLoadError: string | null = null;
function addonFileName(): string {
return `ffmpegAddon.${process.platform}.${process.arch}.node`;
}
/**
* Load the ffmpegAddon once and return a cached instance.
* Throws on first-use failure; subsequent calls replay the same error message
* so callers don't silently fall back.
*/
export function getFFmpegAddon(): FFmpegNativeAddon {
if (cachedAddon) {
return cachedAddon;
}
if (cachedLoadError) {
throw new Error(cachedLoadError);
}
const addonPath = resolveRuntimeNativePath(path.join('ffmpeg', addonFileName()));
if (!addonPath) {
cachedLoadError = `ffmpegAddon not found for ${process.platform}-${process.arch} (looked for ${addonFileName()})`;
throw new Error(cachedLoadError);
}
try {
const mod = { exports: {} as Record<string, unknown> };
process.dlopen(mod, addonPath);
cachedAddon = mod.exports as unknown as FFmpegNativeAddon;
return cachedAddon;
} catch (error) {
cachedLoadError = `failed to load ffmpegAddon (${addonPath}): ${errorMessage(error)}`;
throw new Error(cachedLoadError);
}
}
/**
* Check whether `filePath` is already an NT SILK container. The addon can
* read any input directly so the only reason we peek at the header is to
* skip the conversion (and the temp file) when the caller already fed us
* a ready-to-upload silk file.
*/
export function isSilkFile(filePath: string): boolean {
const fd = fs.openSync(filePath, 'r');
try {
const buf = Buffer.alloc(10);
fs.readSync(fd, buf, 0, 10, 0);
return buf.toString().includes('#!SILK');
} finally {
fs.closeSync(fd);
}
}
export interface EncodeSilkResult {
/** Absolute path to the silk file ready for highway upload. */
path: string;
/** Voice duration in whole seconds (>= 1). */
duration: number;
/** `true` if we wrote a new silk file and the caller should unlink it. */
converted: boolean;
}
/**
* Convert `inputFile` to NT SILK when necessary and return the output path
* + duration. The caller is responsible for unlinking the returned path
* when `converted === true`.
*/
export async function encodeSilk(inputFile: string, tempDir: string): Promise<EncodeSilkResult> {
const addon = getFFmpegAddon();
const duration = Math.max(1, Math.round(await addon.getDuration(inputFile)));
if (isSilkFile(inputFile)) {
return { path: inputFile, duration, converted: false };
}
fs.mkdirSync(tempDir, { recursive: true });
const outPath = path.join(tempDir, crypto.randomUUID());
try {
await addon.convertToNTSilkTct(inputFile, outPath);
if (!fs.existsSync(outPath)) {
throw new Error('convertToNTSilkTct produced no output file');
}
} catch (error) {
try {
await fs.promises.rm(outPath, { force: true });
} catch (cleanupError) {
throw new AggregateError(
[error, cleanupError],
`SILK conversion failed: ${errorMessage(error)}; temporary file cleanup failed: ${errorMessage(cleanupError)}`,
);
}
throw error;
}
return { path: outPath, duration, converted: true };
}
/** Default location for temporary silk files. */
export function defaultPttTempDir(): string {
return path.join(os.tmpdir(), 'audio-upload');
}
// ── audio transcode (get_record out_format, #165) ──
// The addon is a custom ffmpeg build that bundles a SILK decoder, so it can
// transcode a QQ voice (SILK/AMR) into a normal container in one
// decodeAudioToFmt call — mirroring NapCat's FFmpegService.convertAudioFmt.
/** Output formats accepted by `out_format` (mirrors NapCat's allowlist). */
export const AUDIO_OUT_FORMATS = ['mp3', 'amr', 'wma', 'm4a', 'spx', 'ogg', 'wav', 'flac'] as const;
export type AudioOutFormat = (typeof AUDIO_OUT_FORMATS)[number];
export function isAudioOutFormat(s: string): s is AudioOutFormat {
return (AUDIO_OUT_FORMATS as readonly string[]).includes(s);
}
/** Minimal addon surface the transcode needs — lets tests inject a fake. */
type AudioConvertAddon = Pick<FFmpegNativeAddon, 'decodeAudioToFmt'>;
/** Default ceiling on the transcoded output read into memory for base64. SILK→
* WAV/FLAC is a decompressing direction, so cap it even though real voices are
* tiny. Generous; overridable via deps. */
const DEFAULT_MAX_AUDIO_OUTPUT = 256 * 1024 * 1024; // 256 MiB
const SILK_V3_SIGNATURE = Buffer.from('#!SILK_V3', 'ascii');
/**
* QQ AI voices use the same SILK payload as ordinary QQ voices but may carry
* a 0x03 container marker. The bundled decoder accepts the ordinary 0x02
* marker, so normalize this exact, observed container variant at the shared
* transcode boundary. Copy before rewriting to preserve caller-owned bytes.
*/
function normalizeQqAiSilkContainer(bytes: Uint8Array): Uint8Array {
if (bytes.length < SILK_V3_SIGNATURE.length + 1 || bytes[0] !== 0x03) {
return bytes;
}
for (let index = 0; index < SILK_V3_SIGNATURE.length; index += 1) {
if (bytes[index + 1] !== SILK_V3_SIGNATURE[index]) {
return bytes;
}
}
const normalized = Uint8Array.from(bytes);
normalized[0] = 0x02;
log.debug('normalized QQ AI SILK container for audio transcoding');
return normalized;
}
/**
* Transcode raw audio bytes (a QQ voice SILK/AMR) to `format`, returning the
* result as base64 + byte size. Writes the input + output to temp files (the
* native addon is file-based), always cleaning them up. `deps` injects a fake
* addon / tmp dir for tests. Throws on an unsupported format or a failed
* conversion (e.g. the binary lacks SILK decode).
*/
export async function convertAudioBytes(
bytes: Uint8Array,
format: string,
deps: { addon?: AudioConvertAddon; tmpDir?: string; maxOutputBytes?: number } = {},
): Promise<{ base64: string; size: number }> {
if (!isAudioOutFormat(format)) {
throw new Error(
`unsupported out_format: ${format} (expected one of ${AUDIO_OUT_FORMATS.join(', ')})`,
);
}
const addon = deps.addon ?? getFFmpegAddon();
const maxOut = deps.maxOutputBytes ?? DEFAULT_MAX_AUDIO_OUTPUT;
const dir = deps.tmpDir ?? path.join(os.tmpdir(), 'audio-transcode');
fs.mkdirSync(dir, { recursive: true });
const id = crypto.randomBytes(8).toString('hex');
const inPath = path.join(dir, `${id}.in`);
const outPath = path.join(dir, `${id}.${format}`);
let outcome: PromiseSettledResult<{ base64: string; size: number }>;
try {
await fs.promises.writeFile(inPath, normalizeQqAiSilkContainer(bytes));
await addon.decodeAudioToFmt(inPath, outPath, format);
if (!fs.existsSync(outPath)) {
throw new Error('audio conversion failed: no output file');
}
// Bound the output before reading it into memory (decompressing direction).
const stat = await fs.promises.stat(outPath);
if (stat.size > maxOut) {
throw new Error(`converted audio too large: ${stat.size} > ${maxOut}`);
}
const out = await fs.promises.readFile(outPath);
outcome = {
status: 'fulfilled',
value: { base64: out.toString('base64'), size: out.length },
};
} catch (reason) {
outcome = { status: 'rejected', reason };
}
// `force` makes missing files harmless. Any remaining rejection is a real
// permission/I/O failure and must stay observable instead of leaking voice
// data silently. Preserve the conversion error too when both phases fail.
const cleanupResults = await Promise.allSettled([
fs.promises.rm(inPath, { force: true }),
fs.promises.rm(outPath, { force: true }),
]);
const cleanupErrors = cleanupResults
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
.map((result) => result.reason);
if (cleanupErrors.length > 0) {
const cleanupMessage = cleanupErrors.map(errorMessage).join('; ');
if (outcome.status === 'rejected') {
throw new AggregateError(
[outcome.reason, ...cleanupErrors],
`audio conversion failed: ${errorMessage(outcome.reason)}; temporary audio file cleanup failed: ${cleanupMessage}`,
);
}
throw new AggregateError(
cleanupErrors,
`temporary audio file cleanup failed: ${cleanupMessage}`,
);
}
if (outcome.status === 'rejected') {
throw outcome.reason;
}
return outcome.value;
}
+173
View File
@@ -0,0 +1,173 @@
// Streaming file hasher for the disk-backed upload path (#stream-upload).
//
// The buffered path reads the whole file into RAM and calls
// `computeHashes(bytes)` + `computeVideoSha1Blocks(bytes)` (+ a head-md5 for
// offline files). This does the same in ONE sequential pass over the file,
// 1 MiB at a time, so a multi-GiB upload never buffers more than a chunk.
//
// It MUST be byte-for-byte identical to the buffered helpers — verified by
// `tests/highway/hash-file.test.ts` against `computeHashes` /
// `computeVideoSha1Blocks` / `computeMd5(head)` over the same bytes.
import { createHash } from 'crypto';
import { promises as fsp } from 'fs';
import { FileChunkSource } from './highway-client';
import { Sha1Stream } from './sha1-stream';
// Must match video-upload.ts's SHA1_STREAM_BLOCK_SIZE: the Highway video
// "block sha1" snapshots the running SHA1 state at every 1 MiB boundary.
const SHA1_STREAM_BLOCK_SIZE = 1024 * 1024;
export interface StreamedFileHashes {
md5: Uint8Array;
sha1: Uint8Array;
md5Hex: string;
sha1Hex: string;
/** Per-1 MiB intermediate SHA1 states (little-endian, un-finalized) plus a
* final entry that is the finalized whole-file SHA1 — the exact shape
* `computeVideoSha1Blocks` returns. Only meaningful for video; file uploads
* ignore it. */
sha1Blocks: Uint8Array[];
/** md5 of the first `headLimit` bytes — the offline-file `md510MCheckSum`.
* Present only when `headLimit` was passed. */
headMd5?: Uint8Array;
}
/**
* Hash a file on disk in a single streaming pass. Equivalent to
* `computeHashes` + `computeVideoSha1Blocks` (+ `computeMd5(bytes[0:headLimit])`
* when `headLimit` is given) over the file's bytes, without buffering the file.
*/
export async function hashFileStreaming(
filePath: string,
opts?: { headLimit?: number },
): Promise<StreamedFileHashes> {
const { size } = await fsp.stat(filePath);
const src = await FileChunkSource.open(filePath, size);
try {
const md5 = createHash('md5');
const sha1 = createHash('sha1');
const headLimit = opts?.headLimit;
const headMd5 = headLimit !== undefined ? createHash('md5') : null;
let headRemaining = headLimit ?? 0;
// Running SHA1 state fed only full 1 MiB chunks, snapshotted at each 1 MiB
// boundary — mirrors computeVideoSha1Blocks (which feeds 64-byte blocks and
// snapshots at 1 MiB; identical result since 1 MiB is a multiple of 64 and
// Sha1Stream buffers internally). The sub-1 MiB tail is never snapshotted,
// so it need not be fed to the block-sha1 at all.
const blockSha1 = new Sha1Stream();
const sha1Blocks: Uint8Array[] = [];
let offset = 0;
while (offset < size) {
const len = Math.min(SHA1_STREAM_BLOCK_SIZE, size - offset);
const chunk = await src.read(offset, len);
md5.update(chunk);
sha1.update(chunk);
if (headMd5 && headRemaining > 0) {
const take = Math.min(headRemaining, chunk.length);
headMd5.update(chunk.subarray(0, take));
headRemaining -= take;
}
if (len === SHA1_STREAM_BLOCK_SIZE) {
blockSha1.update(chunk);
sha1Blocks.push(blockSha1.hash(true)); // little-endian intermediate state
}
offset += len;
}
const sha1Digest = sha1.digest();
// Final entry: the finalized whole-file SHA1 — the last element
// computeVideoSha1Blocks pushes.
sha1Blocks.push(new Uint8Array(sha1Digest));
const md5Digest = md5.digest();
return {
md5: new Uint8Array(md5Digest),
sha1: new Uint8Array(sha1Digest),
md5Hex: md5Digest.toString('hex'),
sha1Hex: sha1Digest.toString('hex'),
sha1Blocks,
headMd5: headMd5 ? new Uint8Array(headMd5.digest()) : undefined,
};
} finally {
await src.close();
}
}
export interface StreamedFlashHashes {
md5: Uint8Array;
sha1: Uint8Array;
md5Hex: string;
sha1Hex: string;
/** Flash sliceupload Sha1StateV — un-finalized little-endian state after
* every full 1 MiB slice except the last; the last entry is the finalized
* whole-file SHA1. Same shape as `computeSha1StateV`, not `sha1Blocks`. */
sha1StateV: Uint8Array[];
sliceCount: number;
}
/**
* Streaming hash for flash-transfer sliceupload. Equivalent to
* `computeHashes` + `computeSha1StateV(bytes, ceil(size/1MiB), 1MiB)`
* without buffering the file.
*
* Do not substitute `hashFileStreaming().sha1Blocks`: when
* `size % 1 MiB === 0` that list has an extra un-finalized last-slice
* state that flash's Sha1StateV must not carry.
*/
export async function hashFlashFileStreaming(filePath: string): Promise<StreamedFlashHashes> {
const { size } = await fsp.stat(filePath);
const sliceCount = Math.ceil(size / SHA1_STREAM_BLOCK_SIZE);
const src = await FileChunkSource.open(filePath, size);
try {
const md5 = createHash('md5');
const sha1 = createHash('sha1');
const blockSha1 = new Sha1Stream();
const sha1StateV: Uint8Array[] = [];
let offset = 0;
let sliceIndex = 0;
while (offset < size) {
const len = Math.min(SHA1_STREAM_BLOCK_SIZE, size - offset);
const chunk = await src.read(offset, len);
md5.update(chunk);
sha1.update(chunk);
blockSha1.update(chunk);
if (sliceIndex !== sliceCount - 1) {
sha1StateV.push(blockSha1.hash(true));
}
offset += len;
sliceIndex += 1;
}
const sha1Digest = sha1.digest();
if (sliceCount > 0) {
sha1StateV.push(new Uint8Array(sha1Digest));
}
const md5Digest = md5.digest();
return {
md5: new Uint8Array(md5Digest),
sha1: new Uint8Array(sha1Digest),
md5Hex: md5Digest.toString('hex'),
sha1Hex: sha1Digest.toString('hex'),
sha1StateV,
sliceCount,
};
} finally {
await src.close();
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,233 @@
import { createLogger } from '@snowluma/common/logger';
import { protobuf_encode } from '@snowluma/proton';
import type { NotOnlineImagePbReserve, PicExtBizInfo } from '@snowluma/proto-defs/element';
import type { BridgeContext } from '../bridge-context';
import type { MessageElement } from '../events';
import { GROUP_IMAGE_CMD_ID, PRIVATE_IMAGE_CMD_ID } from './highway-client';
import {
finalizeMediaMsgInfo,
hexToBytes,
runNtv2Upload,
type MediaSubFileUpload,
} from './pipeline';
import { computeHashes, detectImageFormat, loadBinarySource } from './utils';
import { ntImageUrlFromMsgInfo } from '../msg-push/helpers';
const moduleLog = createLogger('Highway.Image');
function loggerFor(bridge: BridgeContext) {
const raw = bridge.identity?.uin;
const uin = typeof raw === 'string' ? Number.parseInt(raw, 10) : 0;
return Number.isFinite(uin) && uin > 0 ? moduleLog.child({ uin }) : moduleLog;
}
interface ImageData {
/** Empty when the caller is forwarding from cached fingerprints. */
bytes: Uint8Array;
md5: Uint8Array;
sha1: Uint8Array;
md5Hex: string;
sha1Hex: string;
fileName: string;
fileSize: number;
summary: string;
subType: number;
width: number;
height: number;
picFormat: number;
/** True when `bytes` is empty; runNtv2Upload throws fastOnlyError if
* the server demands the bytes anyway. */
fastOnly: boolean;
}
function imageDataFromFingerprint(element: MessageElement): ImageData {
return {
bytes: new Uint8Array(0),
md5: hexToBytes(element.md5Hex ?? ''),
sha1: hexToBytes(element.sha1Hex ?? ''),
md5Hex: element.md5Hex ?? '',
sha1Hex: element.sha1Hex ?? '',
fileName: element.fileName || `${element.md5Hex ?? 'image'}.jpg`,
fileSize: element.fileSize ?? 0,
// QQ ecosystem convention — mobile QQ + Lagrange.Core + NapCat all
// surface these exact Chinese strings as the chat-list bubble
// preview text. English fallbacks would render `[image]` /
// `[sticker]` in QQ users' chat lists instead of the expected
// `[图片]` / `[动画表情]`. Cross-checked against:
// dev/NapCatQQ/.../packet/message/element.ts:367-371
// dev/Lagrange.Core/.../Message/Entity/ImageEntity.cs:186-192
summary: element.summary || (element.subType === 1 ? '[动画表情]' : '[图片]'),
subType: element.subType ?? 0,
width: element.width ?? 0,
height: element.height ?? 0,
picFormat: element.picFormat ?? 1000,
fastOnly: true,
};
}
function loadImage(element: MessageElement): Promise<ImageData> {
if (element.noByteFallback) {
if (!element.md5Hex || !element.sha1Hex) {
return Promise.reject(new Error('image fast-upload requires md5Hex + sha1Hex'));
}
return Promise.resolve(imageDataFromFingerprint(element));
}
return loadImageFromSource(
element.url || element.imageUrl || element.fileId || '',
element.fileName ?? '',
element.subType ?? 0,
element.summary ?? '',
);
}
async function loadImageFromSource(
source: string,
fileName: string,
subType: number,
summary: string,
): Promise<ImageData> {
const loaded = await loadBinarySource(source, 'image');
const hashes = computeHashes(loaded.bytes);
const fmt = detectImageFormat(loaded.bytes);
const extMap: Record<number, string> = {
1000: '.jpg',
1001: '.png',
1002: '.webp',
1005: '.bmp',
2000: '.gif',
};
const ext = extMap[fmt.format] ?? '.jpg';
let finalName = fileName || loaded.fileName;
if (!finalName) {
finalName = hashes.md5Hex + ext;
}
return {
bytes: loaded.bytes,
md5: hashes.md5,
sha1: hashes.sha1,
md5Hex: hashes.md5Hex,
sha1Hex: hashes.sha1Hex,
fileName: finalName,
fileSize: loaded.bytes.length,
summary: summary || (subType === 1 ? '[动画表情]' : '[图片]'),
subType,
width: fmt.width,
height: fmt.height,
picFormat: fmt.format,
fastOnly: false,
};
}
/**
* Upload an image and return the encoded MsgInfo bytes for the outgoing
* commonElem.
*/
export async function uploadImageMsgInfo(
bridge: BridgeContext,
isGroup: boolean,
targetIdOrUid: string | number,
element: MessageElement,
tempGroupId?: number,
): Promise<Uint8Array> {
const log = loggerFor(bridge);
const image = await loadImage(element);
const pic: PicExtBizInfo = {
bizType: image.subType,
textSummary: image.summary,
...(isGroup
? { extData: { subType: image.subType, textSummary: image.summary } }
: {
bytesPbReserveC2c: protobuf_encode<NotOnlineImagePbReserve>({
subType: image.subType,
summary: image.summary,
}),
}),
};
log.debug(
'uploading %d bytes md5=%s... → %s %s%s',
image.fileSize,
image.md5Hex.slice(0, 8),
isGroup ? 'group' : tempGroupId !== undefined ? 'group-temp' : 'c2c',
String(targetIdOrUid),
tempGroupId !== undefined ? ` sourceGroup=${tempGroupId}` : '',
);
const uploads: MediaSubFileUpload[] = [
{
source: 'top',
cmdId: isGroup ? GROUP_IMAGE_CMD_ID : PRIVATE_IMAGE_CMD_ID,
bytes: image.bytes,
md5: image.md5,
sha1: image.sha1,
fastOnlyError: 'image fast-upload not available (server requires bytes)',
},
];
const upload = await runNtv2Upload({
bridge,
isGroup,
targetIdOrUid,
tempGroupId,
oidbCmd: isGroup ? 0x11c4 : 0x11c5,
serviceCmd: isGroup ? 'OidbSvcTrpcTcp.0x11c4_100' : 'OidbSvcTrpcTcp.0x11c5_100',
requestId: 1,
businessType: 1,
uploadInfo: [
{
fileInfo: {
fileSize: image.fileSize,
fileHash: image.md5Hex,
fileSha1: image.sha1Hex,
fileName: image.fileName,
type: { type: 1, picFormat: image.picFormat, videoFormat: 0, voiceFormat: 0 },
width: image.width,
height: image.height,
time: 0,
original: 1,
},
subFileType: 0,
},
],
compatQmsgSceneType: isGroup ? 2 : 1,
extBizInfo: {
pic,
video: { bytesPbReserve: new Uint8Array(0) },
ptt: {
bytesReserve: new Uint8Array(0),
bytesPbReserve: new Uint8Array(0),
bytesGeneralFlags: new Uint8Array(0),
},
},
uploads,
label: 'image',
});
const encoded = finalizeMediaMsgInfo(upload, pic);
const imageUrl = ntImageUrlFromMsgInfo(upload.msgInfo?.msgInfoBody?.[0], isGroup);
if (!imageUrl) {
throw new Error('image upload response is missing a download reference');
}
// Retain successful upload metadata for the caller's self-sent history.
Object.assign(element, {
imageUrl,
fileId: image.fileName,
md5Hex: image.md5Hex,
sha1Hex: image.sha1Hex,
fileSize: image.fileSize,
fileName: image.fileName,
width: image.width,
height: image.height,
picFormat: image.picFormat,
subType: image.subType,
summary: image.summary,
});
return encoded;
}
+447
View File
@@ -0,0 +1,447 @@
import { errorMessage } from '@snowluma/common/errors';
import { createLogger, renderTraceBytes, runWithTraceRequest } from '@snowluma/common/logger';
import type {
EncodableMediaMsgInfo,
HighwayMsgInfoBody,
NTV2ExtBizInfo,
NTV2UploadInfo,
NTV2UploadRespBody,
} from '@snowluma/proto-defs/highway';
import { protobuf_encode } from '@snowluma/proton';
import type { PicExtBizInfo } from '@snowluma/proto-defs/element';
import crypto from 'crypto';
import type { BridgeContext } from '../bridge-context';
import { OidbError } from '../oidb-service';
import { Ntv2UploadRequest } from '../oidb-services/highway/ntv2-upload-request';
import {
BufferChunkSource,
FileChunkSource,
buildHighwayExtend,
fetchHighwaySession,
uploadHighwayHttp,
} from './highway-client';
const moduleLog = createLogger('Highway');
// ─────────────── public types ───────────────
/**
* Highway upload spec for one sub-file of an NTV2 upload response.
* The server returns 0..N sub-files; for each we either fast-path (skip)
* or do an HTTP PUT.
*/
export interface MediaSubFileUpload {
/**
* Where to read uKey + ipv4s from on the OIDB response:
* 'top' -> `upload.uKey` + `upload.ipv4s` (main file)
* N (int) -> `upload.subFileInfos[N].uKey` + `.ipv4s` (sub-file)
*
* Image and PTT use 'top' only. Video uses 'top' (main) and 0 (thumb).
*/
source: 'top' | number;
/** Highway command id for this sub-file. */
cmdId: number;
/** Optional bounded parallel upload; only video bodies opt in. */
concurrency?: number;
/** Bytes to upload. Empty when the caller is forwarding from cached
* fingerprints; in that case set fastOnlyError so we throw with a
* typed message when the server actually demands the bytes. Also empty
* when `fileSource` is set (the bytes are streamed from disk instead). */
bytes: Uint8Array;
/** When set, this sub-file streams from a disk file instead of `bytes`
* (which should be empty). runPuts opens a `FileChunkSource`; all size /
* data-presence decisions use `fileSource.fileSize`, not `bytes.length`. */
fileSource?: { filePath: string; fileSize: number };
/** md5 used for the highway request. */
md5: Uint8Array;
/** sha1 — single buffer or per-1MB block array. Passed verbatim to
* buildHighwayExtend. */
sha1: Uint8Array | Uint8Array[];
/** subFileIndex argument for buildHighwayExtend. Defaults to 0;
* video thumb passes 1. */
subFileIndex?: number;
/** Error message to throw when uKey is present but `bytes.length === 0`.
* Omit if the caller guarantees bytes always exist (e.g. video thumb
* always has FALLBACK_THUMB bytes). */
fastOnlyError?: string;
}
export interface NtV2UploadParams {
bridge: BridgeContext;
isGroup: boolean;
/** Group uin when isGroup, otherwise the recipient's uid string. */
targetIdOrUid: string | number;
/** Existing passive group temp session; never creates or refreshes a session. */
tempGroupId?: number;
/** OIDB command id (e.g. 0x11C4 / 0x11C5 / 0x126E / 0x126D / 0x11EA / 0x11E9). */
oidbCmd: number;
/** Service cmd (e.g. 'OidbSvcTrpcTcp.0x11c4_100'). */
serviceCmd: string;
/** `reqHead.common.requestId`. NTV2 sub-protocols use different values
* here (image=1, video=3, ptt = group:1 / c2c:4). */
requestId: number;
/** `reqHead.scene.businessType` (1=image, 3=voice, 2=video). */
businessType: number;
/** `upload.uploadInfo` array. Each entry is `{ fileInfo, subFileType }`. */
uploadInfo: NTV2UploadInfo[];
/** `upload.compatQmsgSceneType`. */
compatQmsgSceneType: number;
/** `upload.extBizInfo` — type-specific bytes/flags. */
extBizInfo: NTV2ExtBizInfo;
/** Sub-file Highway PUTs to perform after the OIDB response. */
uploads: MediaSubFileUpload[];
/** Used in error messages. Defaults to 'media'. */
label?: string;
}
// ─────────────── helpers ───────────────
/**
* 8 random bytes masked into the positive int64 range so the resulting
* BigInt survives signed-int64 protobuf encoding without surprises.
* Mirrors NapCat.
*/
export function makeClientRandomId(): bigint {
const buf = crypto.randomBytes(8);
return buf.readBigUInt64BE() & 0x7fffffffffffffffn;
}
/** Hex string -> Uint8Array. Used by every format's fingerprint path. */
export function hexToBytes(hex: string): Uint8Array {
const clean = hex.length % 2 === 0 ? hex : '0' + hex;
const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(clean.substring(i * 2, i * 2 + 2), 16);
}
return out;
}
function sha1TraceValue(value: Uint8Array | Uint8Array[]): string {
const values = Array.isArray(value) ? value : [value];
return `[${values.map((item) => renderTraceBytes(item)).join(',')}]`;
}
function uploadDescriptors(uploads: MediaSubFileUpload[]): string {
return `[${uploads
.map((sub) =>
[
`source:${JSON.stringify(String(sub.source))}`,
`cmdId:${sub.cmdId}`,
`size:${sub.fileSource ? sub.fileSource.fileSize : sub.bytes.byteLength}`,
`storage:${JSON.stringify(sub.fileSource ? 'disk' : 'buffer')}`,
`md5:${renderTraceBytes(sub.md5)}`,
`sha1:${sha1TraceValue(sub.sha1)}`,
`subFileIndex:${sub.subFileIndex ?? 0}`,
].join(','),
)
.join(';')}]`;
}
// ─────────────── main entrypoint ───────────────
/**
* Send a NTV2UploadRichMediaReq and run any Highway PUTs the server asks
* for. Returns the decoded `upload` object so the caller can pass it to
* `finalizeMediaMsgInfo`.
*
* Sessions are cached across sub-file uploads — video does two PUTs but
* only fetches the Highway session once.
*/
export function runNtv2Upload(params: NtV2UploadParams): Promise<NTV2UploadRespBody> {
const startedAt = Date.now();
const label = params.label ?? 'media';
let failureReason = 'unexpected_failure';
let didPut = false;
return runWithTraceRequest(async () => {
moduleLog.trace(() => [
'highway_media_start label=%j scope=%s target=%j sourceGroup=%s oidbCmd=%d serviceCmd=%j requestId=%d businessType=%d uploads=%s',
label,
params.isGroup ? 'group' : params.tempGroupId !== undefined ? 'group-temp' : 'private',
String(params.targetIdOrUid),
params.tempGroupId === undefined ? '-' : String(params.tempGroupId),
params.oidbCmd,
params.serviceCmd,
params.requestId,
params.businessType,
uploadDescriptors(params.uploads),
]);
try {
const result = await runNtv2UploadOperation(params, (reason, put) => {
failureReason = reason;
if (put !== undefined) {
didPut = put;
}
});
moduleLog.trace(
'highway_media_terminal label=%j outcome=completed reason=%s uploads=%d elapsedMs=%d',
label,
didPut ? 'put_complete' : 'fast_upload',
params.uploads.length,
Date.now() - startedAt,
);
return result;
} catch (error) {
moduleLog.trace(() => [
'highway_media_terminal label=%j outcome=failed reason=%s uploads=%d error=%j elapsedMs=%d',
label,
failureReason,
params.uploads.length,
errorMessage(error),
Date.now() - startedAt,
]);
throw error;
}
});
}
async function runNtv2UploadOperation(
params: NtV2UploadParams,
setState: (failureReason: string, didPut?: boolean) => void,
): Promise<NTV2UploadRespBody> {
const { bridge, isGroup, targetIdOrUid, oidbCmd, uploads } = params;
const label = params.label ?? 'media';
const raw = bridge.identity?.uin;
const uinNum = typeof raw === 'string' ? Number.parseInt(raw, 10) : 0;
const log =
Number.isFinite(uinNum) && uinNum > 0 ? moduleLog.child({ uin: uinNum }) : moduleLog;
// Send one OIDB request and return its decoded `upload` body. `tryFast`
// toggles `tryFastUploadCompleted`: true asks the server to reuse a
// cached resource (skip the bytes); false forces it to allocate a fresh
// upload session and hand back a uKey. NOTE: proton only emits a plain
// `pb<bool>` when it's `true`, so `tryFast === false` omits field 2 —
// the server reads that as "don't fast-upload" (the opt-in default).
const requestUpload = async (tryFast: boolean): Promise<NTV2UploadRespBody> => {
setState('request_failed');
try {
return await Ntv2UploadRequest.invoke(bridge, {
oidbCmd,
isGroup,
targetIdOrUid,
tempGroupId: params.tempGroupId,
requestId: params.requestId,
businessType: params.businessType,
uploadInfo: params.uploadInfo,
compatQmsgSceneType: params.compatQmsgSceneType,
extBizInfo: params.extBizInfo,
tryFast,
clientRandomId: makeClientRandomId(),
label,
});
} catch (error) {
if (error instanceof OidbError) {
setState('oidb_rejected');
} else if (error instanceof Error && error.message.includes('missing msgInfo')) {
setState('response_invalid');
} else if (error instanceof Error && error.message.includes('upload failed')) {
setState('business_rejected');
}
log.trace(() => [
'highway_media_branch branch=oidb_response error=%j',
error instanceof Error ? error.message : String(error),
]);
throw error;
}
};
// Highway PUTs. Session is lazily fetched and cached — video does two
// PUTs (main + thumb) and shouldn't pay for two sessions, and a forced
// retry shouldn't re-fetch it either.
let session: Awaited<ReturnType<typeof fetchHighwaySession>> | null = null;
const getSession = async () => {
setState('session_failed');
session ??= await fetchHighwaySession(bridge);
return session;
};
// Run whatever PUTs the given `upload` response asks for.
const runPuts = async (upload: NTV2UploadRespBody): Promise<void> => {
const msgInfo = upload.msgInfo;
if (!msgInfo) {
throw new Error('upload response missing msgInfo');
}
let didPut = false;
for (const sub of uploads) {
const target = sub.source === 'top' ? upload : upload.subFileInfos?.[sub.source];
const uKey = target?.uKey ?? '';
// Data size / presence comes from fileSource (streamed) when set, else
// from the in-memory bytes — a streamed sub-file has empty `bytes`, so
// keying the checks below on `bytes.length` would wrongly treat it as a
// fast-only / empty sub-file.
const subSize = sub.fileSource ? sub.fileSource.fileSize : sub.bytes.length;
// No uKey: the server fast-pathed this sub-file — it already holds
// (or claims to hold) the resource, so there are no bytes to push.
if (!uKey) {
if (subSize > 0) {
log.trace(
'highway_media_branch branch=fast_upload source=%j cmdId=%d size=%d',
String(sub.source),
sub.cmdId,
subSize,
);
log.debug(
'%s fast-upload hit for sub=%s (server reusing cached resource)',
label,
String(sub.source),
);
}
continue;
}
if (subSize === 0) {
log.trace(
'highway_media_branch branch=empty_source source=%j cmdId=%d hasFastOnlyError=%s',
String(sub.source),
sub.cmdId,
Boolean(sub.fastOnlyError),
);
if (sub.fastOnlyError) {
setState('source_unavailable');
throw new Error(sub.fastOnlyError);
}
continue;
}
if (!target) {
continue;
}
log.trace(
'highway_media_branch branch=put_required source=%j cmdId=%d size=%d uKey=%j storage=%s',
String(sub.source),
sub.cmdId,
subSize,
uKey,
sub.fileSource ? 'disk' : 'buffer',
);
setState('extend_build_failed');
const extend = buildHighwayExtend(
uKey,
msgInfo,
target.ipv4s ?? [],
sub.sha1,
sub.subFileIndex ?? 0,
);
// Resolve the (lazy, cached) session BEFORE opening the ChunkSource, so
// there is no fallible await between opening the FileChunkSource handle
// and the uploadHighwayHttp call that owns closing it — otherwise a
// session-fetch failure on the first PUT would leak the open handle.
const putSession = await getSession();
// uploadHighwayHttp owns the ChunkSource and closes it exactly once.
setState('source_open_failed');
const chunkSource = sub.fileSource
? await FileChunkSource.open(sub.fileSource.filePath, sub.fileSource.fileSize)
: new BufferChunkSource(sub.bytes);
log.debug(
'%s OIDB requires bytes, PUT %d bytes (sub=%s)',
label,
subSize,
String(sub.source),
);
const t0 = Date.now();
setState('put_failed');
if (sub.concurrency === undefined) {
await uploadHighwayHttp(
bridge,
putSession,
sub.cmdId,
chunkSource,
sub.md5,
extend,
);
} else {
await uploadHighwayHttp(
bridge,
putSession,
sub.cmdId,
chunkSource,
sub.md5,
extend,
{ concurrency: sub.concurrency },
);
}
log.debug('%s PUT done in %dms', label, Date.now() - t0);
didPut = true;
setState('unexpected_failure', true);
}
if (!didPut) {
log.debug('%s fast-upload hit (server already had bytes)', label);
}
};
// NOTE: a previous attempt re-issued the request with
// `tryFastUploadCompleted: false` when the server fast-pathed the main video,
// on the theory it would force a fresh upload of a possibly-expired resource.
// Real-machine testing + kernel RE proved that ineffective — the server's
// fast-path is driven by md5-metadata existence and ignores the flag, and the
// QQ NT kernel itself does no validity check either (HandleRspUploadV3 trusts
// `fileExist` and reports success). An expired-but-still-indexed video
// resource is a platform-level limitation, not something the upload flow can
// refresh. See #145.
const upload = await requestUpload(true);
await runPuts(upload);
return upload;
}
// ─────────────── shared finalize ───────────────
/**
* Build the encoded MsgInfo bytes that go inside the outgoing commonElem.
*
* `requestedPic` carries image presentation for this send, including the
* scene-specific compatibility reserve. PTT and video omit it.
*/
export function finalizeMediaMsgInfo(
upload: NTV2UploadRespBody,
requestedPic?: PicExtBizInfo,
): Uint8Array {
if (!upload?.msgInfo) {
throw new Error('upload response missing msgInfo');
}
const msgInfoBody = (upload.msgInfo.msgInfoBody ?? []).map((b: HighwayMsgInfoBody) => ({
index: b.index,
picture: b.picture,
fileExist: b.fileExist,
hashSum: b.hashSum,
}));
const extBizInfo: NonNullable<EncodableMediaMsgInfo['extBizInfo']> = {};
if (upload.msgInfo.extBizInfo?.pic || requestedPic) {
// Upload responses can reuse cached image metadata. Presentation belongs
// to this send, so retain resource metadata but apply the requested style.
extBizInfo.pic = { ...upload.msgInfo.extBizInfo?.pic, ...requestedPic };
}
if (upload.msgInfo.extBizInfo?.video) {
extBizInfo.video = upload.msgInfo.extBizInfo.video;
}
if (upload.msgInfo.extBizInfo?.ptt) {
extBizInfo.ptt = upload.msgInfo.extBizInfo.ptt;
}
if (upload.msgInfo.extBizInfo?.busiType !== undefined) {
extBizInfo.busiType = upload.msgInfo.extBizInfo.busiType;
}
return protobuf_encode<EncodableMediaMsgInfo>({ msgInfoBody, extBizInfo });
}
+335
View File
@@ -0,0 +1,335 @@
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { createLogger } from '@snowluma/common/logger';
import type { BridgeContext } from '../bridge-context';
import type { MessageElement } from '../events';
import { defaultPttTempDir, encodeSilk, getFFmpegAddon } from './ffmpeg-addon';
import {
finalizeMediaMsgInfo,
hexToBytes,
runNtv2Upload,
type MediaSubFileUpload,
} from './pipeline';
import {
amplitudesFromPcmS16le,
encodePttWaveform,
mergePttWaveform,
pcmS16leFromWav,
} from './ptt-waveform';
import { computeHashes, loadBinarySource, resolveLocalFilePath } from './utils';
const moduleLog = createLogger('Highway.Ptt');
function loggerFor(bridge: BridgeContext) {
const raw = bridge.identity?.uin;
const uin = typeof raw === 'string' ? Number.parseInt(raw, 10) : 0;
return Number.isFinite(uin) && uin > 0 ? moduleLog.child({ uin }) : moduleLog;
}
export const PRIVATE_PTT_CMD_ID = 1007;
export const GROUP_PTT_CMD_ID = 1008;
interface PttPayload {
/** Silk bytes uploaded to Highway. Empty when forwarding from cached fingerprints. */
bytes: Uint8Array;
md5: Uint8Array;
sha1: Uint8Array;
md5Hex: string;
sha1Hex: string;
fileName: string;
fileSize: number;
/** Whole seconds, >= 1. */
duration: number;
voiceFormat: number;
/** True when bytes is empty; pipeline throws fastOnlyError if the
* server demands the bytes anyway. */
fastOnly: boolean;
/** Cleanup hooks for any temp silk files staged during loadPtt. */
cleanups: Array<() => void>;
/** Encoded `PttWaveform` for private-chat progress / peaks. Omitted on fingerprint. */
waveform?: Uint8Array;
}
function pttPayloadFromFingerprint(element: MessageElement): PttPayload {
return {
bytes: new Uint8Array(0),
md5: hexToBytes(element.md5Hex ?? ''),
sha1: hexToBytes(element.sha1Hex ?? ''),
md5Hex: element.md5Hex ?? '',
sha1Hex: element.sha1Hex ?? '',
fileName: element.fileName || `${element.md5Hex ?? 'record'}.amr`,
fileSize: element.fileSize ?? 0,
duration: element.duration ?? 1,
// Voice format: 1 = silk (NTV2 standard). Honour any fingerprint that
// declares a different format; legacy NTQQ paths all use 1.
voiceFormat: element.voiceFormat || 1,
fastOnly: true,
cleanups: [],
};
}
async function loadPtt(element: MessageElement, tempDir: string): Promise<PttPayload> {
if (element.noByteFallback) {
if (!element.md5Hex || !element.sha1Hex) {
throw new Error('record fast-upload requires md5Hex + sha1Hex');
}
return pttPayloadFromFingerprint(element);
}
const source = element.url || element.fileId || '';
if (!source) {
throw new Error('record source is empty');
}
const cleanups: Array<() => void> = [];
// Run all queued cleanups; called both on the success path (via the
// returned `cleanups` array) and on the failure path (try/catch below).
const runCleanups = () => {
while (cleanups.length > 0) {
const fn = cleanups.pop();
if (!fn) {
continue;
}
try {
fn();
} catch {
/* best-effort */
}
}
};
fs.mkdirSync(tempDir, { recursive: true });
try {
// Resolve the on-disk path the addon should read. Anything that isn't
// already a local file (base64, HTTP) gets staged into the temp dir.
let inputPath: string;
const local = resolveLocalFilePath(source);
if (local && fs.existsSync(local)) {
inputPath = local;
} else {
const loaded = await loadBinarySource(source, 'record');
inputPath = path.join(tempDir, `snowluma-ptt-in-${crypto.randomUUID()}`);
fs.writeFileSync(inputPath, Buffer.from(loaded.bytes));
cleanups.push(() => {
try {
fs.unlinkSync(inputPath);
} catch {
/* ignore */
}
});
}
const silk = await encodeSilk(inputPath, tempDir);
if (silk.converted) {
cleanups.push(() => {
try {
fs.unlinkSync(silk.path);
} catch {
/* ignore */
}
});
moduleLog.debug(
'converted record to silk: %s -> %s (duration=%ds)',
inputPath,
silk.path,
silk.duration,
);
}
const silkBytes = new Uint8Array(fs.readFileSync(silk.path));
if (silkBytes.length === 0) {
throw new Error('silk file is empty after conversion');
}
const hashes = computeHashes(silkBytes);
const waveform = await tryPttWaveform(silk.path, tempDir, cleanups);
return {
bytes: silkBytes,
md5: hashes.md5,
sha1: hashes.sha1,
md5Hex: hashes.md5Hex,
sha1Hex: hashes.sha1Hex,
fileName: `${hashes.md5Hex}.amr`,
fileSize: silkBytes.length,
duration: silk.duration,
voiceFormat: 1,
fastOnly: false,
cleanups: [...cleanups],
waveform,
};
} catch (err) {
runCleanups();
throw err;
}
}
async function tryPttWaveform(
silkPath: string,
tempDir: string,
cleanups: Array<() => void>,
): Promise<Uint8Array | undefined> {
const wavPath = path.join(tempDir, `snowluma-ptt-wave-${crypto.randomUUID()}.wav`);
cleanups.push(() => {
try {
fs.unlinkSync(wavPath);
} catch {
/* ignore */
}
});
try {
const decoded = await getFFmpegAddon().decodeAudioToFmt(silkPath, wavPath, 'wav');
if (!decoded.result || !fs.existsSync(wavPath)) {
return undefined;
}
const wav = new Uint8Array(fs.readFileSync(wavPath));
const { pcm, channels } = pcmS16leFromWav(wav);
return encodePttWaveform(amplitudesFromPcmS16le(pcm, { channels }));
} catch (err) {
moduleLog.debug(
'ptt waveform skipped: %s',
err instanceof Error ? err.message : String(err),
);
return undefined;
}
}
/**
* Upload a voice clip and return the encoded MsgInfo bytes that go inside
* a `commonElem { serviceType: 48, businessType: 22 }`.
*/
export async function uploadPtt(
bridge: BridgeContext,
isGroup: boolean,
targetIdOrUid: string | number,
element: MessageElement,
): Promise<{ msgInfo: Uint8Array; compatQMsg?: Uint8Array }> {
const log = loggerFor(bridge);
const tempDir = defaultPttTempDir();
const ptt = await loadPtt(element, tempDir);
log.debug(
'uploading %d bytes md5=%s... → %s %s duration=%ds',
ptt.fileSize,
ptt.md5Hex.slice(0, 8),
isGroup ? 'group' : 'c2c',
String(targetIdOrUid),
ptt.duration,
);
try {
const uploads: MediaSubFileUpload[] = [
{
source: 'top',
cmdId: isGroup ? GROUP_PTT_CMD_ID : PRIVATE_PTT_CMD_ID,
bytes: ptt.bytes,
md5: ptt.md5,
sha1: ptt.sha1,
fastOnlyError: 'record fast-upload not available (server requires bytes)',
},
];
const upload = await runNtv2Upload({
bridge,
isGroup,
targetIdOrUid,
oidbCmd: isGroup ? 0x126e : 0x126d,
serviceCmd: isGroup ? 'OidbSvcTrpcTcp.0x126e_100' : 'OidbSvcTrpcTcp.0x126d_100',
// NapCat uses requestId=1 for group / 4 for c2c. Mirror it.
requestId: isGroup ? 1 : 4,
businessType: 3,
uploadInfo: [
{
fileInfo: {
fileSize: ptt.fileSize,
fileHash: ptt.md5Hex,
fileSha1: ptt.sha1Hex,
fileName: ptt.fileName,
type: {
type: 3,
picFormat: 0,
videoFormat: 0,
voiceFormat: ptt.voiceFormat,
},
width: 0,
height: 0,
time: ptt.duration,
original: 0,
},
subFileType: 0,
},
],
compatQmsgSceneType: isGroup ? 2 : 1,
extBizInfo: {
// NapCat fills in a placeholder textSummary so the legacy compat
// QMsg path still has something to render. Mirror it.
pic: { textSummary: 'Nya~' },
video: { bytesPbReserve: new Uint8Array(0) },
ptt: {
bytesReserve: new Uint8Array([0x08, 0x00, 0x38, 0x00]),
bytesPbReserve: new Uint8Array(0),
// `bytesGeneralFlags` is the group blob lifted verbatim from NapCat
// (UploadGroupPtt.ts). Private chat must send **none**: with the
// NapCat c2c blob (UploadPrivatePtt.ts) present the receiving client
// draws the voice bubble without its progress bar, and the waveform
// we ship is silently dropped. Confirmed by A/B-ing this single field
// against an otherwise identical send (present → flat, absent → drawn).
...(isGroup
? {
bytesGeneralFlags: new Uint8Array([
0x9a, 0x01, 0x07, 0xaa, 0x03, 0x04, 0x08, 0x08, 0x12, 0x00,
]),
}
: {}),
...(ptt.waveform ? { waveform: ptt.waveform } : {}),
},
},
uploads,
label: 'ptt',
});
mergePttWaveform(upload, ptt.waveform);
const encoded = finalizeMediaMsgInfo(upload);
const mediaNode = upload.msgInfo?.msgInfoBody?.[0]?.index;
Object.assign(element, {
md5Hex: ptt.md5Hex,
sha1Hex: ptt.sha1Hex,
fileSize: ptt.fileSize,
fileName: ptt.fileName,
duration: ptt.duration,
voiceFormat: ptt.voiceFormat,
fileId: mediaNode?.fileUuid,
mediaNode,
});
return { msgInfo: encoded, compatQMsg: upload.compatQMsg };
} finally {
for (const fn of ptt.cleanups) {
try {
fn();
} catch {
/* best-effort cleanup */
}
}
}
}
export async function uploadPttMsgInfo(
bridge: BridgeContext,
isGroup: boolean,
targetIdOrUid: string | number,
element: MessageElement,
): Promise<Uint8Array> {
return (await uploadPtt(bridge, isGroup, targetIdOrUid, element)).msgInfo;
}
@@ -0,0 +1,207 @@
import type { PttWaveform } from '@snowluma/proto-defs/element';
import type { NTV2UploadRespBody } from '@snowluma/proto-defs/highway';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
/** Official receive-side default bar count (`ParsePttWave` fallback). */
export const PTT_WAVEFORM_BINS = 30;
/** Official default amplitude floor when a bin (or the whole clip) is silent. */
export const PTT_WAVEFORM_SILENCE = 25;
function u16le(bytes: Uint8Array, offset: number): number {
return bytes[offset]! | (bytes[offset + 1]! << 8);
}
function u32le(bytes: Uint8Array, offset: number): number {
return (
bytes[offset]! |
(bytes[offset + 1]! << 8) |
(bytes[offset + 2]! << 16) |
(bytes[offset + 3]! << 24)
);
}
function fourcc(bytes: Uint8Array, offset: number): string {
return String.fromCharCode(
bytes[offset]!,
bytes[offset + 1]!,
bytes[offset + 2]!,
bytes[offset + 3]!,
);
}
function s16le(bytes: Uint8Array, offset: number): number {
const raw = u16le(bytes, offset);
return raw >= 0x8000 ? raw - 0x10000 : raw;
}
/**
* Pull s16le PCM out of a WAV produced by `decodeAudioToFmt(..., 'wav')`.
* Rejects non-PCM / non-16-bit so a botched transcode cannot invent bars.
*/
export function pcmS16leFromWav(wav: Uint8Array): {
pcm: Uint8Array;
channels: number;
sampleRate: number;
} {
if (wav.length < 12) {
throw new Error('wav is truncated');
}
if (fourcc(wav, 0) !== 'RIFF' || fourcc(wav, 8) !== 'WAVE') {
throw new Error('wav is not a RIFF/WAVE file');
}
let offset = 12;
let channels = 0;
let sampleRate = 0;
let bitsPerSample = 0;
let audioFormat = 0;
let data: Uint8Array | undefined;
while (offset + 8 <= wav.length) {
const id = fourcc(wav, offset);
const size = u32le(wav, offset + 4);
const start = offset + 8;
if (start + size > wav.length) {
throw new Error('wav chunk is truncated');
}
if (id === 'fmt ') {
if (size < 16) {
throw new Error('wav fmt chunk is short');
}
audioFormat = u16le(wav, start);
channels = u16le(wav, start + 2);
sampleRate = u32le(wav, start + 4);
bitsPerSample = u16le(wav, start + 14);
} else if (id === 'data') {
data = wav.subarray(start, start + size);
}
offset = start + size + (size & 1);
}
if (!data) {
throw new Error('wav has no data chunk');
}
if (audioFormat !== 1) {
throw new Error(`wav format ${audioFormat} is not PCM`);
}
if (channels < 1) {
throw new Error('wav channel count is invalid');
}
if (bitsPerSample !== 16) {
throw new Error(`wav bits ${bitsPerSample} is not s16`);
}
return { pcm: data, channels, sampleRate };
}
function silentWaveform(bins: number): Uint8Array {
return new Uint8Array(bins).fill(PTT_WAVEFORM_SILENCE);
}
/**
* Peak each window of interleaved s16le frames into official-length bars.
* Quiet windows stay 0 (valleys). A fully silent clip uses the official floor
* so private-chat clients still draw a progress track.
*/
export function amplitudesFromPcmS16le(
pcm: Uint8Array,
options: { channels?: number; bins?: number } = {},
): Uint8Array {
const channels = options.channels ?? 1;
const bins = options.bins ?? PTT_WAVEFORM_BINS;
if (channels < 1 || bins < 1) {
throw new Error('waveform channels/bins must be >= 1');
}
const frameSize = channels * 2;
const frames = Math.floor(pcm.length / frameSize);
if (frames === 0) {
return silentWaveform(bins);
}
const out = new Uint8Array(bins);
let any = false;
for (let bin = 0; bin < bins; bin += 1) {
const start = Math.floor((bin * frames) / bins);
let end = Math.floor(((bin + 1) * frames) / bins);
if (end <= start) {
end = start + 1;
}
let peak = 0;
for (let frame = start; frame < end && frame < frames; frame += 1) {
const base = frame * frameSize;
for (let channel = 0; channel < channels; channel += 1) {
const sample = Math.abs(s16le(pcm, base + channel * 2));
if (sample > peak) {
peak = sample;
}
}
}
const amplitude = Math.min(255, Math.round((peak * 255) / 32767));
if (amplitude > 0) {
any = true;
}
out[bin] = amplitude;
}
return any ? out : silentWaveform(bins);
}
/** Encode amplitudes as the blob `ParsePttWave` accepts (count == bytes). */
export function encodePttWaveform(amplitudes: Uint8Array): Uint8Array {
if (amplitudes.length === 0) {
throw new Error('ptt waveform amplitudes are empty');
}
return protobuf_encode<PttWaveform>({
size: amplitudes.length,
amplitudes,
});
}
export function decodePttWaveform(bytes: Uint8Array): { size: number; amplitudes: Uint8Array } {
const decoded = protobuf_decode<PttWaveform>(bytes);
const amplitudes = decoded.amplitudes ?? new Uint8Array(0);
const size = decoded.size ?? 0;
if (size !== amplitudes.length) {
throw new Error(`ptt waveform count ${size} != amplitudes ${amplitudes.length}`);
}
return { size, amplitudes };
}
/** Stamp computed waveform onto the upload response so finalize cannot drop it. */
export function mergePttWaveform(
upload: NTV2UploadRespBody,
waveform: Uint8Array | undefined,
): void {
if (!waveform || waveform.length === 0 || !upload.msgInfo) {
return;
}
const ext = upload.msgInfo.extBizInfo ?? {};
upload.msgInfo.extBizInfo = {
...ext,
ptt: { ...ext.ptt, waveform },
};
}
@@ -0,0 +1,156 @@
// 流式 SHA1 计算器,支持输出中间 state(不 finalize)。
// 闪传 sliceupload 的 f107.f6 (Sha1StateV) 需要每片对应的累积 SHA1 中间 state,
// Node.js 内置 crypto 只能算完整 digest,这里自行实现 transform 以暴露中间 state。
import { createHash } from 'node:crypto';
const SHA1_BLOCK_SIZE = 64;
const SHA1_DIGEST_SIZE = 20;
function rotl(x: number, n: number): number {
return ((x << n) | (x >>> (32 - n))) >>> 0;
}
export class Sha1Stream {
private readonly state = new Uint32Array(5);
private readonly count = new Uint32Array(2); // [low, high] bit count
private readonly buffer = new Uint8Array(SHA1_BLOCK_SIZE);
constructor() {
this.reset();
}
reset(): void {
this.state[0] = 0x67452301;
this.state[1] = 0xefcdab89;
this.state[2] = 0x98badcfe;
this.state[3] = 0x10325476;
this.state[4] = 0xc3d2e1f0;
this.count[0] = 0;
this.count[1] = 0;
}
private transform(data: Uint8Array, offset: number): void {
const w = new Uint32Array(80);
const dv = new DataView(data.buffer, data.byteOffset + offset, 64);
for (let i = 0; i < 16; i++) {
w[i] = dv.getUint32(i * 4, false);
} // big-endian
for (let i = 16; i < 80; i++) {
w[i] = rotl(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1);
}
let a = this.state[0],
b = this.state[1],
c = this.state[2],
d = this.state[3],
e = this.state[4];
for (let i = 0; i < 80; i++) {
let temp: number;
if (i < 20) {
temp = ((b & c) | (~b & d)) + 0x5a827999;
} else if (i < 40) {
temp = (b ^ c ^ d) + 0x6ed9eba1;
} else if (i < 60) {
temp = ((b & c) | (b & d) | (c & d)) + 0x8f1bbcdc;
} else {
temp = (b ^ c ^ d) + 0xca62c1d6;
}
temp = (temp + rotl(a, 5) + e + w[i]) >>> 0;
e = d;
d = c;
c = rotl(b, 30);
b = a;
a = temp;
}
this.state[0] = (this.state[0] + a) >>> 0;
this.state[1] = (this.state[1] + b) >>> 0;
this.state[2] = (this.state[2] + c) >>> 0;
this.state[3] = (this.state[3] + d) >>> 0;
this.state[4] = (this.state[4] + e) >>> 0;
}
update(data: Uint8Array): void {
const len = data.length;
let index = (this.count[0] >>> 3) & 0x3f;
this.count[0] = (this.count[0] + (len << 3)) >>> 0;
if (this.count[0] < len << 3) {
this.count[1] = (this.count[1] + 1) >>> 0;
}
this.count[1] = (this.count[1] + (len >>> 29)) >>> 0;
let partLen = SHA1_BLOCK_SIZE - index;
let i = 0;
if (len >= partLen) {
this.buffer.set(data.subarray(0, partLen), index);
this.transform(this.buffer, 0);
i = partLen;
while (i + SHA1_BLOCK_SIZE <= len) {
this.transform(data, i);
i += SHA1_BLOCK_SIZE;
}
index = 0;
}
this.buffer.set(data.subarray(i), index);
}
/** 输出当前 state(20B)。littleEndian=true 小端,false 大端。不 finalize。 */
hash(littleEndian: boolean): Uint8Array {
const digest = new Uint8Array(SHA1_DIGEST_SIZE);
const dv = new DataView(digest.buffer);
for (let i = 0; i < 5; i++) {
if (littleEndian) {
dv.setUint32(i * 4, this.state[i], true);
} else {
dv.setUint32(i * 4, this.state[i], false);
}
}
return digest;
}
}
/**
* 计算闪传 sliceupload 的 Sha1StateV(累积 SHA1 state list)。
* states[i] = SHA1 中间 state(从初始 state 处理 file[0:(i+1)*sliceSize] 后,无 finalize,小端 20B)。
* 最后一片 states[last] = 标准整文件 SHA1(有 finalize)。
*
* 用单个 Sha1Stream 增量 update:每片只喂新增的 sliceSize 字节再快照,O(n)。
* (sliceSize=1MB 是 SHA1 块大小 64B 的整数倍,增量 update 不会残留半块。)
*/
export function computeSha1StateV(
bytes: Uint8Array,
sliceCount: number,
sliceSize: number,
): Uint8Array[] {
const states: Uint8Array[] = [];
const sha1 = new Sha1Stream();
for (let i = 0; i < sliceCount; i++) {
const start = i * sliceSize;
const end = Math.min(start + sliceSize, bytes.length);
sha1.update(bytes.subarray(start, end));
if (i !== sliceCount - 1) {
states.push(sha1.hash(true)); // 小端中间 state,不 finalize
} else {
// 最后一片:标准整文件 SHA1(有 finalize)
states.push(new Uint8Array(createHash('sha1').update(Buffer.from(bytes)).digest()));
}
}
return states;
}
+347
View File
@@ -0,0 +1,347 @@
// Stage any upload source onto a local disk path so the uploader can stream it
// (#stream-upload). Unifies the three source kinds into one "here is a file on
// disk + its size + a cleanup" contract, so the streaming hash pass and the
// FileChunkSource PUT never have to care where the bytes came from:
//
// file:// / raw path → hardlink into our own stage dir (O(1); the extra inode
// link pins the bytes so a caller-side retention/unlink
// — e.g. upload_file_stream's timer — can't delete the
// data mid-send). Copy fallback on any link() failure.
// http(s):// → stream the download to a .part file, fsync, atomic
// rename (shares the exact transport of loadBinarySource
// via downloadHttp — same UA/redirect/timeout/retry/caps).
// base64:// / data: → enforce maxBytes on the DECODED length, then decode to
// a .part file and rename.
//
// Crash safety: staged temps are written `.part` → fsync → rename → fsync(dir),
// so a partial file is never consumed; a background TTL reaper reaps temps left
// by a crash, skipping any path currently in use (the `active` set), so it can
// never delete an in-flight upload.
import { promises as fsp } from 'fs';
import os from 'os';
import path from 'path';
import { randomUUID } from 'crypto';
import { createLogger } from '@snowluma/common/logger';
import {
downloadHttp,
inlineBase64Payload,
resolveLocalFilePath,
type DownloadSink,
} from './utils';
const log = createLogger('Highway.Stage');
export const STAGE_DIR = path.join(os.tmpdir(), 'media-stage');
// Must comfortably exceed the slowest plausible single upload (a 1.5 GiB PUT at
// a few MB/s is ~5-10 min). Only unregistered (crash-orphaned) temps are reaped.
const STAGE_TTL_MS = 30 * 60 * 1000;
const REAPER_INTERVAL_MS = 5 * 60 * 1000;
/** Absolute paths currently in use (being written, or a live staged file). The
* reaper never touches these — an in-flight upload's file is read-only so its
* mtime won't advance, and an age-only sweep would otherwise delete it. */
const active = new Set<string>();
export interface StagedSource {
/** Local disk path the uploader reads from. */
filePath: string;
fileSize: number;
/** Caller-facing name (URL basename / original local basename); '' for base64. */
fileName: string;
/** Remove the staged temp (our hardlink/copy/downloaded file). Idempotent,
* best-effort; never throws. Never touches the caller's original file. */
cleanup(): Promise<void>;
}
let reaperTimer: NodeJS.Timeout | null = null;
function ensureReaper(): void {
if (reaperTimer) {
return;
}
reaperTimer = setInterval(() => {
void sweepStagedUploads();
}, REAPER_INTERVAL_MS);
reaperTimer.unref(); // never pin the event loop
void sweepStagedUploads(); // startup sweep of crash orphans
}
/** Reap staged temps older than the TTL, skipping any path currently in use.
* Best-effort; never throws. Exposed for an explicit startup/interval hook. */
export async function sweepStagedUploads(now: number = Date.now()): Promise<number> {
let removed = 0;
let names: string[];
try {
names = await fsp.readdir(STAGE_DIR);
} catch {
return 0; // dir not created yet
}
await Promise.all(
names.map(async (n) => {
const p = path.join(STAGE_DIR, n);
if (active.has(p)) {
return;
} // in-flight — never reap
try {
const s = await fsp.stat(p);
if (now - s.mtimeMs > STAGE_TTL_MS) {
await fsp.rm(p, { force: true });
removed += 1;
}
} catch {
/* vanished / racing cleanup */
}
}),
);
if (removed > 0) {
log.debug('reaped %d stale staged upload temp(s)', removed);
}
return removed;
}
function tempPaths(ext: string): { partPath: string; finalPath: string } {
const id = randomUUID();
const base = path.join(STAGE_DIR, `stage-${id}${ext}`);
return { partPath: `${base}.part`, finalPath: base };
}
/** fsync the file's directory entry so the rename survives a crash (POSIX). */
async function fsyncDir(): Promise<void> {
try {
const dh = await fsp.open(STAGE_DIR, 'r');
try {
await dh.sync();
} finally {
await dh.close();
}
} catch {
/* some platforms reject directory fsync — best effort */
}
}
async function finalizeRename(partPath: string, finalPath: string): Promise<void> {
await fsp.rename(partPath, finalPath);
await fsyncDir();
}
function registerStaged(filePath: string, fileSize: number, fileName: string): StagedSource {
active.add(filePath);
let done = false;
return {
filePath,
fileSize,
fileName,
async cleanup() {
if (done) {
return;
}
done = true;
active.delete(filePath);
await fsp.unlink(filePath).catch(() => {
/* best-effort */
});
},
};
}
/** Disk sink for `downloadHttp`: streams chunks to a FileHandle, fsyncs on
* success, and closes-before-unlink on failure. Backpressure comes from
* awaiting each `fh.write`. */
function fileSink(partPath: string): DownloadSink<void> {
let opened: Promise<import('fs').promises.FileHandle> | null = null;
const open = () => (opened ??= fsp.open(partPath, 'w'));
return {
async write(chunk) {
const fh = await open();
await fh.write(chunk);
},
async done() {
const fh = await open(); // create even for an empty body
await fh.sync();
await fh.close();
},
async discard() {
if (opened) {
try {
const fh = await opened;
await fh.close();
} catch {
/* already closed */
}
}
await fsp.unlink(partPath).catch(() => {
/* best-effort */
});
},
};
}
/** base64 decoded byte length without allocating the buffer. */
function base64DecodedLength(b64: string): number {
const clean = b64.replace(/[^A-Za-z0-9+/=]/g, '');
const pad = clean.endsWith('==') ? 2 : clean.endsWith('=') ? 1 : 0;
return Math.floor((clean.length * 3) / 4) - pad;
}
/**
* Stage `source` onto a local disk path. `maxBytes` bounds the size (rejected
* before any large allocation for base64, before reading for local files, and
* incrementally for http). The returned `cleanup()` MUST be called when the
* upload is done (or fails) to remove the staged temp.
*/
export async function stageSourceToDisk(source: string, maxBytes: number): Promise<StagedSource> {
if (!source) {
throw new Error('stage source is empty');
}
ensureReaper();
await fsp.mkdir(STAGE_DIR, { recursive: true });
// ── inline Base64 (`base64://` or RFC 2397 `data:`) ────────────────────
const b64 = inlineBase64Payload(source);
if (b64 !== null) {
const decodedLen = base64DecodedLength(b64);
if (decodedLen > maxBytes) {
throw new Error(`stage source too large: ${decodedLen} > ${maxBytes}`);
}
const bytes = Buffer.from(b64, 'base64');
if (bytes.length > maxBytes) {
throw new Error(`stage source too large: ${bytes.length} > ${maxBytes}`);
}
const { partPath, finalPath } = tempPaths('.bin');
active.add(partPath);
try {
const fh = await fsp.open(partPath, 'w');
try {
await fh.write(bytes);
await fh.sync();
} finally {
await fh.close();
}
await finalizeRename(partPath, finalPath);
} catch (e) {
await fsp.unlink(partPath).catch(() => {
/* best-effort */
});
throw e;
} finally {
active.delete(partPath);
}
log.debug('staged inline base64 source (%d bytes)', bytes.length);
return registerStaged(finalPath, bytes.length, '');
}
// ── http(s):// ─────────────────────────────────────────────────────────
if (/^https?:\/\//i.test(source)) {
const { partPath, finalPath } = tempPaths('.bin');
active.add(partPath);
let fileName = '';
try {
const r = await downloadHttp<void>(source, 'stage', maxBytes, () => fileSink(partPath));
fileName = r.fileName;
await finalizeRename(partPath, finalPath);
} catch (e) {
await fsp.unlink(partPath).catch(() => {
/* best-effort */
});
throw e;
} finally {
active.delete(partPath);
}
const { size } = await fsp.stat(finalPath);
log.debug('staged http source %s (%d bytes)', fileName || '(unnamed)', size);
return registerStaged(finalPath, size, fileName);
}
// ── local path / file:// ───────────────────────────────────────────────
const local = resolveLocalFilePath(source);
if (!local) {
throw new Error('stage source is not a local file');
}
const real = await fsp.realpath(local); // resolve symlinks — hardlink the target, not the link
const st = await fsp.stat(real);
if (!st.isFile()) {
throw new Error(`stage source is not a regular file: ${local}`);
}
if (st.size > maxBytes) {
throw new Error(`stage source too large: ${st.size} > ${maxBytes}`);
}
const fileName = path.basename(local); // caller-facing name (before symlink resolution)
const { partPath, finalPath } = tempPaths(path.extname(local) || '.bin');
// Reserve finalPath in `active` BEFORE it exists on disk. A hardlink inherits
// the source's (often old) mtime, so — unlike the fresh-mtime http/base64/copy
// temps whose TTL saves them during the pre-register gap — only the active-set
// can keep the age reaper from deleting this live staged file in the window
// between link and registerStaged.
active.add(finalPath);
let staged: 'link' | 'copy';
try {
try {
await fsp.link(real, finalPath); // O(1) hardlink; pins the inode against caller-side unlink
staged = 'link';
} catch {
// Any link() failure (cross-fs EXDEV, hardlink protection, permissions,
// link-count/platform limits) — fall back to a streamed copy of the
// readable source (disk-to-disk, never buffered in RAM).
active.add(partPath);
try {
await fsp.copyFile(real, partPath);
const fh = await fsp.open(partPath, 'r+'); // fsync copied data before rename
try {
await fh.sync();
} finally {
await fh.close();
}
await finalizeRename(partPath, finalPath);
staged = 'copy';
} finally {
active.delete(partPath);
}
}
} catch (e) {
active.delete(finalPath);
await fsp.unlink(finalPath).catch(() => {
/* best-effort */
});
await fsp.unlink(partPath).catch(() => {
/* best-effort */
});
throw e;
}
log.debug('staged local source %s via %s (%d bytes)', fileName, staged, st.size);
return registerStaged(finalPath, st.size, fileName);
}
@@ -0,0 +1,79 @@
import type { BridgeContext } from '../bridge-context';
import type { HighwayServer } from './highway-client';
const SUCCESS_TTL_MS = 5 * 60_000;
const FAILURE_TTL_MS = 30_000;
const MAX_OBSERVATIONS = 128;
interface Observation {
succeeded: boolean;
expiresAt: number;
}
/** Short-lived scheduling evidence, never credentials or additional upload destinations. */
export class UploadNodeHistory {
private readonly observations = new Map<string, Observation>();
private key(commandId: number, server: HighwayServer): string {
return JSON.stringify([commandId, server.host, server.port]);
}
private prune(now: number): void {
for (const [key, observation] of this.observations) {
if (observation.expiresAt <= now) {
this.observations.delete(key);
}
}
}
order(commandId: number, candidates: readonly HighwayServer[]): HighwayServer[] {
this.prune(Date.now());
const unique = new Map(candidates.map((server) => [this.key(commandId, server), server]));
const rank = (server: HighwayServer): number => {
const observation = this.observations.get(this.key(commandId, server));
return observation ? (observation.succeeded ? 0 : 2) : 1;
};
// Stable ties retain server-provided order. Failed endpoints remain eligible:
// a temporary observation must not permanently exclude the only working node.
return [...unique.values()].sort((a, b) => rank(a) - rank(b));
}
record(commandId: number, server: HighwayServer, succeeded: boolean): void {
const now = Date.now();
this.prune(now);
const key = this.key(commandId, server);
this.observations.delete(key);
this.observations.set(key, {
succeeded,
expiresAt: now + (succeeded ? SUCCESS_TTL_MS : FAILURE_TTL_MS),
});
if (this.observations.size > MAX_OBSERVATIONS) {
const oldest = this.observations.keys().next();
if (!oldest.done) {
this.observations.delete(oldest.value);
}
}
}
}
// IdentityService belongs to one account's Bridge. Weak ownership also releases
// observations when that account session is discarded, without a global UIN registry.
const histories = new WeakMap<
BridgeContext['identity'],
{ uin: string; history: UploadNodeHistory }
>();
export function uploadNodeHistory(identity: BridgeContext['identity']): UploadNodeHistory {
let entry = histories.get(identity);
if (!entry || entry.uin !== identity.uin) {
entry = { uin: identity.uin, history: new UploadNodeHistory() };
histories.set(identity, entry);
}
return entry.history;
}
@@ -0,0 +1,48 @@
/** Deployment-wide policy. Each video snapshots it before preparing its bytes. */
let videoUploadConcurrency = 1;
export interface HighwayUploadTimeouts {
idleMs: number;
totalMs: number;
}
/** Snapshot once per file; a deployment change must not alter an in-flight upload. */
export function highwayUploadTimeouts(env: NodeJS.ProcessEnv = process.env): HighwayUploadTimeouts {
const read = (name: string, defaultMs: number): number => {
const raw = env[name];
if (raw === undefined) {
return defaultMs;
}
const value = raw.trim();
const parsed = /^[1-9]\d*$/.test(value) ? Number(value) : Number.NaN;
if (!Number.isSafeInteger(parsed) || parsed > 2_147_483_647) {
throw new RangeError(`${name} must be an integer from 1 to 2147483647 milliseconds`);
}
return parsed;
};
return {
idleMs: read('SNOWLUMA_HIGHWAY_UPLOAD_IDLE_TIMEOUT_MS', 30_000),
totalMs: read('SNOWLUMA_HIGHWAY_UPLOAD_TIMEOUT_MS', 30 * 60_000),
};
}
export function validateUploadConcurrency(value: unknown): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 4) {
throw new RangeError('videoUploadConcurrency must be an integer from 1 to 4');
}
return value;
}
export function setVideoUploadConcurrency(value: number): void {
videoUploadConcurrency = validateUploadConcurrency(value);
}
export function getVideoUploadConcurrency(): number {
return videoUploadConcurrency;
}
+560
View File
@@ -0,0 +1,560 @@
import { createHash } from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import {
DEFAULT_MAX_BINARY_DOWNLOAD_BYTES,
httpDownloadTimeoutMs,
maxBinaryDownloadBytes,
} from './download-settings';
export interface LoadedBinary {
bytes: Uint8Array;
fileName: string;
}
/**
* Default cap on a single binary load issued through this helper. Callers
* that route here (image / voice via base64+HTTP, group/private file
* uploads, avatar) get bounded reads. Not all callers route through here
* — in particular `video-upload.stageVideoSource` reads local-video files
* directly and enforces its own cap via `MAX_VIDEO_SIZE`. Group/private
* file uploads override this via `FILE_UPLOAD_MAX_BYTES` because QQ's
* file protocol legitimately supports up to 4 GiB.
*
* The effective default is `SNOWLUMA_MAX_BINARY_DOWNLOAD_BYTES` (see
* `download-settings`); this constant is the documented 1 GiB floor reused by
* the static flash-transfer ceiling below.
*/
const DEFAULT_MAX_BINARY_SIZE = DEFAULT_MAX_BINARY_DOWNLOAD_BYTES;
/** Hard ceiling QQ's file protocol supports — used by group/private files. */
export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024 * 1024; // 4 GiB
/** Local / HTTP flash sources. Official fileset max is unknown; reuse the
* group/private file ceiling so create_flash_task is not stuck on
* `loadBinarySource`'s 1 GiB default. */
export const FLASH_TRANSFER_MAX_BYTES = FILE_UPLOAD_MAX_BYTES;
/** Inline `base64://` / `data:` flash sources still decode into RAM inside
* `stageSourceToDisk`, so they keep the 1 GiB buffered-load ceiling. */
export const FLASH_TRANSFER_INLINE_MAX_BYTES = DEFAULT_MAX_BINARY_SIZE;
/**
* Browser-like User-Agent for remote media downloads. Many image / CDN
* hosts (and anti-hotlink front-ends) reject or RST a header-less,
* non-browser request — which surfaces as undici `TypeError: fetch
* failed` rather than a clean 403. Sending a normal browser UA on every
* request (and retrying with a Referer when the first try is refused) is
* what lets NapCat fetch sources our bare `fetch(source)` couldn't.
* Cross-checked against
* `dev/napcatQQInside/packages/napcat-common/src/file.ts:101-142,359-369`.
*/
const DOWNLOAD_USER_AGENT =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
export interface ImageHashes {
md5: Uint8Array;
sha1: Uint8Array;
md5Hex: string;
sha1Hex: string;
}
export interface ImageFormat {
format: number; // 1000=jpg, 1001=png, 1002=webp, 1005=bmp, 2000=gif
width: number;
height: number;
}
// --- Binary source loading ---
/**
* Return the encoded payload for an inline Base64 source.
*
* OneBot historically accepts the shorthand `base64://...`; media elements
* also accept RFC 2397 Data URLs such as `data:audio/webm;base64,...`. Keep
* their classification in one place so Data URLs can never fall through to
* the local-file path.
*/
export function inlineBase64Payload(source: string): string | null {
if (/^base64:\/\//i.test(source)) {
return source.slice(9);
}
if (!/^data:/i.test(source)) {
return null;
}
const comma = source.indexOf(',');
if (comma < 0) {
throw new Error('data URL source is missing its payload separator');
}
const metadata = source.slice(5, comma);
if (!/;base64$/i.test(metadata)) {
throw new Error('data URL source must use base64 encoding');
}
return source.slice(comma + 1);
}
export function resolveLocalFilePath(source: string): string | null {
if (!source) {
return null;
}
if (/^(?:base64:\/\/|data:)/i.test(source)) {
return null;
}
if (/^https?:\/\//i.test(source)) {
return null;
}
let filePath = source;
if (/^file:\/\//i.test(source)) {
try {
filePath = fileURLToPath(source);
} catch {
filePath = source.replace(/^file:\/+/i, '/');
try {
filePath = decodeURIComponent(filePath);
} catch {
// Keep the original fallback path when percent decoding fails.
}
}
if (process.platform !== 'win32' && filePath.startsWith('//')) {
filePath = filePath.replace(/^\/+/, '/');
}
}
// Windows-style paths can arrive from file:///C:/... as /C:/...
if (/^\/[a-zA-Z]:/.test(filePath)) {
filePath = filePath.slice(1);
}
return filePath;
}
/**
* Tag a size-limit error so the HTTP retry path leaves it alone — retrying
* a too-large response just re-downloads the same oversized body.
*/
export function tooLarge(message: string): Error {
return Object.assign(new Error(message), { noRetry: true });
}
/**
* Where a download's bytes go. `loadBinarySource` uses an in-memory sink;
* `stageSourceToDisk` uses a disk-file sink. A fresh sink is built per fetch
* attempt and `discard()`ed on failure so a Referer retry starts clean.
*/
export interface DownloadSink<T> {
write(chunk: Uint8Array): Promise<void> | void;
done(): Promise<T> | T;
discard(): Promise<void> | void;
}
/**
* Shared HTTP download engine for both the buffered and the disk-streaming
* paths — the single owner of the observable download behavior: browser UA +
* Accept, redirect follow, the configurable download timeout, incremental
* `maxBytes` enforcement (declared Content-Length AND streamed total,
* `noRetry`-tagged), and one Referer retry on a non-size failure. Bytes are
* delivered chunk-by-chunk to a caller-provided sink so the same
* length-checking transport feeds either RAM or disk.
*/
export async function downloadHttp<T>(
source: string,
resourceName: string,
maxBytes: number,
makeSink: () => DownloadSink<T>,
): Promise<{ result: T; fileName: string }> {
const fileName = guessFileNameFromUrl(source);
const attempt = async (headers: Record<string, string>): Promise<T> => {
const resp = await fetch(source, {
headers,
redirect: 'follow',
signal: AbortSignal.timeout(httpDownloadTimeoutMs()),
});
if (!resp.ok) {
throw new Error(`HTTP download failed: ${resp.status}`);
}
const declared = Number(resp.headers.get('content-length') ?? '0');
if (Number.isFinite(declared) && declared > maxBytes) {
throw tooLarge(`${resourceName} too large: ${declared} > ${maxBytes}`);
}
const sink = makeSink();
try {
const reader = resp.body?.getReader();
if (!reader) {
const buf = new Uint8Array(await resp.arrayBuffer());
if (buf.length > maxBytes) {
throw tooLarge(`${resourceName} too large: ${buf.length} > ${maxBytes}`);
}
await sink.write(buf);
} else {
let total = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (!value) {
continue;
}
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel().catch(() => {
/* ignore */
});
throw tooLarge(`${resourceName} too large: > ${maxBytes}`);
}
await sink.write(value);
}
} finally {
reader.releaseLock();
}
}
return await sink.done();
} catch (e) {
await sink.discard();
throw e;
}
};
// First try with a browser UA only. On any non-size failure — a
// network-level `fetch failed` (connection reset by an anti-bot front-end)
// or a 403/4xx from anti-hotlink — retry once with a Referer pointing at the
// resource itself, the common bypass for same-origin hotlink checks.
const baseHeaders: Record<string, string> = {
'User-Agent': DOWNLOAD_USER_AGENT,
Accept: '*/*',
};
try {
return { result: await attempt(baseHeaders), fileName };
} catch (err) {
if ((err as { noRetry?: boolean } | null)?.noRetry) {
throw err;
}
try {
return { result: await attempt({ ...baseHeaders, Referer: source }), fileName };
} catch {
throw err;
}
}
}
export async function loadBinarySource(
source: string,
resourceName: string,
maxBytes: number = maxBinaryDownloadBytes(),
): Promise<LoadedBinary> {
if (!source) {
throw new Error(`${resourceName} source is empty`);
}
const inlinePayload = inlineBase64Payload(source);
if (inlinePayload !== null) {
const bytes = Buffer.from(inlinePayload, 'base64');
if (bytes.length > maxBytes) {
throw new Error(`${resourceName} too large: ${bytes.length} > ${maxBytes}`);
}
return { bytes, fileName: '' };
}
if (/^https?:\/\//i.test(source)) {
// Buffered sink: accumulate the streamed chunks and assemble them into one
// contiguous buffer. The shared `downloadHttp` owns the transport, the
// incremental length check, and the Referer retry.
const memorySink = (): DownloadSink<Uint8Array> => {
const chunks: Uint8Array[] = [];
let total = 0;
return {
write(chunk) {
chunks.push(chunk);
total += chunk.byteLength;
},
done() {
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return bytes;
},
discard() {
chunks.length = 0;
},
};
};
const { result, fileName } = await downloadHttp(source, resourceName, maxBytes, memorySink);
return { bytes: result, fileName };
}
const filePath = resolveLocalFilePath(source);
if (!filePath) {
throw new Error(`${resourceName} source is not a local file`);
}
const stat = fs.statSync(filePath);
if (stat.size > maxBytes) {
throw new Error(`${resourceName} too large: ${stat.size} > ${maxBytes}`);
}
const bytes = fs.readFileSync(filePath);
const fileName = path.basename(filePath);
return { bytes, fileName };
}
export function guessFileNameFromUrl(url: string): string {
const queryPos = url.search(/[?#]/);
const pathPart = queryPos >= 0 ? url.slice(0, queryPos) : url;
const lastSlash = pathPart.lastIndexOf('/');
const raw = lastSlash >= 0 ? pathPart.slice(lastSlash + 1) : '';
if (!raw) {
return '';
}
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
// --- Hashing ---
export function computeHashes(data: Uint8Array): ImageHashes {
const md5 = createHash('md5').update(data).digest();
const sha1 = createHash('sha1').update(data).digest();
return {
md5: new Uint8Array(md5),
sha1: new Uint8Array(sha1),
md5Hex: md5.toString('hex'),
sha1Hex: sha1.toString('hex'),
};
}
export function computeMd5(data: Uint8Array): Uint8Array {
return new Uint8Array(createHash('md5').update(data).digest());
}
// --- Image format detection (port of C++ detect_image_format) ---
function readBE16(data: Uint8Array, offset: number): number {
return (data[offset] << 8) | data[offset + 1];
}
function readBE32(data: Uint8Array, offset: number): number {
return (
((data[offset] << 24) |
(data[offset + 1] << 16) |
(data[offset + 2] << 8) |
data[offset + 3]) >>>
0
);
}
function readLE16(data: Uint8Array, offset: number): number {
return data[offset] | (data[offset + 1] << 8);
}
function readLE32(data: Uint8Array, offset: number): number {
return (
(data[offset] |
(data[offset + 1] << 8) |
(data[offset + 2] << 16) |
(data[offset + 3] << 24)) >>>
0
);
}
function readLE24(data: Uint8Array, offset: number): number {
return data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16);
}
export function detectImageFormat(bytes: Uint8Array): ImageFormat {
let width = 0;
let height = 0;
// PNG
if (
bytes.length >= 24 &&
bytes[0] === 0x89 &&
bytes[1] === 0x50 &&
bytes[2] === 0x4e &&
bytes[3] === 0x47
) {
width = readBE32(bytes, 16);
height = readBE32(bytes, 20);
return { format: 1001, width, height };
}
// GIF
if (
bytes.length >= 10 &&
bytes[0] === 0x47 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x38 &&
(bytes[4] === 0x37 || bytes[4] === 0x39) &&
bytes[5] === 0x61
) {
width = readLE16(bytes, 6);
height = readLE16(bytes, 8);
return { format: 2000, width, height };
}
// BMP
if (bytes.length >= 26 && bytes[0] === 0x42 && bytes[1] === 0x4d) {
width = readLE32(bytes, 18);
height = readLE32(bytes, 22);
return { format: 1005, width, height };
}
// WebP
if (
bytes.length >= 30 &&
bytes[0] === 0x52 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x46 &&
bytes[8] === 0x57 &&
bytes[9] === 0x45 &&
bytes[10] === 0x42 &&
bytes[11] === 0x50
) {
if (bytes[12] === 0x56 && bytes[13] === 0x50 && bytes[14] === 0x38 && bytes[15] === 0x20) {
width = readLE16(bytes, 26);
height = readLE16(bytes, 28);
return { format: 1002, width, height };
}
if (bytes[12] === 0x56 && bytes[13] === 0x50 && bytes[14] === 0x38 && bytes[15] === 0x4c) {
const bits = readLE32(bytes, 21);
width = (bits & 0x3fff) + 1;
height = ((bits >> 14) & 0x3fff) + 1;
return { format: 1002, width, height };
}
if (bytes[12] === 0x56 && bytes[13] === 0x50 && bytes[14] === 0x38 && bytes[15] === 0x58) {
// VP8X canvas width/height are 24-bit LITTLE-endian (Minus-One). Reading
// them big-endian inflated dims to absurd values (e.g. 1920×1080 →
// 8324865×3605505), cropping the QQ thumbnail. (issue #112)
width = readLE24(bytes, 24) + 1;
height = readLE24(bytes, 27) + 1;
return { format: 1002, width, height };
}
}
// JPEG
if (bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
let offset = 2;
while (offset + 9 <= bytes.length) {
if (bytes[offset] !== 0xff) {
offset++;
continue;
}
const marker = bytes[offset + 1];
if (marker === 0xd8 || marker === 0xd9) {
offset += 2;
continue;
}
if (offset + 4 > bytes.length) {
break;
}
const segLen = readBE16(bytes, offset + 2);
if (segLen < 2 || offset + 2 + segLen > bytes.length) {
break;
}
// Real SOF markers: 0xC0..0xCF EXCEPT 0xC4 (DHT), 0xC8 (JPG), 0xCC
// (DAC). The previous `(marker & 0xFC) === 0xC0` check accepted DHT
// and friends, then misread Huffman table bytes as image dimensions.
const isSof =
marker >= 0xc0 &&
marker <= 0xcf &&
marker !== 0xc4 &&
marker !== 0xc8 &&
marker !== 0xcc;
if (isSof && segLen >= 7 && offset + 9 <= bytes.length) {
height = readBE16(bytes, offset + 5);
width = readBE16(bytes, offset + 7);
return { format: 1000, width, height };
}
offset += 2 + segLen;
}
return { format: 1000, width: 0, height: 0 };
}
return { format: 1000, width: 0, height: 0 };
}
// --- Highway frame packing/unpacking ---
export function packHighwayFrame(head: Uint8Array, body: Uint8Array): Uint8Array {
const frame = new Uint8Array(9 + head.length + body.length + 1);
frame[0] = 0x28;
const dv = new DataView(frame.buffer, frame.byteOffset);
dv.setUint32(1, head.length, false);
dv.setUint32(5, body.length, false);
frame.set(head, 9);
frame.set(body, 9 + head.length);
frame[frame.length - 1] = 0x29;
return frame;
}
export function unpackHighwayFrame(frame: Uint8Array): { head: Uint8Array; body: Uint8Array } {
if (frame.length < 10 || frame[0] !== 0x28 || frame[frame.length - 1] !== 0x29) {
throw new Error('invalid highway response frame');
}
const dv = new DataView(frame.buffer, frame.byteOffset);
const headLen = dv.getUint32(1, false);
const bodyLen = dv.getUint32(5, false);
return {
head: frame.subarray(9, 9 + headLen),
body: frame.subarray(9 + headLen, 9 + headLen + bodyLen),
};
}
@@ -0,0 +1,504 @@
import { getVideoUploadConcurrency } from './upload-settings';
import crypto from 'crypto';
import fs from 'fs';
import { promises as fsp } from 'fs';
import { createLogger } from '@snowluma/common/logger';
import type { BridgeContext } from '../bridge-context';
import type { MessageElement } from '../events';
import { getFFmpegAddon } from './ffmpeg-addon';
import {
finalizeMediaMsgInfo,
hexToBytes,
runNtv2Upload,
type MediaSubFileUpload,
} from './pipeline';
import { computeHashes, detectImageFormat, loadBinarySource, resolveLocalFilePath } from './utils';
import { stageSourceToDisk } from './stage';
import { hashFileStreaming } from './hash-file';
import { Sha1Stream } from './sha1-stream';
const moduleLog = createLogger('Highway.Video');
function loggerFor(bridge: BridgeContext) {
const raw = bridge.identity?.uin;
const uin = typeof raw === 'string' ? Number.parseInt(raw, 10) : 0;
return Number.isFinite(uin) && uin > 0 ? moduleLog.child({ uin }) : moduleLog;
}
export const PRIVATE_VIDEO_CMD_ID = 1001;
export const PRIVATE_VIDEO_THUMB_CMD_ID = 1002;
export const GROUP_VIDEO_CMD_ID = 1005;
export const GROUP_VIDEO_THUMB_CMD_ID = 1006;
// Videos up to 1.5 GiB send through the Highway video path (matches the
// file-upload ceiling). Real-machine verified up to ~500 MB rendering and
// playing fine as a video; the old 100 MB cap existed only to dodge the
// width/height=0 → 已过期 bug (fixed above), not any real QQ size limit.
// Above this the OneBot layer re-routes to the file pipeline. NOTE: the
// whole video is buffered in RAM here (fs.readFileSync), so a 1.5 GiB send
// costs ~1.5 GiB+ of process memory.
export const MAX_VIDEO_SIZE = 1536 * 1024 * 1024;
const SHA1_STREAM_BLOCK_SIZE = 1024 * 1024;
const SHA1_BLOCK_SIZE = 64;
export function getVideoSourceSize(element: MessageElement): number | null {
if (element.fileSize && element.fileSize > 0) {
return element.fileSize;
}
const source = element.url || element.fileId || '';
if (!source) {
return null;
}
const local = resolveLocalFilePath(source);
if (local && fs.existsSync(local)) {
return fs.statSync(local).size;
}
return null;
}
const FALLBACK_THUMB = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAtAAAAUACAIAAADhrPJqAAAACXBIWXMAAAABAAAAAQBPJcTWAAAQAElEQVR4nO3WQQkAMAzAwOqYf6FVEQblTkGemQcAEJvfAQDAfYYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOEAAHKGAwDIGQ4AIGc4AICc4QAAcoYDAMgZDgAgZzgAgJzhAAByhgMAyBkOACBnOACAnOLine truncated
'base64',
);
// [#145] The fast-upload (fingerprint / forward) path has no video bytes to
// probe, so every field it ships is caller-supplied and untrusted — a video
// that arrived on the wire with no metadata caches 0s, and re-forwarding it
// with width/height=0 makes QQ-NT receivers render 文件已过期. FALLBACK_THUMB is
// a real 720x1280 cover; we derive the fallback video/cover dimensions FROM it
// so the declared video size, the declared cover size and the actual cover
// pixels are the same numbers by construction — never a 1x1 image lying about
// its size. Real cached dimensions always win over these.
const FALLBACK_THUMB_FORMAT = detectImageFormat(new Uint8Array(FALLBACK_THUMB));
const FAST_UPLOAD_FALLBACK_WIDTH = FALLBACK_THUMB_FORMAT.width || 720;
const FAST_UPLOAD_FALLBACK_HEIGHT = FALLBACK_THUMB_FORMAT.height || 1280;
interface VideoPayload {
/** Main-file bytes. Empty on the normal path (streamed from `fileSource`)
* and when forwarding from cached fingerprints (fastOnly). */
bytes: Uint8Array;
/** Normal path: the staged video streams from this disk file instead of
* buffering `bytes`. Absent for the fingerprint fast path. */
fileSource?: { filePath: string; fileSize: number };
md5: Uint8Array;
sha1: Uint8Array;
sha1Blocks: Uint8Array[];
md5Hex: string;
sha1Hex: string;
fileName: string;
fileSize: number;
width: number;
height: number;
duration: number;
videoFormat: number;
thumb: ThumbPayload;
/** When true, video bytes are empty; pipeline throws fastOnlyError
* for the main file if the server demands the bytes. The thumb is
* always present (FALLBACK_THUMB at worst) so its sub-file uploads
* normally regardless. */
fastOnly: boolean;
/** Baseline stat of the staged file captured before hashing; re-checked
* before the upload to fail cleanly if the source was mutated mid-send.
* Absent for the fingerprint fast path. */
guard?: { size: number; mtimeMs: number };
/** Release the staged temp (hardlink / download). Best-effort, idempotent. */
cleanup: () => Promise<void>;
}
function makeFallbackThumb(): ThumbPayload {
const bytes = new Uint8Array(FALLBACK_THUMB);
const hashes = computeHashes(bytes);
return {
bytes,
md5: hashes.md5,
sha1: hashes.sha1,
md5Hex: hashes.md5Hex,
sha1Hex: hashes.sha1Hex,
// Declared cover dims == FALLBACK_THUMB's real pixels (same source as the
// video fallback), so content and declared size always agree.
width: FAST_UPLOAD_FALLBACK_WIDTH,
height: FAST_UPLOAD_FALLBACK_HEIGHT,
};
}
function videoPayloadFromFingerprint(element: MessageElement): VideoPayload {
return {
bytes: new Uint8Array(0),
md5: hexToBytes(element.md5Hex ?? ''),
sha1: hexToBytes(element.sha1Hex ?? ''),
sha1Blocks: [],
md5Hex: element.md5Hex ?? '',
sha1Hex: element.sha1Hex ?? '',
fileName: element.fileName || `${element.md5Hex ?? 'video'}.mp4`,
fileSize: element.fileSize ?? 0,
// Zero-trust on the fast-upload path (no bytes to probe): `|| fallback` not
// `??`, so a cached-but-zero value is replaced too. width/height=0 → the
// receiver shows 文件已过期; duration=0 → every client renders 00:00 (#145).
width: element.width || FAST_UPLOAD_FALLBACK_WIDTH,
height: element.height || FAST_UPLOAD_FALLBACK_HEIGHT,
duration: element.duration || 1,
videoFormat: element.videoFormat ?? 0,
thumb: makeFallbackThumb(),
fastOnly: true,
cleanup: async () => {
/* nothing staged */
},
};
}
interface ThumbPayload {
bytes: Uint8Array;
md5: Uint8Array;
sha1: Uint8Array;
md5Hex: string;
sha1Hex: string;
width: number;
height: number;
}
// ─────────────── 1MB-block sha1 (Highway main-video extend) ───────────────
// Highway expects the sha1 of each 1 MB block (intermediate un-finalized state,
// little-endian) plus the overall sha1. Reuses the well-tested streaming
// Sha1Stream for the block states and Node crypto for the overall digest (same
// split as computeSha1StateV), replacing a ~110-line hand-rolled, untested
// SHA1 duplicate.
export function computeVideoSha1Blocks(bytes: Uint8Array): Uint8Array[] {
const sha1 = new Sha1Stream();
const blocks: Uint8Array[] = [];
let bytesRead = 0;
let offset = 0;
while (offset + SHA1_BLOCK_SIZE <= bytes.length) {
sha1.update(bytes.subarray(offset, offset + SHA1_BLOCK_SIZE));
offset += SHA1_BLOCK_SIZE;
bytesRead += SHA1_BLOCK_SIZE;
if (bytesRead % SHA1_STREAM_BLOCK_SIZE === 0) {
blocks.push(sha1.hash(true)); // little-endian intermediate state
}
}
// Overall SHA1 (finalized) via Node crypto — the reference impl.
blocks.push(new Uint8Array(crypto.createHash('sha1').update(Buffer.from(bytes)).digest()));
return blocks;
}
// ─────────────── thumb extraction ───────────────
async function loadThumb(
element: MessageElement,
videoPath: string,
): Promise<{
thumb: ThumbPayload;
width: number;
height: number;
duration: number;
}> {
let width = element.width ?? 0;
let height = element.height ?? 0;
let duration = element.duration ?? 0;
let thumbBytes: Uint8Array | null = null;
if (element.thumbUrl) {
try {
thumbBytes = (await loadBinarySource(element.thumbUrl, 'video thumbnail')).bytes;
} catch (err) {
moduleLog.warn(
'custom video thumbnail load failed: %s',
err instanceof Error ? err.message : String(err),
);
}
}
if (!thumbBytes) {
try {
const info = await getFFmpegAddon().getVideoInfo(videoPath);
width = width || info.width || 0;
height = height || info.height || 0;
duration = duration || Math.max(1, Math.round(info.duration || 0));
if (info.image && info.image.length > 0) {
thumbBytes = new Uint8Array(info.image);
}
} catch (err) {
moduleLog.warn(
'video thumbnail generation failed: %s',
err instanceof Error ? err.message : String(err),
);
}
}
if (!thumbBytes) {
thumbBytes = new Uint8Array(FALLBACK_THUMB);
}
const fmt = detectImageFormat(thumbBytes);
width = width || fmt.width || 1;
height = height || fmt.height || 1;
duration = duration || 1;
// The cover sub-file must declare its OWN pixel size, which can be smaller
// than the video: the ffmpeg addon downscales large frames (4K/8K → ≤1080p)
// so the JPEG cover stays under QQ's 1 MiB cover limit. Fall back to the video
// dims when the format can't be read. For 1080p and custom-thumb sends the
// cover already equals the video size, so this is a no-op there.
const thumbWidth = fmt.width || width;
const thumbHeight = fmt.height || height;
const hashes = computeHashes(thumbBytes);
return {
width,
height,
duration,
thumb: {
bytes: thumbBytes,
md5: hashes.md5,
sha1: hashes.sha1,
md5Hex: hashes.md5Hex,
sha1Hex: hashes.sha1Hex,
width: thumbWidth,
height: thumbHeight,
},
};
}
async function loadVideo(element: MessageElement): Promise<VideoPayload> {
if (element.noByteFallback) {
if (!element.md5Hex || !element.sha1Hex) {
throw new Error('video fast-upload requires md5Hex + sha1Hex');
}
return videoPayloadFromFingerprint(element);
}
const source = element.url || element.fileId || '';
if (!source) {
throw new Error('video source is empty');
}
// Stage onto a local disk path (hardlink / streamed download) and hash it in
// one streaming pass — the video is never fully buffered in RAM. maxBytes =
// MAX_VIDEO_SIZE; oversize sources throw here and the OneBot layer re-routes
// them to the file pipeline (matches the message-actions size fallback).
const staged = await stageSourceToDisk(source, MAX_VIDEO_SIZE);
try {
if (staged.fileSize === 0) {
throw new Error('video file is empty');
}
// Baseline stat captured BEFORE hashing; re-checked before the upload so a
// source mutated mid-send fails cleanly (see uploadVideoMsgInfo).
const g = await fsp.stat(staged.filePath);
const hashes = await hashFileStreaming(staged.filePath);
const { thumb, width, height, duration } = await loadThumb(element, staged.filePath);
return {
bytes: new Uint8Array(0),
fileSource: { filePath: staged.filePath, fileSize: staged.fileSize },
md5: hashes.md5,
sha1: hashes.sha1,
sha1Blocks: hashes.sha1Blocks,
md5Hex: hashes.md5Hex,
sha1Hex: hashes.sha1Hex,
fileName: element.fileName || staged.fileName || `${hashes.md5Hex}.mp4`,
fileSize: staged.fileSize,
width,
height,
duration,
videoFormat: 0,
thumb,
fastOnly: false,
guard: { size: g.size, mtimeMs: g.mtimeMs },
cleanup: staged.cleanup,
};
} catch (err) {
await staged.cleanup();
throw err;
}
}
// ─────────────── exported entry ───────────────
/**
* Upload a video and return the encoded MsgInfo bytes that go inside a
* `commonElem { serviceType: 48, businessType: 21 }`.
*
* Two highway PUTs run when the server doesn't fast-path: the main video
* (with per-1MB-block sha1) and a thumb (read off `upload.subFileInfos[0]`).
*/
export async function uploadVideoMsgInfo(
bridge: BridgeContext,
isGroup: boolean,
targetIdOrUid: string | number,
element: MessageElement,
): Promise<Uint8Array> {
const log = loggerFor(bridge);
const concurrency = getVideoUploadConcurrency();
const preparedAt = Date.now();
const video = await loadVideo(element);
log.debug(
'video preparation done: bytes=%d elapsedMs=%d concurrency=%d',
video.fileSize,
Date.now() - preparedAt,
concurrency,
);
log.debug(
'uploading %d bytes md5=%s... → %s %s',
video.fileSize,
video.md5Hex.slice(0, 8),
isGroup ? 'group' : 'c2c',
String(targetIdOrUid),
);
try {
const uploads: MediaSubFileUpload[] = [
{
source: 'top',
concurrency,
cmdId: isGroup ? GROUP_VIDEO_CMD_ID : PRIVATE_VIDEO_CMD_ID,
bytes: video.bytes,
fileSource: video.fileSource, // streamed from disk (undefined on the fingerprint fast path)
md5: video.md5,
sha1: video.sha1Blocks,
subFileIndex: 0,
fastOnlyError: 'video fast-upload not available (server requires bytes)',
},
{
source: 0, // upload.subFileInfos[0]
cmdId: isGroup ? GROUP_VIDEO_THUMB_CMD_ID : PRIVATE_VIDEO_THUMB_CMD_ID,
bytes: video.thumb.bytes,
md5: video.thumb.md5,
sha1: video.thumb.sha1,
subFileIndex: 1,
// No fastOnlyError: thumb always has bytes (FALLBACK_THUMB at worst).
},
];
// Mutation guard: the streaming path reads the staged file twice (hash pass
// in loadVideo, then the highway PUT). For a hardlinked local source a
// concurrent in-place write would make the uploaded bytes disagree with the
// hashes we already sent. Re-stat before the PUT and fail cleanly if the
// file changed since the baseline (the server's per-chunk md5 check is the
// backstop during the PUT itself).
if (video.guard && video.fileSource) {
const now = await fsp.stat(video.fileSource.filePath);
if (now.size !== video.guard.size || now.mtimeMs !== video.guard.mtimeMs) {
throw new Error(
'video source changed during send (mutated between hashing and upload)',
);
}
}
const upload = await runNtv2Upload({
bridge,
isGroup,
targetIdOrUid,
oidbCmd: isGroup ? 0x11ea : 0x11e9,
serviceCmd: isGroup ? 'OidbSvcTrpcTcp.0x11ea_100' : 'OidbSvcTrpcTcp.0x11e9_100',
requestId: 3,
businessType: 2,
uploadInfo: [
{
fileInfo: {
fileSize: video.fileSize,
fileHash: video.md5Hex,
fileSha1: video.sha1Hex,
fileName: 'nya.mp4',
type: { type: 2, picFormat: 0, videoFormat: 0, voiceFormat: 0 },
// [#145] Group video MUST carry real width/height. A real QQ
// group video's MsgInfo has them (e.g. 296x640); sending 0x0
// makes QQ-NT receivers (Android especially) fail to lay out
// the video tile and render 文件已过期 even though the resource
// is fresh and downloadable (iOS is lenient — shows expired but
// still opens). c2c is left at 0 because the QQ-NT server has
// been observed to reject non-zero dimensions there with a
// schema-mismatch error (no c2c regression coverage yet).
height: isGroup ? video.height : 0,
width: isGroup ? video.width : 0,
// `time` MUST be the real duration in seconds, otherwise
// every receiving client renders "00:00" on the video.
// NapCat ships `time: 0` because it sits on top of QQ-NT's
// IPC layer, which the desktop client patches up before
// the wire message goes out. We're a protocol-direct
// client (same position as acidify), so we own this field.
// acidify writes `payload.videoDuration` here for the same
// reason — verified against `RichMediaUpload.kt::
// buildVideoUploadInfoList` (2026-04 refactor).
time: video.duration,
original: 0,
},
subFileType: 0,
},
{
fileInfo: {
fileSize: video.thumb.bytes.length,
fileHash: video.thumb.md5Hex,
fileSha1: video.thumb.sha1Hex,
fileName: 'nya.jpg',
type: { type: 1, picFormat: 0, videoFormat: 0, voiceFormat: 0 },
height: video.thumb.height,
width: video.thumb.width,
time: 0,
original: 0,
},
subFileType: 100,
},
],
// Hardcoded 2 even on c2c (matches NapCat). Image/PTT use
// `isGroup ? 2 : 1` because their legacy compat elements differ
// per scene (notOnlineImage vs customFace; ptt c2c vs group),
// but the legacy `videoFile` element has no scene split — its
// fromChatType/toChatType live inside the element itself — so
// the server generates a single group-shaped compat payload
// regardless. Setting 1 here makes the server emit a c2c-scene
// shaped compat blob that old QQ clients fail to resolve,
// showing the message as "视频已过期" on those receivers while
// new clients (which only read the commonElem) display fine.
compatQmsgSceneType: 2,
extBizInfo: {
pic: { bizType: 0, textSummary: 'Nya~' },
video: { bytesPbReserve: new Uint8Array([0x80, 0x01, 0x00]) },
ptt: {
bytesPbReserve: new Uint8Array(0),
bytesReserve: new Uint8Array(0),
bytesGeneralFlags: new Uint8Array(0),
},
},
uploads,
label: 'video',
});
log.debug(
'video upload completed: md5=%s scene=%s',
video.md5Hex,
isGroup ? 'group' : 'c2c',
);
const encoded = finalizeMediaMsgInfo(upload);
const mediaNode = upload.msgInfo?.msgInfoBody?.[0]?.index;
Object.assign(element, {
md5Hex: video.md5Hex,
sha1Hex: video.sha1Hex,
fileSize: video.fileSize,
fileName: 'nya.mp4',
duration: video.duration,
width: video.width,
height: video.height,
videoFormat: 0,
fileId: mediaNode?.fileUuid,
mediaNode,
});
return encoded;
} finally {
await video.cleanup();
}
}
File diff suppressed because it is too large. Load diff
+37
View File
@@ -0,0 +1,37 @@
// Forced-offline push. QQ NT tells a client it was kicked / logged in elsewhere /
// dropped for risk-control via the SSO command
// `trpc.qq_new_tech.status_svc.StatusService.KickNT`, whose body is a
// `KickNTResponse`. We surface it as a `bot_offline` event (NapCat parity).
//
// The push arrives on the same StatusService channel SnowLuma already receives
// (e.g. StatusService.SsoHeartBeat), so it just needs its own cmd handler.
// Command + field layout RE-confirmed against wrapper.linux.node + Lagrange's
// KickNTService/ServiceKickNTResponse.
import { protobuf_decode } from '@snowluma/proton';
import type { KickNTResponse } from '@snowluma/proto-defs/notify';
import type { PacketInfo } from '@snowluma/common/protocol-types';
import type { IdentityService } from './identity-service';
import type { QQEventVariant } from './events';
export const KICK_NT_CMD = 'trpc.qq_new_tech.status_svc.StatusService.KickNT';
type BotOfflineEvent = Extract<QQEventVariant, { kind: 'bot_offline' }>;
export function parseKickNT(pkt: PacketInfo, identity: IdentityService): QQEventVariant[] {
const resp = protobuf_decode<KickNTResponse>(pkt.body);
if (!resp) {
return [];
}
const ev: BotOfflineEvent = {
kind: 'bot_offline',
// KickNT carries no timestamp — stamp with receive time.
time: Math.floor(Date.now() / 1000),
selfUin: Number(pkt.uin) || Number(identity.uin) || 0,
tag: resp.title ?? '', // tips_title — the short title
message: resp.tips ?? '', // tips_content — the description / reason
};
return [ev];
}
@@ -0,0 +1,153 @@
import { hexPreview } from '@snowluma/common/hex';
import { createLogger } from '@snowluma/common/logger';
import type { Elem } from '@snowluma/proto-defs/element';
import type { MsgPushHead, PushMsgBody } from './context';
import { hasDecodableContent, isOfflineFileReceipt } from './rich-body-decoder';
const log = createLogger('MsgPush');
// C2C-family message types (private 166, temp 141, and 167) whose pushes QQ NT
// classifies by `c2c_cmd`. RE: `long_cnn_msg_mgr.cc::OnRecvSysMsg` gates on
// exactly this set (svr_msg_type-141 bitmask 0x6000001 → {141,166,167}).
const C2C_CONTROL_TYPES = new Set<number>([141, 166, 167]);
// `c2c_cmd` values QQ NT routes as system/control signals (via OnRecvSysMsg) and
// excludes from the chat list — never shown as a bubble. RE: the static lookup
// table @0xA13DF0 consulted by `msg_header_codec_helper.cc::DecodeRoutingHead`
// (sub_3B42650). These carry no chat content by design; the group-invite "[空消息]"
// phantom (#102) is one of them.
const C2C_CONTROL_CMDS = new Set<number>([1, 73, 75, 129, 131, 133, 135, 192]);
export type MessageSurvival =
'drop-control' | 'drop-receipt' | 'drop-blank' | 'keep-undecoded' | 'keep';
/**
* Whether a push is a C2C control/system signal that QQ NT never renders as a
* chat message (it dispatches it through `OnRecvSysMsg` instead). Matched by
* `(msgType, c2cCmd)` exactly as the official client's header codec does, so it
* catches these even on the rare occasion they carry junk content that would
* otherwise decode to a stray element. History may skip these before decode;
* the post-decode filter still returns `drop-control` for the same decision.
*/
export function isC2cControlPush(head: Pick<MsgPushHead, 'msgType' | 'c2cCmd'>): boolean {
return C2C_CONTROL_TYPES.has(head.msgType) && C2C_CONTROL_CMDS.has(head.c2cCmd);
}
/**
* A message-kind event is "blank" — the "[空消息]" phantom from #102 — when it
* decoded to zero elements AND its body carried nothing decodable.
*/
export function isBlankMessage(
elements: readonly unknown[],
body: PushMsgBody | undefined,
): boolean {
return elements.length === 0 && !hasDecodableContent(body);
}
/**
* Decoder-after bubble decision for friend / group / temp messages.
* Control drops even when a body decoded to elements. Blank drops only when
* there is nothing to decode. A body that had content but decoded to zero
* elements is keep-undecoded, not blank.
*/
export function classifyMessageSurvival(
head: Pick<MsgPushHead, 'msgType' | 'c2cCmd'>,
elements: readonly unknown[],
body: PushMsgBody | undefined,
): MessageSurvival {
if (isC2cControlPush(head)) {
return 'drop-control';
}
if (isOfflineFileReceipt(body)) {
return 'drop-receipt';
}
if (elements.length !== 0) {
return 'keep';
}
if (hasDecodableContent(body)) {
return 'keep-undecoded';
}
return 'drop-blank';
}
/** Summarise a body that decoded to zero elements despite carrying content. */
export function describeUndecodedBody(body: PushMsgBody | undefined): string {
const elems = (body?.richText?.elems ?? []) as Elem[];
const parts = elems.map((e) => {
if (e.commonElem) {
const ce = e.commonElem;
const pb =
ce.pbElem && ce.pbElem.length > 0 ? ` pbElem=${hexPreview(ce.pbElem, 256)}` : '';
return `commonElem(svc=${ce.serviceType ?? 0},biz=${ce.businessType ?? 0})${pb}`;
}
const keys = Object.keys(e).filter((k) => (e as Record<string, unknown>)[k] != null);
return keys.join('+') || '(empty)';
});
const extras: string[] = [];
if (body?.richText?.ptt) {
extras.push('ptt');
}
if (body?.richText?.notOnlineFile) {
extras.push('notOnlineFile');
}
if (body?.msgContent && body.msgContent.length > 0) {
extras.push(`msgContent=${hexPreview(body.msgContent, 256)}`);
}
return `elems=[${parts.join('; ')}]${extras.length ? ` ${extras.join(' ')}` : ''}`;
}
/** Keep when the message should appear as a bubble. Warns on keep-undecoded. */
export function keepDecodedMessage(
head: Pick<MsgPushHead, 'msgType' | 'c2cCmd' | 'sequence' | 'subType'>,
elements: readonly unknown[],
body: PushMsgBody | undefined,
kind: string,
fromUin: number,
): boolean {
const survival = classifyMessageSurvival(head, elements, body);
if (survival === 'drop-control' || survival === 'drop-receipt' || survival === 'drop-blank') {
return false;
}
if (survival === 'keep-undecoded') {
warnUndecodedMessage({
kind,
sequence: head.sequence,
fromUin,
msgType: head.msgType,
subType: head.subType,
body,
});
}
return true;
}
export function warnUndecodedMessage(info: {
kind: string;
sequence: number;
fromUin: number;
msgType: number;
subType: number;
body: PushMsgBody | undefined;
}): void {
log.warn(
'message had content but decoded to 0 elements — missing decoder? (kind=%s seq=%d from=%d msgType=%d/%d): %s',
info.kind,
info.sequence,
info.fromUin,
info.msgType,
info.subType,
describeUndecodedBody(info.body),
);
}
@@ -0,0 +1,195 @@
import { CLIENT_MESSAGE_RECEIPT_CMD, type PacketInfo } from '@snowluma/common/protocol-types';
import type { PacketSender } from '@snowluma/common/packet-sender';
import { isRealUin } from '@snowluma/common/uin';
import { createLogger } from '@snowluma/common/logger';
import type { SendMessageResponse } from '@snowluma/proto-defs/action';
import type { SsoGetC2cMsg, SsoGetC2cMsgResponse } from '@snowluma/proto-defs/get-c2c-msg';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { FriendMessage } from '../events';
import type { IdentityService } from '../identity-service';
import { classifyMessageSurvival } from './blank-filter';
import { buildContextFromMessage } from './context';
import { decodeFriendMessage } from './decoders/friend-message';
import { PkgType } from './enums';
import { SSO_GET_C2C_MSG_CMD } from './fetch-c2c-history';
import { requirePacketResponse } from './packet-response';
const log = createLogger('MsgPush.ClientMessage');
const historyRetryDelays = [100, 250, 500, 1000, 2000] as const;
export interface ClientMessageReceipt {
readonly peerUid: string;
readonly clientSequence: number;
readonly sequence: number;
readonly timestamp: number;
}
export function decodeClientMessageReceipt(pkt: PacketInfo, selfUin: string): ClientMessageReceipt {
if (
pkt.source !== 'client-message' ||
pkt.serviceCmd !== CLIENT_MESSAGE_RECEIPT_CMD ||
!isRealUin(selfUin) ||
pkt.uin !== selfUin
) {
throw new Error('Client message receipt has an invalid source or account');
}
if (pkt.retCode !== 0) {
throw new Error(`Client message receipt is unavailable (status=${pkt.retCode})`);
}
const body = Buffer.from(pkt.body);
if (body.length < 12 || body.length > 8 + 128 + 65536) {
throw new Error('Client message receipt has an invalid length');
}
const peerLength = body[1]!;
const ackOffset = 8 + peerLength;
if (
body[0] !== 1 ||
body.readUInt16LE(2) !== 0 ||
peerLength < 3 ||
peerLength > 128 ||
ackOffset >= body.length ||
body.length - ackOffset > 65536
) {
throw new Error('Client message receipt has an invalid envelope');
}
const peerUid = body.subarray(8, ackOffset).toString('utf8');
const clientSequence = body.readUInt32LE(4);
if (!/^u_[A-Za-z0-9_-]+$/.test(peerUid) || clientSequence === 0) {
throw new Error('Client message receipt has an invalid message identity');
}
const ack = protobuf_decode<SendMessageResponse>(body.subarray(ackOffset));
if (
(ack.result ?? 0) !== 0 ||
(ack.reviewResult ?? 0) !== 0 ||
(ack.serverResult?.code ?? 0) !== 0
) {
throw new Error('Client message was not accepted');
}
const sequence = ack.privateSequence;
const timestamp = ack.timestamp1;
if (!sequence || !timestamp || (ack.groupSequence ?? 0) !== 0) {
throw new Error('Client message receipt has no valid private message confirmation');
}
return { peerUid, clientSequence, sequence, timestamp };
}
export async function fetchClientMessage(
sender: PacketSender,
identity: IdentityService,
receipt: ClientMessageReceipt,
): Promise<FriendMessage> {
const request = protobuf_encode<SsoGetC2cMsg>({
friendUid: receipt.peerUid,
startSequence: receipt.sequence,
endSequence: receipt.sequence,
});
let response: SsoGetC2cMsgResponse;
for (let attempt = 0; ; attempt++) {
const result = await sender.sendPacket(SSO_GET_C2C_MSG_CMD, Buffer.from(request), 5000);
response = protobuf_decode<SsoGetC2cMsgResponse>(
requirePacketResponse(result, 'Client message lookup'),
);
if (response.friendUid && response.friendUid !== receipt.peerUid) {
throw new Error('Client message lookup returned another conversation');
}
// A successful send can precede visibility in message storage. Only
// its explicit not-yet-stored result permits retrying this exact ID.
if (
response.result === 100000301 &&
response.friendUid === receipt.peerUid &&
!response.messages?.length
) {
const delay = historyRetryDelays[attempt];
if (delay === undefined) {
throw new Error(
`Client message history is still unavailable after ${attempt + 1} attempts (code=${response.result})`,
);
}
log.debug(
'Confirmed client message is not yet in history: attempt=%d nextDelayMs=%d',
attempt + 1,
delay,
);
await new Promise<void>((resolve) => setTimeout(resolve, delay));
continue;
}
if ((response.result ?? 0) !== 0) {
throw new Error(`Client message lookup failed (code=${response.result})`);
}
break;
}
if (response.messages?.length !== 1) {
throw new Error(
`Client message lookup expected one message, received ${response.messages?.length ?? 0}`,
);
}
const message = response.messages[0]!;
const head = message.contentHead;
const route = message.responseHead;
const selfUin = Number(identity.uin);
if (
!head ||
!route ||
route.fromUin !== selfUin ||
route.toUid !== receipt.peerUid ||
!route.toUin ||
route.grp != null ||
head.ntMsgSeq !== receipt.sequence ||
head.sequence !== receipt.clientSequence ||
head.timestamp !== receipt.timestamp
) {
throw new Error('Client message lookup did not match the confirmed message');
}
if (
![
PkgType.PrivateMessage,
PkgType.PrivateRecordMessage,
PkgType.PrivateFileMessage,
PkgType.ForwardFakePrivateMessage,
].includes(head.msgType ?? 0)
) {
throw new Error('Client message lookup returned an unsupported message type');
}
const context = buildContextFromMessage(message, selfUin, identity);
if (!context) {
throw new Error('Client message lookup has no message context');
}
const events = decodeFriendMessage(context);
const event = events[0];
if (
events.length !== 1 ||
event?.kind !== 'friend_message' ||
classifyMessageSurvival(context.head, event.elements, context.body) !== 'keep'
) {
throw new Error('Client message lookup has no supported message content');
}
return event;
}
+125
View File
@@ -0,0 +1,125 @@
import { protobuf_decode } from '@snowluma/proton';
import type { PacketInfo } from '@snowluma/common/protocol-types';
import type { IdentityService } from '../identity-service';
import type {
ContentHead,
MessageBody,
PushMsg,
PushMsgBody as ProtoMessage,
ResponseHead,
} from '@snowluma/proto-defs/message';
export type PushMsgBody = MessageBody;
export type PushMsgResponseHead = ResponseHead;
export type PushMsgContentHead = ContentHead;
export interface MsgPushHead {
readonly msgType: number;
readonly subType: number;
/** C2C command (`contentHead` field 3). Discriminates control pushes — see
* `isC2cControlPush` in ./blank-filter. */
readonly c2cCmd: number;
/** Sender-local C2C client sequence (field 5), or the group sequence. */
readonly sequence: number;
readonly autoReply?: true;
/** Bidirectional C2C sequence (field 11); zero on groups/legacy packets. */
readonly ntMsgSeq: number;
readonly timestamp: number;
readonly msgId: number;
}
export interface MsgPushContext {
readonly head: MsgPushHead;
readonly fromUin: number;
readonly fromUid: string;
readonly selfUin: number;
readonly content: Uint8Array;
readonly body: PushMsgBody | undefined;
readonly responseHead: PushMsgResponseHead | undefined;
readonly identity: IdentityService;
/**
* Historical messages reuse the live decoder but must not replay
* live-only identity observations.
*/
readonly isHistorical: boolean;
}
export function buildContext(pkt: PacketInfo, identity: IdentityService): MsgPushContext | null {
if (pkt.body.length === 0) {
return null;
}
// `pkt.body` is already a `Uint8Array` (see `PacketInfo` in
// @snowluma/common/protocol-types). The legacy `Buffer.from(uint8)`
// wrap was a no-op copy — Buffer is a Uint8Array subclass and
// proton's decoder accepts the parent type directly. Dropping the
// wrap saves one allocation per incoming push packet.
const push = protobuf_decode<PushMsg>(pkt.body);
if (!push?.message) {
return null;
}
let selfUin = 0;
if (pkt.uin) {
const n = parseInt(pkt.uin, 10);
if (!isNaN(n)) {
selfUin = n;
}
}
return buildContextFromMessage(push.message, selfUin, identity, false);
}
/**
* Build a push context from an already-extracted per-message struct (the same
* `PushMsgBody` shape that `PushMsg.message` carries). Used both by
* {@link buildContext} (live OlPush) and by the group-history fetch, whose
* `SsoGetGroupMsgResponse.body.messages` are these same structs — so the
* regular msg-push decoders parse fetched history unchanged.
*/
export function buildContextFromMessage(
msg: ProtoMessage,
selfUin: number,
identity: IdentityService,
isHistorical = true,
): MsgPushContext | null {
if (!msg.contentHead) {
return null;
}
const head: MsgPushHead = {
msgType: msg.contentHead.msgType ?? 0,
subType: msg.contentHead.subType ?? 0,
c2cCmd: msg.contentHead.c2cCmd ?? 0,
sequence: msg.contentHead.sequence ?? 0,
autoReply: msg.contentHead.autoReply ? true : undefined,
ntMsgSeq: msg.contentHead.ntMsgSeq ?? 0,
timestamp: msg.contentHead.timestamp ?? 0,
msgId: msg.contentHead.msgId ?? 0,
};
let fromUin = 0;
let fromUid = '';
if (msg.responseHead) {
fromUin = msg.responseHead.fromUin ?? 0;
fromUid = msg.responseHead.fromUid ?? '';
}
const content = msg.body?.msgContent ?? new Uint8Array(0);
return {
head,
fromUin,
fromUid,
selfUin,
content,
body: msg.body,
responseHead: msg.responseHead,
identity,
isHistorical,
};
}
@@ -0,0 +1,397 @@
import { protobuf_decode } from '@snowluma/proton';
import { createLogger } from '@snowluma/common/logger';
import type {
FriendAddEvent,
FriendPokeEvent,
FriendRequestEvent,
OnlineDeviceInfo,
OnlineDeviceKind,
QQEventVariant,
} from '../../events';
import type {
FriendRecall,
FriendRemarkChangedNotify,
FriendRequest,
GeneralGrayTipInfo,
InputStatusNotify,
NewFriend,
OnlineDeviceNotify,
ProfileLikeTip,
} from '@snowluma/proto-defs/notify';
import type { PttTransPush } from '@snowluma/proto-defs/ptt-trans';
import type { MsgPushContext } from '../context';
import { Event0x210SubType } from '../enums';
import { buildTemplateMap, c2cRoutingUin, findTemplateValue, resolveUidToUin } from '../helpers';
import type { MsgPushDecoder } from '../registry';
type FriendRecallEvent = Extract<QQEventVariant, { kind: 'friend_recall' }>;
type FriendInputStatusEvent = Extract<QQEventVariant, { kind: 'friend_input_status' }>;
type FriendProfileLikeEvent = Extract<QQEventVariant, { kind: 'friend_profile_like' }>;
const unknownLog = createLogger('MsgPush.Unknown');
export const decodeEvent0x210: MsgPushDecoder = (ctx) => {
switch (ctx.head.subType as Event0x210SubType) {
case Event0x210SubType.FriendRequestNotice:
return decodeFriendRequest(ctx);
case Event0x210SubType.PttTransResult:
return decodePttTransResult(ctx);
// 138 (other-recalled) and 139 (self-recalled) share the same
// FriendRecall wire shape; the helper picks the right uid side
// based on the subType.
case Event0x210SubType.FriendRecallNotice:
case Event0x210SubType.FriendRecallSelfNotice:
return decodeFriendRecall(ctx);
case Event0x210SubType.FriendPokeNotice:
return decodeFriendPoke(ctx);
case Event0x210SubType.InputStatusNotice:
return decodeInputStatus(ctx);
case Event0x210SubType.OnlineDevicesNotice:
return decodeOnlineDevices(ctx);
case Event0x210SubType.FriendRemarkChangedNotice:
return decodeFriendRemarkChanged(ctx);
case Event0x210SubType.ProfileLikeNotice:
return decodeProfileLike(ctx);
// 179 + 226 both carry the NewFriend payload — see enum comment.
case Event0x210SubType.NewFriendNotice:
case Event0x210SubType.NewFriendNoticeAlt:
return decodeNewFriend(ctx);
case Event0x210SubType.GroupAppStatePush:
// QQ-client-internal troop shortcut bar / discussion app state
// push — no OneBot-level event. See enum comment for the
// decompiled-source breakdown of the sub_cmd dispatch.
return [];
case Event0x210SubType.UnmappedClientState380:
// QQ-NT-era subType with no reference handler: the legacy QQ
// Android decompile (tsuzcx/qq_apk) only enumerates SubType0x26
// through SubType0x146 under msgType0x210/, so 380 / 0x17C is
// an NT-era addition. None of the NT clients (Lagrange.Core,
// LagrangeGo, lagrange-python, mania) handle it either. See the
// enum comment for the full investigation; acknowledge silently
// until someone maps the schema.
return [];
}
unknownLog.debug('Event0x210 unknown subType=%d', ctx.head.subType);
return [];
};
function decodeFriendRemarkChanged(ctx: MsgPushContext): QQEventVariant[] {
const notify = protobuf_decode<FriendRemarkChangedNotify>(ctx.content);
const change = notify?.change;
const uid = change?.target?.uid?.trim() ?? '';
const rawUin = change?.target?.uin;
const uin = rawUin === undefined ? 0 : Number(rawUin);
if (rawUin !== undefined && (!Number.isSafeInteger(uin) || uin <= 0)) {
throw new Error(`friend remark synchronization has invalid UIN ${rawUin}`);
}
if (!uid && uin <= 0) {
throw new Error('friend remark synchronization is missing both UID and UIN');
}
if (change?.remark === undefined) {
throw new Error(
`friend remark synchronization is missing the remark value (uid=${uid} uin=${uin})`,
);
}
if (ctx.isHistorical) {
return [];
}
return [
{
kind: 'friend_remark_changed',
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
userUid: uid,
userUin: uin,
remark: change.remark,
},
];
}
const COMPUTER_CLIENT_TYPES = new Set([1, 5, 15]);
const PHONE_CLIENT_TYPES = new Set([2, 3, 4, 6, 7, 12]);
const PAD_CLIENT_TYPES = new Set([8, 9, 10, 11, 13]);
function onlineDeviceKind(clientType: number): OnlineDeviceKind {
if (COMPUTER_CLIENT_TYPES.has(clientType)) {
return 'computer';
}
if (PHONE_CLIENT_TYPES.has(clientType)) {
return 'phone';
}
if (PAD_CLIENT_TYPES.has(clientType)) {
return 'pad';
}
unknownLog.warn('online-device snapshot contains unknown clientType=%d', clientType);
return 'unknown';
}
function onlineDeviceClientType(rawClientType: number): number {
// QQ's OnRecvSysMsg path stores this wire field as an unsigned byte before
// it reaches DecodeOnLineDev. Higher bits carry terminal metadata and must
// not participate in device classification (live examples: 0x14702 -> 2,
// 0x10107 -> 7).
return rawClientType & 0xff;
}
function decodeOnlineDevices(ctx: MsgPushContext): QQEventVariant[] {
const notify = protobuf_decode<OnlineDeviceNotify>(ctx.content);
if (!notify) {
return [];
}
const devices: OnlineDeviceInfo[] = [];
const seenDeviceKinds = new Set<OnlineDeviceKind>();
for (const [index, item] of (notify.devices ?? []).entries()) {
const appId = item.appId ?? 0;
const rawClientType = item.clientType ?? 0;
if (appId <= 0 || rawClientType <= 0) {
throw new Error(
`online-device entry ${index} is missing appId or clientType ` +
`(appIdPresent=${item.appId != null} clientTypePresent=${item.clientType != null})`,
);
}
const clientType = onlineDeviceClientType(rawClientType);
const deviceKind = onlineDeviceKind(clientType);
// QQ's DecodeOnLineDevList exposes one entry per derived devUid
// (computer/pad/phone), retaining the first item in each category.
if (seenDeviceKinds.has(deviceKind)) {
continue;
}
seenDeviceKinds.add(deviceKind);
devices.push({
appId,
instanceId: item.instanceId ?? 0,
clientType,
platform: item.platform ?? 0,
deviceName: item.deviceName ?? '',
deviceKind,
});
}
return [
{
kind: 'online_devices_changed',
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
devices,
},
];
}
// Voice-to-text async result push. Wire shape (live-verified):
// { f1: uint, f2: { f1 = msgId (echoes the request), f8 = text, ... } }
// `msgId` is the correlation key a pending fetch_ptt_text waits on; we surface
// it + the recognised text as an internal `ptt_trans_result` event.
function decodePttTransResult(ctx: MsgPushContext): QQEventVariant[] {
const push = protobuf_decode<PttTransPush>(ctx.content);
const item = push?.item;
if (!item) {
return [];
}
const msgId = item.msgId ?? 0n;
const text = item.text ?? '';
if (!msgId || !text) {
return [];
}
return [
{ kind: 'ptt_trans_result', time: ctx.head.timestamp, selfUin: ctx.selfUin, msgId, text },
];
}
function decodeFriendRequest(ctx: MsgPushContext): QQEventVariant[] {
const request = protobuf_decode<FriendRequest>(ctx.content);
if (!request?.info) {
return [];
}
const sourceUid = request.info.newSource || request.info.sourceUid || '';
const ev: FriendRequestEvent = {
kind: 'friend_request',
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
fromUin: resolveUidToUin(ctx.identity, 0, sourceUid),
fromUid: sourceUid,
message: request.info.message ?? '',
flag: sourceUid,
};
return [ev];
}
function decodeFriendRecall(ctx: MsgPushContext): QQEventVariant[] {
const recall = protobuf_decode<FriendRecall>(ctx.content);
if (!recall?.info) {
return [];
}
// 138 = friend recalled their own message sent to bot (peer = fromUid).
// 139 = bot recalled own message sent to friend (peer = toUid). Same
// ambiguity acidify handles in `parseFriendRecall:380`. Without this
// split, self-recall events surface `userUin` as the bot itself which
// confuses downstream OneBot consumers that key off `user_id`.
const isSelfRecall = ctx.head.subType === Event0x210SubType.FriendRecallSelfNotice;
const peerUid = (isSelfRecall ? recall.info.toUid : recall.info.fromUid) ?? '';
const ev: FriendRecallEvent = {
kind: 'friend_recall',
time: recall.info.time ?? ctx.head.timestamp,
selfUin: ctx.selfUin,
userUin: resolveUidToUin(ctx.identity, 0, peerUid),
msgSeq: recall.info.clientSequence ?? 0,
clientSeq: recall.info.clientSequence ?? 0,
recalledBySelf: isSelfRecall,
};
return [ev];
}
function decodeNewFriend(ctx: MsgPushContext): QQEventVariant[] {
const nf = protobuf_decode<NewFriend>(ctx.content);
if (!nf?.info) {
return [];
}
const newFriendUid = nf.info.uid ?? '';
const newFriendUin = c2cRoutingUin(ctx.identity, newFriendUid, ctx.fromUin);
if (newFriendUin <= 0) {
return [];
} // can't surface a `user_id`-less friend_add to OneBot
const ev: FriendAddEvent = {
kind: 'friend_add',
// proto field is `fixed32 Time` — already uint32 seconds epoch.
// Fall back to head.timestamp if the field is missing (some
// captures have it as 0).
time: nf.info.time && nf.info.time > 0 ? nf.info.time : ctx.head.timestamp,
selfUin: ctx.selfUin,
userUin: newFriendUin,
};
return [ev];
}
// C2C "对方正在输入…" input-status push. Body (`InputStatusNotify`) rides in
// `body.msgContent` (= ctx.content). Layout + event-type semantics RE'd from
// `aio_input_state_worker.cc::ProcessInputStateNotifySysMsg`: the notify item's
// field 4 is the event type (1 = typing, 3 = recording voice). The client
// synthesises the status text from that type, so we do the same for OneBot
// parity (NapCat's `onInputStatusPush` → `notice/notify` `input_status`).
function decodeInputStatus(ctx: MsgPushContext): QQEventVariant[] {
const notify = protobuf_decode<InputStatusNotify>(ctx.content);
const fromUid = notify?.fromUid ?? '';
if (!fromUid) {
return [];
}
const eventType = notify.notifyItem?.eventType ?? 1;
const ev: FriendInputStatusEvent = {
kind: 'friend_input_status',
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
userUin: resolveUidToUin(ctx.identity, 0, fromUid),
userUid: fromUid,
eventType,
statusText: inputStatusText(eventType),
};
return [ev];
}
// The client's own strings (wrapper.linux.node @0xB8FFE0 / @0xB90000).
function inputStatusText(eventType: number): string {
return eventType === 3 ? '对方正在讲话...' : '对方正在输入...';
}
// subType 39 is multiplexed; body.msgContent decodes as ProfileLikeTip and is a
// profile-like ("名片赞") only when inner msgType==0 && subType==203. Other 39
// variants (multi-device sync etc.) decode to a non-matching tip → dropped.
// `times` is the like count parsed out of `detail.txt` ("赞了我的资料卡N次").
// Field layout confirmed byte-exact against real on-wire captures.
function decodeProfileLike(ctx: MsgPushContext): QQEventVariant[] {
const tip = protobuf_decode<ProfileLikeTip>(ctx.content);
// msgType 0 is the proto default → omitted on the wire → decodes as undefined,
// so treat missing as 0 (matches NapCat's `msgType !== 0` check).
if (!tip || (tip.msgType ?? 0) !== 0 || (tip.subType ?? 0) !== 203) {
return [];
}
const msg = tip.content?.msg;
const detail = msg?.detail;
const operatorUin = Number(detail?.uin ?? 0n);
if (!operatorUin) {
return [];
}
const times = Number.parseInt((detail?.txt ?? '').match(/\d+/)?.[0] ?? '0', 10) || 0;
const ev: FriendProfileLikeEvent = {
kind: 'friend_profile_like',
time: msg?.time || ctx.head.timestamp,
selfUin: ctx.selfUin,
operatorUin,
operatorNick: detail?.nickname ?? '',
times,
};
return [ev];
}
function decodeFriendPoke(ctx: MsgPushContext): QQEventVariant[] {
const grayTip = protobuf_decode<GeneralGrayTipInfo>(ctx.content);
if (!grayTip || (grayTip.busiType ?? 0n) !== 12n) {
return [];
}
const templates = buildTemplateMap(grayTip.msgTemplParam ?? []);
const actor = findTemplateValue(templates, 'uin_str1');
const target = findTemplateValue(templates, 'uin_str2');
const senderUin = resolveUidToUin(ctx.identity, 0, actor);
const routeFromUin = c2cRoutingUin(ctx.identity, ctx.fromUid, ctx.fromUin);
// The gray-tip template only identifies the two poke endpoints. The C2C
// routing head owns the conversation peer: outbound notices use `to`,
// while inbound notices use `from`, just like ordinary friend messages.
const sentBySelf = routeFromUin > 0 && routeFromUin === ctx.selfUin;
const peerUin = sentBySelf
? c2cRoutingUin(ctx.identity, ctx.responseHead?.toUid ?? '', ctx.responseHead?.toUin ?? 0)
: routeFromUin;
if (peerUin <= 0) {
throw new Error(
'friend poke conversation peer is missing ' +
`(fromUin=${ctx.fromUin} toUin=${ctx.responseHead?.toUin ?? 0} sentBySelf=${sentBySelf})`,
);
}
const ev: FriendPokeEvent = {
kind: 'friend_poke',
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
peerUin,
senderUin,
targetUin: resolveUidToUin(ctx.identity, 0, target),
action: findTemplateValue(templates, 'action_str', 'alt_str1'),
suffix: findTemplateValue(templates, 'suffix_str'),
actionImgUrl: findTemplateValue(templates, 'action_img_url'),
};
return [ev];
}
@@ -0,0 +1,325 @@
import { protobuf_decode } from '@snowluma/proton';
import { createLogger } from '@snowluma/common/logger';
import type {
GroupEssenceEvent,
GroupMsgEmojiLikeEvent,
GroupMuteEvent,
GroupNameChangeEvent,
GroupTitleChangeEvent,
GroupPokeEvent,
GroupRecallEvent,
QQEventVariant,
} from '../../events';
import type {
GroupMute,
GroupNameChange,
GroupSpecialTitleChange,
GroupReactNotify,
NotifyMessageBody,
} from '@snowluma/proto-defs/notify';
import type { MsgPushContext } from '../context';
import { Event0x2DCSubType } from '../enums';
import {
buildTemplateMap,
findTemplateValue,
resolveUidToUin,
unwrapGroupNotifyPayload,
} from '../helpers';
import type { MsgPushDecoder } from '../registry';
const unknownLog = createLogger('MsgPush.Unknown');
export const decodeEvent0x2DC: MsgPushDecoder = (ctx) => {
switch (ctx.head.subType as Event0x2DCSubType) {
case Event0x2DCSubType.GroupMuteNotice:
return decodeGroupMute(ctx);
case Event0x2DCSubType.GroupMsgEmojiLikeNotice:
return decodeSubType16(ctx);
case Event0x2DCSubType.GroupRecallNotice:
return decodeGroupRecall(ctx);
case Event0x2DCSubType.GroupGreyTipNotice:
return decodeGroupGreyTip(ctx);
case Event0x2DCSubType.GroupEssenceNotice:
return decodeGroupEssence(ctx);
}
unknownLog.debug('Event0x2DC unknown subType=%d', ctx.head.subType);
return [];
};
function decodeGroupMute(ctx: MsgPushContext): QQEventVariant[] {
const mute = protobuf_decode<GroupMute>(ctx.content);
if (!mute?.data?.state) {
return [];
}
const duration = mute.data.state.duration ?? 0;
const ev: GroupMuteEvent = {
kind: 'group_mute',
time: mute.data.timestamp ?? ctx.head.timestamp,
selfUin: ctx.selfUin,
groupId: mute.groupUin ?? 0,
operatorUin: resolveUidToUin(ctx.identity, mute.groupUin ?? 0, mute.operatorUid ?? ''),
userUin: resolveUidToUin(ctx.identity, mute.groupUin ?? 0, mute.data.state.targetUid ?? ''),
duration: duration === 0xffffffff ? 0x7fffffff : duration,
};
return [ev];
}
function decodeGroupRecall(ctx: MsgPushContext): QQEventVariant[] {
const payload = unwrapGroupNotifyPayload(ctx.content);
if (!payload) {
return [];
}
const notify = protobuf_decode<NotifyMessageBody>(payload);
if (!notify?.recall?.recallMessages || notify.recall.recallMessages.length === 0) {
return [];
}
const recalled = notify.recall.recallMessages[0];
const ev: GroupRecallEvent = {
kind: 'group_recall',
time: recalled.time ?? ctx.head.timestamp,
selfUin: ctx.selfUin,
groupId: notify.groupUin ?? 0,
operatorUin: resolveUidToUin(
ctx.identity,
notify.groupUin ?? 0,
notify.recall.operatorUid || notify.operatorUid || '',
),
authorUin: resolveUidToUin(ctx.identity, notify.groupUin ?? 0, recalled.authorUid ?? ''),
msgSeq: recalled.sequence ?? 0,
};
return [ev];
}
function decodeGroupGreyTip(ctx: MsgPushContext): QQEventVariant[] {
const payload = unwrapGroupNotifyPayload(ctx.content);
if (!payload) {
return [];
}
const notify = protobuf_decode<NotifyMessageBody>(payload);
if (!notify?.generalGrayTip || (notify.generalGrayTip.busiType ?? 0n) !== 12n) {
return [];
}
const templates = buildTemplateMap(notify.generalGrayTip.msgTemplParam ?? []);
const actor = findTemplateValue(templates, 'uin_str1');
const target = findTemplateValue(templates, 'uin_str2');
const ev: GroupPokeEvent = {
kind: 'group_poke',
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
groupId: notify.groupUin ?? 0,
userUin: resolveUidToUin(ctx.identity, notify.groupUin ?? 0, actor),
targetUin: resolveUidToUin(ctx.identity, notify.groupUin ?? 0, target),
action: findTemplateValue(templates, 'action_str', 'alt_str1'),
suffix: findTemplateValue(templates, 'suffix_str'),
actionImgUrl: findTemplateValue(templates, 'action_img_url'),
};
return [ev];
}
function decodeGroupEssence(ctx: MsgPushContext): QQEventVariant[] {
const payload = unwrapGroupNotifyPayload(ctx.content);
if (!payload) {
return [];
}
const notify = protobuf_decode<NotifyMessageBody>(payload);
if (!notify?.essenceMessage) {
return [];
}
const essence = notify.essenceMessage;
const setFlag = essence.setFlag ?? essence.setFlag2 ?? 0;
const ev: GroupEssenceEvent = {
kind: 'group_essence',
time: essence.timestamp ?? ctx.head.timestamp,
selfUin: ctx.selfUin,
groupId: essence.groupUin ?? notify.groupUin ?? 0,
senderUin: essence.memberUin ?? 0,
operatorUin: essence.operatorUin ?? 0,
msgSeq: essence.msgSequence ?? essence.msgSequence2 ?? notify.msgSequence ?? 0,
random: essence.random ?? 0,
set: setFlag === 1,
};
return [ev];
}
// Magic prefix QQ prepends to the GroupReactNotify payload inside
// body.msgContent for Event0x2DC subType=16. Same value NapCat strips
// (see api/group.ts: `msgContent?.slice(7)`).
const GROUP_REACT_PREFIX_BYTES = 7;
// Discriminator on GroupReactNotify.field13 — same subType is reused
// for other notify variants, only 35 means "emoji react". Anything
// else falls through to MsgPush.Unknown for protocol-drift visibility.
const GROUP_REACT_DISCRIMINATOR = 35;
// Event 0x2DC subType 16 multiplexes several group notices, discriminated by
// `NotifyMessageBody.field13`. RE cross-ref (Lagrange Event0x2DCSubType16Field13,
// itself RE'd from wrapper.linux.node): 6=special title, 12=name change, 23=todo,
// 35=emoji reaction. We peek field13 off the shared NotifyMessageBody, then branch.
const SUBTYPE16_SPECIAL_TITLE = 6;
const SUBTYPE16_GROUP_NAME_CHANGE = 12;
function decodeSubType16(ctx: MsgPushContext): QQEventVariant[] {
if (ctx.content.length <= GROUP_REACT_PREFIX_BYTES) {
return [];
}
const field13 =
protobuf_decode<NotifyMessageBody>(ctx.content.subarray(GROUP_REACT_PREFIX_BYTES))
?.field13 ?? 0;
switch (field13) {
case SUBTYPE16_SPECIAL_TITLE:
return decodeGroupTitle(ctx);
case SUBTYPE16_GROUP_NAME_CHANGE:
return decodeGroupName(ctx);
case GROUP_REACT_DISCRIMINATOR:
return decodeGroupMsgEmojiLike(ctx);
default:
unknownLog.debug('Event0x2DC subType=16 unhandled field13=%d', field13);
return [];
}
}
// field13 == 6: a member was granted a special title. eventParam carries the
// member uin (f5) + a gray-tip template string (f2); the title text is the last
// `<{"…","text":TITLE,…}>` rich token in that string (on-wire captured shape —
// the kernel resolves the template to a clean string, we parse it here).
function decodeGroupTitle(ctx: MsgPushContext): QQEventVariant[] {
const notify = protobuf_decode<NotifyMessageBody>(
ctx.content.subarray(GROUP_REACT_PREFIX_BYTES),
);
const param = notify?.eventParam
? protobuf_decode<GroupSpecialTitleChange>(notify.eventParam)
: undefined;
const userUin = param?.memberUin ?? 0;
const title = extractTitleFromTip(param?.tipText ?? '');
if (!userUin || !title) {
return [];
}
const ev: GroupTitleChangeEvent = {
kind: 'group_title_change',
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
groupId: notify?.groupUin ?? 0,
userUin,
title,
};
return [ev];
}
// The tip text is "恭喜<{member}>获得群主授予的<{…"text":TITLE…}>头衔" — the title is
// the `text` of the LAST `<{…}>` rich token (the one just before "头衔"). Tokens
// are flat JSON with no `<`/`>` inside, so a non-greedy scan is safe.
function extractTitleFromTip(text: string): string {
const tokens = text.match(/<\{[^<>]*\}>/g);
if (!tokens || tokens.length === 0) {
return '';
}
const last = tokens[tokens.length - 1];
try {
const parsed = JSON.parse(last.slice(1, -1)) as { text?: string };
return parsed.text ?? '';
} catch {
return '';
}
}
// field13 == 12: group renamed. New name is nested in `eventParam` as a
// `GroupNameChange` (field 2); operator is `NotifyMessageBody.operatorUid`.
function decodeGroupName(ctx: MsgPushContext): QQEventVariant[] {
const notify = protobuf_decode<NotifyMessageBody>(
ctx.content.subarray(GROUP_REACT_PREFIX_BYTES),
);
const name = notify?.eventParam
? (protobuf_decode<GroupNameChange>(notify.eventParam)?.name ?? '')
: '';
if (!name) {
return [];
}
const groupId = notify?.groupUin ?? 0;
const ev: GroupNameChangeEvent = {
kind: 'group_name_change',
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
groupId,
// Operator is a group member → resolves from the roster. Fall back to 0
// (not ctx.fromUin, which on a 0x2DC push is the group id) so an
// unresolved operator never surfaces the group id as user_id.
operatorUin: resolveUidToUin(ctx.identity, groupId, notify?.operatorUid ?? ''),
name,
};
return [ev];
}
function decodeGroupMsgEmojiLike(ctx: MsgPushContext): QQEventVariant[] {
if (ctx.content.length <= GROUP_REACT_PREFIX_BYTES) {
return [];
}
const payload = ctx.content.subarray(GROUP_REACT_PREFIX_BYTES);
const notify = protobuf_decode<GroupReactNotify>(payload);
if (!notify) {
return [];
}
if ((notify.field13 ?? 0) !== GROUP_REACT_DISCRIMINATOR) {
unknownLog.debug(
'Event0x2DC subType=16 unhandled field13=%d (expected %d for emoji react)',
notify.field13 ?? 0,
GROUP_REACT_DISCRIMINATOR,
);
return [];
}
const content = notify.groupReactionData?.data?.data?.groupReactionDataContent;
const target = notify.groupReactionData?.data?.data?.groupReactionTarget;
if (!content) {
return [];
}
const groupId = Number(notify.groupUin ?? 0n);
const operatorUid = content.operatorUid ?? '';
const emojiId = content.code ?? '';
const count = content.count ?? 1;
// type is QQ's add/remove discriminator: 1 = react added, 2 = react
// removed. Older payloads occasionally omit it; default to "add"
// since most clients can only generate add events.
const isAdd = (content.type ?? 1) === 1;
const msgSeq = Number(target?.seq ?? 0n);
const ev: GroupMsgEmojiLikeEvent = {
kind: 'group_msg_emoji_like',
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
groupId,
operatorUin: resolveUidToUin(ctx.identity, groupId, operatorUid),
operatorUid,
msgSeq,
emojiId,
count,
isAdd,
};
return [ev];
}
@@ -0,0 +1,115 @@
import { tryDecodeDatalineFriendMessage } from '../../dataline/decode-push';
import type { FriendMessage } from '../../events';
import type { MsgPushDecoder } from '../registry';
import { decodeRichBody } from '../rich-body-decoder';
/** Read the approval tuple from a QQ group-invitation card. */
function parseGroupInviteCard(jsonText: string): { groupUin: number; sequence: number } | null {
const card: unknown = JSON.parse(jsonText);
if (
!card ||
typeof card !== 'object' ||
!('app' in card) ||
(card.app !== 'com.tencent.qun.invite' && card.app !== 'com.tencent.tuwen.lua')
) {
return null;
}
if (
!('meta' in card) ||
!card.meta ||
typeof card.meta !== 'object' ||
!('news' in card.meta) ||
!card.meta.news ||
typeof card.meta.news !== 'object' ||
!('jumpUrl' in card.meta.news) ||
typeof card.meta.news.jumpUrl !== 'string'
) {
return null;
}
const url = URL.parse(card.meta.news.jumpUrl);
if (
!url ||
url.protocol !== 'mqqapi:' ||
url.hostname !== 'group' ||
url.pathname !== '/invite_join'
) {
return null;
}
const groupUin = Number(url.searchParams.get('groupcode'));
const sequence = Number(url.searchParams.get('msgseq'));
if (
!Number.isSafeInteger(groupUin) ||
groupUin <= 0 ||
!Number.isSafeInteger(sequence) ||
sequence <= 0
) {
throw new Error('group invitation card has an invalid approval tuple');
}
return { groupUin, sequence };
}
export const decodeFriendMessage: MsgPushDecoder = (ctx) => {
const dataline = tryDecodeDatalineFriendMessage(ctx);
if (dataline) {
return [dataline];
}
const elements = decodeRichBody(ctx.body, false);
const sentBySelf = ctx.fromUin > 0 && ctx.fromUin === ctx.selfUin;
const peerUin = sentBySelf ? (ctx.responseHead?.toUin ?? 0) : ctx.fromUin;
const sequenceAuthoritative = ctx.head.ntMsgSeq > 0;
let inviteCard: { groupUin: number; sequence: number } | null = null;
for (const el of elements) {
if (el.type === 'json' && typeof el.text === 'string') {
inviteCard = parseGroupInviteCard(el.text);
if (inviteCard) {
break;
}
}
}
const ev: FriendMessage = {
kind: 'friend_message',
autoReply: ctx.head.autoReply,
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
senderUin: ctx.fromUin,
senderUid: ctx.fromUid,
...(peerUin > 0 ? { peerUin } : {}),
msgSeq: ctx.head.sequence,
ntMsgSeq: ctx.head.ntMsgSeq,
clientSeq: ctx.head.sequence,
sequenceAuthoritative,
msgId: ctx.head.msgId & 0x7fffffff,
elements,
senderNick: '',
...(inviteCard
? {
inviteCardGroupUin: inviteCard.groupUin,
inviteCardSequence: inviteCard.sequence,
}
: {}),
};
if (ctx.responseHead?.forward?.friendName) {
ev.senderNick = ctx.responseHead.forward.friendName;
}
const friend = ctx.identity.findFriend(ctx.fromUin);
if (friend && !ev.senderNick) {
ev.senderNick = friend.nickname;
}
return [ev];
};
@@ -0,0 +1,41 @@
import { protobuf_decode } from '@snowluma/proton';
import type { GroupAdminEvent } from '../../events';
import type { GroupAdmin } from '@snowluma/proto-defs/notify';
import { resolveUidToUin } from '../helpers';
import type { MsgPushDecoder } from '../registry';
export const decodeGroupAdmin: MsgPushDecoder = (ctx) => {
const admin = protobuf_decode<GroupAdmin>(ctx.content);
if (!admin?.body) {
return [];
}
// proton materializes an *absent* embedded-message field as an empty
// `{}` rather than leaving it undefined, so the old
// `extraEnable !== undefined` promote/demote test was always true and
// every demote got reported as a promotion (with a fallback userUin).
// The live sub-message is the one that actually carries an `adminUid`
// — key off that instead.
const enableUid = admin.body.extraEnable?.adminUid;
const disableUid = admin.body.extraDisable?.adminUid;
const adminUid = enableUid || disableUid;
if (!adminUid) {
return [];
}
const groupId = admin.groupUin ?? 0;
const set = !!enableUid;
const userUin = resolveUidToUin(ctx.identity, groupId, adminUid);
const ev: GroupAdminEvent = {
kind: 'group_admin',
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
groupId,
userUin,
set,
};
return [ev];
};
@@ -0,0 +1,80 @@
import { protobuf_decode } from '@snowluma/proton';
import type { GroupInviteEvent } from '../../events';
import type { GroupInvitation, GroupInvite, GroupJoin } from '@snowluma/proto-defs/notify';
import { resolveUidToUin } from '../helpers';
import type { MsgPushDecoder } from '../registry';
export const decodeGroupJoinRequest: MsgPushDecoder = (ctx) => {
const join = protobuf_decode<GroupJoin>(ctx.content);
if (!join) {
return [];
}
const ev: GroupInviteEvent = {
kind: 'group_invite',
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
groupId: join.groupUin ?? 0,
// Strangers stay unresolved here. Pipeline fills UIN via Identity.resolveUin.
fromUin: resolveUidToUin(ctx.identity, join.groupUin ?? 0, join.targetUid ?? ''),
fromUid: join.targetUid ?? '',
subType: 'add',
message: '',
// Legacy flag: the approval path can resolve it from the request inbox
// even when the pipeline cannot find a pending request to enrich from.
flag: `add:${join.groupUin ?? 0}:${join.targetUid ?? ''}`,
};
return [ev];
};
export const decodeGroupInvitation: MsgPushDecoder = (ctx) => {
const invitation = protobuf_decode<GroupInvitation>(ctx.content);
if (invitation?.cmd !== 87 || !invitation.info?.inner) {
return [];
}
const inner = invitation.info.inner;
const ev: GroupInviteEvent = {
kind: 'group_invite',
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
groupId: inner.groupUin ?? 0,
fromUin: resolveUidToUin(ctx.identity, inner.groupUin ?? 0, inner.invitorUid ?? ''),
fromUid: inner.invitorUid ?? '',
invitedUin: resolveUidToUin(ctx.identity, inner.groupUin ?? 0, inner.targetUid ?? ''),
invitedUid: inner.targetUid ?? '',
subType: 'invite',
message: '',
// Self-invite notices never carry the approval msgseq, and they are
// absent from the UIN-based request inbox -- but the UID-based one used
// by `set_group_add_request` legacy parsing does return them. Ship the
// legacy flag so the event stays actionable (#125).
flag: `invite:${inner.groupUin ?? 0}:${inner.invitorUid ?? ''}`,
};
return [ev];
};
export const decodeGroupInvite: MsgPushDecoder = (ctx) => {
const invite = protobuf_decode<GroupInvite>(ctx.content);
if (!invite) {
return [];
}
const ev: GroupInviteEvent = {
kind: 'group_invite',
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
groupId: invite.groupUin ?? 0,
fromUin: resolveUidToUin(ctx.identity, invite.groupUin ?? 0, invite.invitorUid ?? ''),
fromUid: invite.invitorUid ?? '',
invitedUin: ctx.selfUin,
subType: 'invite',
message: '',
// See decodeGroupInvitation: keep the event actionable without the card.
flag: `invite:${invite.groupUin ?? 0}:${invite.invitorUid ?? ''}`,
};
return [ev];
};
@@ -0,0 +1,164 @@
import { protobuf_decode } from '@snowluma/proton';
import { createLogger } from '@snowluma/common/logger';
import type { GroupMemberJoin, GroupMemberLeave } from '../../events';
import type { GroupChange, SelfJoinInGroup } from '@snowluma/proto-defs/notify';
import {
decodeGroupChangeOperatorUid,
decodeNestedOperatorUid,
decodeRawOperatorUid,
resolveUidToUin,
} from '../helpers';
import type { MsgPushDecoder } from '../registry';
const log = createLogger('MsgPush.GroupJoin');
function joinTypeFromOperationType(raw: number): NonNullable<GroupMemberJoin['joinType']> {
// QQ uses bit 0x80 as a flag on the PkgType=33 operation code. Clear only
// that bit: other high bits remain significant in the native handler. The
// normalized code 3 means an invitation; all other values mean approval.
// Do not use the nested `join_type`: that is an invitation-source enum.
const normalized = raw - (raw & 0x80);
return normalized === 3 ? 'invite' : 'approve';
}
export const decodeGroupMemberJoin: MsgPushDecoder = (ctx) => {
const change = protobuf_decode<GroupChange>(ctx.content);
if (!change) {
return [];
}
const groupId = change.groupUin ?? 0;
const userUid = change.memberUid ?? '';
const operatorUid = decodeGroupChangeOperatorUid(
change.operatorBytes ?? new Uint8Array(0),
'group member increase',
);
const operation = (change.decreaseType ?? 0) - ((change.decreaseType ?? 0) & 0x80);
const origin = ctx.responseHead?.grp?.messageKey?.joinExtra?.origin;
// QQ consumes this independent origin only for operation 2. Type 1
// identifies a QR-code sharer; neither that person nor the approving
// operator is evidence of an invitation.
const invitorUid =
operation === 2 && origin?.type !== 1 && origin?.uid?.length
? decodeRawOperatorUid(origin.uid, 'group member invitation origin')
: '';
log.trace(
'join identity: group=%d operation=%d originType=%s inviterProvided=%s',
groupId,
operation,
origin?.type === undefined ? 'absent' : String(origin.type),
Boolean(invitorUid),
);
const ev: GroupMemberJoin = {
kind: 'group_member_join',
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
groupId,
userUin: resolveUidToUin(ctx.identity, groupId, userUid),
operatorUin: resolveUidToUin(ctx.identity, groupId, operatorUid),
userUid,
operatorUid,
...(invitorUid
? { invitorUid, invitorUin: resolveUidToUin(ctx.identity, groupId, invitorUid) }
: {}),
joinType: joinTypeFromOperationType(change.decreaseType ?? 0),
};
return [ev];
};
// QQ's GroupChange.decreaseType (proto field 4) on a member-decrease push:
// 3 → the bot itself was kicked
// 129 → the group was disbanded
// 130 → voluntary leave
// 131 → another member was kicked
// 0 / absent → treat as voluntary leave (defensive default)
// The kick → kick_me distinction (was it us?) is decided downstream in the
// OneBot converter via selfId; here we only resolve the protocol-level reason.
function leaveTypeFromDecreaseType(dt: number): GroupMemberLeave['leaveType'] {
switch (dt) {
case 129:
return 'disband';
case 0:
case 130:
return 'leave';
default:
return 'kick';
}
}
export const decodeGroupMemberLeave: MsgPushDecoder = (ctx) => {
const change = protobuf_decode<GroupChange>(ctx.content);
if (!change) {
return [];
}
const dt = change.decreaseType ?? 0;
const groupId = change.groupUin ?? 0;
const userUid = change.memberUid ?? '';
const userUin = resolveUidToUin(ctx.identity, groupId, userUid);
let operatorUid: string;
let operatorUin: number;
if (dt === 130) {
// A voluntary leave has no independent operator. QQ may place structured
// metadata in field 5, so it must not be decoded as a UID. OneBot models
// the leaving member as the operator for this event.
operatorUid = userUid;
operatorUin = userUin;
} else {
const operatorBytes = change.operatorBytes ?? new Uint8Array(0);
// Self-kick always uses OperatorInfo. Ordinary removals vary between a raw
// UTF-8 UID and the same envelope, so discriminate by its field-1 wire tag.
operatorUid =
dt === 3
? decodeNestedOperatorUid(operatorBytes, 'group member decrease type=3')
: decodeGroupChangeOperatorUid(operatorBytes, `group member decrease type=${dt}`);
operatorUin = resolveUidToUin(ctx.identity, groupId, operatorUid);
}
const ev: GroupMemberLeave = {
kind: 'group_member_leave',
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
groupId,
userUin,
operatorUin,
userUid,
operatorUid,
leaveType: leaveTypeFromDecreaseType(dt),
};
return [ev];
};
export const decodeGroupSelfJoined: MsgPushDecoder = (ctx) => {
const joined = protobuf_decode<SelfJoinInGroup>(ctx.content);
if (!joined) {
return [];
}
// groupUin is uint_64 on the wire (bigint). Real group IDs fit in
// uint32 (≤ 10 digits in practice), so Number() conversion is safe
// — saturate to 0 on the unlikely overflow rather than letting
// BigInt propagate downstream where consumers expect a number.
const groupUinBig = joined.groupUin ?? 0n;
const groupId = groupUinBig > 0n && groupUinBig <= 0x7fffffffn ? Number(groupUinBig) : 0;
const operatorUid = joined.operatorUid ?? '';
// We're the one joining — surface the bot's own identity as userUin
// so the existing converter's `notice.group_increase` shape works
// unchanged.
const ev: GroupMemberJoin = {
kind: 'group_member_join',
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
groupId,
userUin: ctx.selfUin,
userUid: ctx.identity.selfUid ?? '',
operatorUin: resolveUidToUin(ctx.identity, groupId, operatorUid),
operatorUid,
};
return [ev];
};
@@ -0,0 +1,50 @@
import type { GroupMessage } from '../../events';
import { decodeGroupDisplayName } from '../../group-member-card';
import type { MsgPushDecoder } from '../registry';
import { decodeRichBody } from '../rich-body-decoder';
export const decodeGroupMessage: MsgPushDecoder = (ctx) => {
const ev: GroupMessage = {
kind: 'group_message',
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
senderUin: ctx.fromUin,
msgSeq: ctx.head.sequence,
msgId: ctx.head.msgId & 0x7fffffff,
elements: decodeRichBody(ctx.body, true),
groupId: 0,
groupName: '',
senderNick: '',
senderCard: '',
senderRole: '',
};
const grp = ctx.responseHead?.grp;
const displayName = decodeGroupDisplayName(grp);
const isNickname = grp?.memberCardType === 2;
ev.groupId = grp?.groupUin ?? 0;
const member = ctx.identity.findGroupMember(ev.groupId, ctx.fromUin);
// History supplies an explicit nickname. Live type 2 is also a nickname;
// a card from an unknown group doubles as a forward node's display name (#201).
ev.senderNick =
grp?.memberName ||
(isNickname ? displayName : undefined) ||
(member ? member.nickname : displayName) ||
'';
if (member) {
ev.senderCard = member.card ?? '';
ev.senderRole = member.role;
// A fresh card supersedes the cache, which can otherwise survive a rename (#1).
if (!isNickname && displayName !== undefined && displayName !== ev.senderNick) {
ev.senderCard = displayName;
}
}
// Group name: prefer the message's own wire field (ResponseGrp field 7 —
// fresh even right after a rename), fall back to the cached group list, else
// leave it empty. `ev.groupId` is set above from grp.groupUin.
ev.groupName = grp?.groupName || ctx.identity.findGroup?.(ev.groupId)?.groupName || '';
return [ev];
};
@@ -0,0 +1,41 @@
import type { TempMessage } from '../../events';
import type { MsgPushDecoder } from '../registry';
import { decodeRichBody } from '../rich-body-decoder';
export const decodeTempMessage: MsgPushDecoder = (ctx) => {
const sequenceAuthoritative = ctx.head.ntMsgSeq > 0;
const ev: TempMessage = {
kind: 'temp_message',
autoReply: ctx.head.autoReply,
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
senderUin: ctx.fromUin,
msgSeq: ctx.head.sequence,
ntMsgSeq: ctx.head.ntMsgSeq,
clientSeq: ctx.head.sequence,
sequenceAuthoritative,
elements: decodeRichBody(ctx.body, false),
groupId: 0,
senderNick: '',
};
if (ctx.responseHead?.grp) {
ev.groupId = ctx.responseHead.grp.groupUin ?? 0;
ev.senderNick = ctx.responseHead.grp.memberName ?? '';
}
// A group temp session carries its source group in responseHead.forward.
if (ctx.responseHead?.forward?.tempGroupUin) {
ev.groupId = ctx.responseHead.forward.tempGroupUin;
}
if (!ev.senderNick) {
const friend = ctx.identity.findFriend(ctx.fromUin);
if (friend) {
ev.senderNick = friend.nickname;
}
}
return [ev];
};
+141
View File
@@ -0,0 +1,141 @@
export enum PkgType {
ForwardFakePrivateMessage = 9,
PrivateMessage = 166,
GroupMessage = 82,
TempMessage = 141,
Event0x210 = 528,
Event0x2DC = 732,
PrivateRecordMessage = 208,
PrivateFileMessage = 529,
GroupRequestInvitationNotice = 525,
GroupRequestJoinNotice = 84,
/**
* Self-join admittance — fires on the bot's session right after an
* admin approves its `set_group_add_request` or an invite-accept
* goes through. Ported from `lagrange-python`'s
* `server_push/msg.py:108 case 85`. Maps to OneBot11
* `notice.group_increase` with the bot itself as `user_id` —
* existing 33 (GroupMemberIncreaseNotice) doesn't fire for the
* bot's own join, so without this case the bot never knows it
* just entered a group until the first message arrives.
*/
GroupSelfJoinedNotice = 85,
GroupInviteNotice = 87,
GroupAdminChangedNotice = 44,
GroupMemberIncreaseNotice = 33,
GroupMemberDecreaseNotice = 34,
}
export enum Event0x2DCSubType {
GroupMuteNotice = 12,
GroupMsgEmojiLikeNotice = 16,
GroupRecallNotice = 17,
GroupGreyTipNotice = 20,
GroupEssenceNotice = 21,
}
export enum Event0x210SubType {
FriendRequestNotice = 35,
/**
* Voice-to-text (语音转文字) async result push, fired after a
* `pttTrans.Trans{C2C,Group}PttReq` once the server finishes transcribing.
* Live-verified body: `{ f1:uint, f2:{ f1=msgId, f8=text, ... } }`. There's
* no static sys_msg_0x210_0x3d handler — the ptt-trans subsystem registers
* it dynamically; we decode it into an internal `ptt_trans_result` event.
*/
PttTransResult = 61,
/**
* Outgoing friend-message recall — bot recalled its own message that
* was sent to a friend. Same `FriendRecall` wire shape as 138; the
* difference is direction (138 = friend recalled their own message
* sent to bot, 139 = bot recalled own message sent to friend).
* Cross-referenced against Lagrange V2 (`FriendRecallMessageProcessor`
* registered for both 138 and 139) + acidify (`138, 139 ->
* parseFriendRecall`). The decoder uses the subType to figure out
* which UID side ends up as `userUin` on the emitted event.
*/
FriendRecallSelfNotice = 139,
FriendRecallNotice = 138,
FriendPokeNotice = 290,
/**
* Mutual-accept friend notice — 179 fires when bot sent a friend
* request and the other side accepted; 226 fires for the opposite
* direction. Both decode through the same `NewFriend` wire shape
* (LagrangeGo registers them via a `case 226: case 179:` fallthrough
* in `client/listener.go:248-258`). Maps to OneBot11
* `notice.friend_add`.
*/
NewFriendNotice = 179,
NewFriendNoticeAlt = 226,
/**
* Multiplexed subType (Lagrange calls it FriendDeleteOrPinChanged=39). One of
* its variants is the profile-like ("名片赞") push: body.msgContent decodes as
* `ProfileLikeTip` and is a like only when inner msgType==0 && subType==203.
* NapCat routes 528/39 → parseLikeEvent (`api/msg.ts:1547`); other 39 variants
* (multi-device sync etc.) fall through.
*/
ProfileLikeNotice = 39,
/**
* C2C input-status notify — the "对方正在输入…" push. subMsgType 0x115 (277).
* RE-confirmed against `wrapper.linux.node`
* `aio_input_state_worker.cc::IsInputStateNotifySysMsg`, which matches exactly
* `(msg_type ^ 0x210) | (sub_msg_type ^ 0x115) == 0`. The payload
* (`InputStatusNotify`) rides in `body.msgContent`. Mirrors NapCat's
* `onInputStatusPush`.
*/
InputStatusNotice = 277,
/**
* QQ NT online-device cache update. The payload is a snapshot with repeated
* device entries; the kernel's getOnLineDev call only replays this cache and
* does not issue a separate network request.
*/
OnlineDevicesNotice = 349,
/** Server-authoritative friend/stranger remark synchronization push. */
FriendRemarkChangedNotice = 364,
/**
* Group-app state push (troop shortcut bar / discussion app).
*
* Sourced from the decompiled stock QQ Android client decoder at
* `com.tencent.imcore.message.ext.codec.decoder.msgType0x210.SubType0x26`
* (tsuzcx/qq_apk @ afe46ef). The payload is `submsgtype0x26.MsgBody`
* dispatching on `uint32_sub_cmd`:
*
* - 0x1 → `UpdateAppUnreadNum`: a list of
* `{group_code, app_id, unread_num}` entries the QQ client uses
* to keep group-internal "mini-app" badges (e.g. `appId=101846662`
* / `101896870` for the shortcut bar) in sync.
* - 0x3 → `UpdateDiscussAppInfo`: `{conf_uin, app_tip_notify.text}`
* for discussion-group app tips, routed to `getGAudioHandler()`.
* - 0x4 → delegated to the troop online-push handler.
*
* None of these have a OneBot event mapping — they're QQ-client-UI
* state pushes (unread badges, in-app tips), not user-visible
* conversation events. Acknowledge the subType so it doesn't keep
* showing up as "unknown" in debug logs and drop it silently.
* Lagrange V2 / lagrange-python / LagrangeGo / acidify all also
* fall through on this one because they don't surface the shortcut
* bar either.
*/
GroupAppStatePush = 38,
/**
* Unmapped QQ-NT-internal subType — first seen as a recurring
* "Event0x210 unknown subType=380" debug log in production captures.
*
* The tsuzcx/qq_apk decompile of the legacy QQ Android client only
* enumerates SubType0x26 (38) through SubType0x146 (326) under
* `com.tencent.imcore.message.ext.codec.decoder.msgType0x210/` —
* `SubType0x17c.java` (380) literally doesn't exist in that tree, so
* 380 is a QQ-NT-era addition rather than a missed legacy subType.
*
* The NT-era reference clients also don't handle it: Lagrange.Core
* (`PushMessageService.cs`), LagrangeGo (`client/listener.go`),
* lagrange-python (`server_push/msg.py`), and mania (`push_msg.rs`)
* each enumerate explicit 0x210 subtypes and none include 380 / 0x17C.
*
* Most likely a QQ-NT-client-UI state push (similar to 38) that
* doesn't map to any OneBot event. Acknowledge silently to keep the
* unknown log clean; if a real OneBot event ends up depending on it,
* revisit with whichever NT-era project finally maps the schema.
*/
UnmappedClientState380 = 380,
}
@@ -0,0 +1,202 @@
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { createLogger } from '@snowluma/common/logger';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type {
SsoGetC2cMsg,
SsoGetC2cMsgResponse,
SsoGetRoamMsg,
SsoGetRoamMsgResponse,
} from '@snowluma/proto-defs/get-c2c-msg';
import type { PushMsgBody } from '@snowluma/proto-defs/message';
import type { QQEventVariant } from '../events';
import type { IdentityService } from '../identity-service';
import { isC2cControlPush, keepDecodedMessage } from './blank-filter';
import { buildContextFromMessage } from './context';
import { decodeFriendMessage } from './decoders/friend-message';
import { requirePacketResponse } from './packet-response';
export const SSO_GET_C2C_MSG_CMD = 'trpc.msg.register_proxy.RegisterProxy.SsoGetC2cMsg';
export const SSO_GET_ROAM_MSG_CMD = 'trpc.msg.register_proxy.RegisterProxy.SsoGetRoamMsg';
const log = createLogger('MsgPush.C2CHistory');
type FriendMessage = Extract<QQEventVariant, { kind: 'friend_message' }>;
interface RawSender {
sendRawPacket(
serviceCmd: string,
body: Uint8Array,
timeoutMs?: number,
): Promise<SendPacketResult>;
}
export interface C2cRoamPage {
messages: FriendMessage[];
cursor: { time: number; random: number };
isComplete: boolean;
}
function decodeC2cMessages(
messages: readonly PushMsgBody[],
identity: IdentityService,
selfUin: number,
source: string,
): FriendMessage[] {
const out: FriendMessage[] = [];
for (const msg of messages) {
const ctx = buildContextFromMessage(msg, selfUin, identity);
if (!ctx) {
throw new Error(`${source} response contains a message without a content head`);
}
if (isC2cControlPush(ctx.head)) {
continue;
}
for (const ev of decodeFriendMessage(ctx)) {
if (ev.kind !== 'friend_message') {
continue;
}
if (!keepDecodedMessage(ctx.head, ev.elements, ctx.body, ev.kind, ctx.fromUin)) {
continue;
}
if (ev.sequenceAuthoritative === false || !ev.ntMsgSeq || ev.ntMsgSeq <= 0) {
throw new Error(
`${source} message is missing the canonical NT sequence ` +
`(clientSeq=${ev.clientSeq ?? ev.msgSeq} from=${ev.senderUin} time=${ev.time})`,
);
}
out.push(ev);
}
}
return out;
}
/**
* Fetch one [startSeq, endSeq] NT-sequence window of private (c2c) history from
* the server via `SsoGetC2cMsg`, decoding each returned message with the regular friend
* decoder. `friendUid` is the conversation peer's UID. Returns `friend_message`
* events sorted oldest→newest by sequence. One packet per call — the caller
* owns chunking/throttling.
*/
export async function fetchC2cMessageRange(
sender: RawSender,
identity: IdentityService,
selfUin: number,
friendUid: string,
startSeq: number,
endSeq: number,
): Promise<FriendMessage[]> {
if (!friendUid || !(endSeq > 0) || startSeq > endSeq) {
return [];
}
const req = protobuf_encode<SsoGetC2cMsg>({
friendUid,
startSequence: startSeq,
endSequence: endSeq,
});
const res = await sender.sendRawPacket(SSO_GET_C2C_MSG_CMD, req);
const decoded = protobuf_decode<SsoGetC2cMsgResponse>(
requirePacketResponse(res, 'SsoGetC2cMsg'),
);
if (decoded.friendUid && decoded.friendUid !== friendUid) {
throw new Error(`SsoGetC2cMsg response friend uid mismatch: ${decoded.friendUid}`);
}
const messages = decoded?.messages ?? [];
const out = decodeC2cMessages(messages, identity, selfUin, 'SsoGetC2cMsg');
out.sort((a, b) => (a.ntMsgSeq ?? 0) - (b.ntMsgSeq ?? 0));
log.debug(
'SsoGetC2cMsg decoded friend=%s range=%d-%d wire=%d usable=%d',
friendUid,
startSeq,
endSeq,
messages.length,
out.length,
);
return out;
}
/**
* Fetch one timestamp-cursor page of C2C roaming history. QQ uses this endpoint
* for an unanchored latest page; it returns messages from both participants.
*/
export async function fetchC2cRoamMessagePage(
sender: RawSender,
identity: IdentityService,
selfUin: number,
friendUid: string,
beforeTime: number,
count: number,
beforeRandom = 0,
): Promise<C2cRoamPage> {
if (!friendUid) {
throw new Error('SsoGetRoamMsg friend uid is required');
}
if (!Number.isSafeInteger(beforeTime) || beforeTime < 0 || beforeTime > 0xffff_ffff) {
throw new Error(`SsoGetRoamMsg cursor time is invalid: ${String(beforeTime)}`);
}
if (!Number.isSafeInteger(beforeRandom) || beforeRandom < 0 || beforeRandom > 0xffff_ffff) {
throw new Error(`SsoGetRoamMsg cursor random is invalid: ${String(beforeRandom)}`);
}
if (!Number.isSafeInteger(count) || count < 1 || count > 200) {
throw new Error(`SsoGetRoamMsg count is invalid: ${String(count)}`);
}
const req = protobuf_encode<SsoGetRoamMsg>({
friendUid,
time: beforeTime,
random: beforeRandom,
count,
direction: true,
});
const result = await sender.sendRawPacket(SSO_GET_ROAM_MSG_CMD, req);
const response = protobuf_decode<SsoGetRoamMsgResponse>(
requirePacketResponse(result, 'SsoGetRoamMsg'),
);
if (response.result === 100000301) {
log.debug('SsoGetRoamMsg reached the end of private history');
return { messages: [], cursor: { time: 0, random: 0 }, isComplete: true };
}
if (response.result) {
throw new Error(
`SsoGetRoamMsg failed: result=${response.result} ${response.errorMessage ?? ''}`.trim(),
);
}
if (response.friendUid && response.friendUid !== friendUid) {
throw new Error(`SsoGetRoamMsg response friend uid mismatch: ${response.friendUid}`);
}
const wireMessages = response.messages ?? [];
const messages = decodeC2cMessages(wireMessages, identity, selfUin, 'SsoGetRoamMsg');
messages.sort((a, b) => (a.ntMsgSeq ?? 0) - (b.ntMsgSeq ?? 0) || a.time - b.time);
const cursor = { time: response.timestamp ?? 0, random: response.random ?? 0 };
log.debug(
'SsoGetRoamMsg decoded friend=%s before=%d/%d count=%d wire=%d usable=%d next=%d/%d complete=%s',
friendUid,
beforeTime,
beforeRandom,
count,
wireMessages.length,
messages.length,
cursor.time,
cursor.random,
response.isComplete === true,
);
return { messages, cursor, isComplete: response.isComplete === true };
}
@@ -0,0 +1,119 @@
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { createLogger } from '@snowluma/common/logger';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { SsoGetGroupMsg, SsoGetGroupMsgResponse } from '@snowluma/proto-defs/get-group-msg';
import type { QQEventVariant } from '../events';
import type { IdentityService } from '../identity-service';
import { keepDecodedMessage } from './blank-filter';
import { buildContextFromMessage } from './context';
import { decodeGroupMessage } from './decoders/group-message';
import { requirePacketResponse } from './packet-response';
export const SSO_GET_GROUP_MSG_CMD = 'trpc.msg.register_proxy.RegisterProxy.SsoGetGroupMsg';
type GroupMessage = Extract<QQEventVariant, { kind: 'group_message' }>;
const log = createLogger('MsgPush.GroupHistory');
interface RawSender {
sendRawPacket(
serviceCmd: string,
body: Uint8Array,
timeoutMs?: number,
): Promise<SendPacketResult>;
}
/**
* Fetch one [startSeq, endSeq] window of group history from the server via
* `SsoGetGroupMsg`, decoding each returned message with the regular group
* decoder. Returns `group_message` events sorted oldest→newest by sequence.
*
* One packet per call — the caller (MessageApi.getGroupHistory) owns the
* chunking/throttling so the server's frequency limits aren't tripped.
*/
export async function fetchGroupMessageRange(
sender: RawSender,
identity: IdentityService,
selfUin: number,
groupUin: number,
startSeq: number,
endSeq: number,
): Promise<GroupMessage[]> {
if (!(groupUin > 0) || !(endSeq > 0) || startSeq > endSeq) {
return [];
}
const req = protobuf_encode<SsoGetGroupMsg>({
info: { groupUin, startSequence: startSeq, endSequence: endSeq },
direction: true,
});
const res = await sender.sendRawPacket(SSO_GET_GROUP_MSG_CMD, req);
const decoded = protobuf_decode<SsoGetGroupMsgResponse>(
requirePacketResponse(res, 'SsoGetGroupMsg'),
);
const messages = decoded?.body?.messages ?? [];
const out: GroupMessage[] = [];
for (let index = 0; index < messages.length; index += 1) {
const msg = messages[index];
const ctx = buildContextFromMessage(msg, selfUin, identity);
if (!ctx) {
throw new Error(
`SsoGetGroupMsg response message ${index} has no content head ` +
`(group=${groupUin} range=${startSeq}-${endSeq})`,
);
}
// QQ NT turns deleted/body-null roam entries into MsgType::kNull and its
// FilterBlankSeqsMsg pass removes them before exposing history to callers.
// SsoGetGroupMsg still carries a localized placeholder body for some of
// those entries (for example "[已删除]"), so the ordinary content-less
// filter below cannot recognize them. On the wire these null records have
// neither a sender nor a timestamp; require both structural absences rather
// than matching localized text so a real message is never language-filtered.
if (ctx.fromUin <= 0 && ctx.head.timestamp <= 0) {
log.debug(
'dropping QQ null roam record: group=%d seq=%d msgId=%d',
groupUin,
ctx.head.sequence,
ctx.head.msgId,
);
continue;
}
for (const ev of decodeGroupMessage(ctx)) {
if (ev.kind !== 'group_message') {
throw new Error(
`SsoGetGroupMsg response message ${index} decoded as ${ev.kind} ` +
`(group=${groupUin} range=${startSeq}-${endSeq})`,
);
}
if (!Number.isSafeInteger(ev.msgSeq) || ev.msgSeq <= 0) {
throw new Error(
`SsoGetGroupMsg response message ${index} has invalid sequence ${String(ev.msgSeq)} ` +
`(group=${groupUin} range=${startSeq}-${endSeq})`,
);
}
if (ev.groupId !== groupUin) {
throw new Error(
`SsoGetGroupMsg response message ${index} belongs to group ${ev.groupId} ` +
`(expected=${groupUin} range=${startSeq}-${endSeq})`,
);
}
if (!keepDecodedMessage(ctx.head, ev.elements, ctx.body, ev.kind, ctx.fromUin)) {
continue;
}
out.push(ev);
}
}
out.sort((a, b) => a.msgSeq - b.msgSeq);
return out;
}
+278
View File
@@ -0,0 +1,278 @@
import { protobuf_decode } from '@snowluma/proton';
import { hexPreview } from '@snowluma/common/hex';
import { inflateSync } from 'zlib';
import type { IdentityService } from '../identity-service';
import type { OperatorInfo } from '@snowluma/proto-defs/notify';
import type { MsgInfoBody } from '@snowluma/proto-defs/element';
export const MAX_RICH_CARD_OUTPUT_BYTES = 4 * 1024 * 1024;
export const MAX_RICH_CARD_MESSAGE_OUTPUT_BYTES = 8 * 1024 * 1024;
export type DecompressDataResult =
{ ok: true; text: string; outputBytes: number } | { ok: false; reason: string };
export function makeImageUrl(origUrl: string): string {
if (!origUrl) {
return '';
}
if (/^https?:\/\//i.test(origUrl)) {
return origUrl;
}
// Compatibility faces sometimes carry a query fragment without a path.
// Prefixing the host onto that produces an unusable URL.
if (!origUrl.startsWith('/')) {
return '';
}
if (origUrl.includes('rkey') || origUrl.includes('fileid')) {
return 'https://multimedia.nt.qq.com.cn' + origUrl;
}
return 'http://gchat.qpic.cn' + origUrl;
}
/** Classic group-pic URL used when the wire carries an MD5 and no download path. */
export function imageUrlFromMd5(md5Hex: string): string {
if (!/^[0-9a-fA-F]{32}$/.test(md5Hex)) {
return '';
}
return `http://gchat.qpic.cn/gchatpic_new/0/0-0-${md5Hex.toUpperCase()}/0`;
}
/** NT multimedia download URL reconstructed from a file id when the path is omitted. */
export function ntImageUrlFromFileId(fileId: string, isGroup: boolean): string {
if (!fileId) {
return '';
}
const appid = isGroup ? 1407 : 1406;
return `https://multimedia.nt.qq.com.cn/download?appid=${appid}&fileid=${encodeURIComponent(fileId)}`;
}
/** Upload receipts and received messages carry the same NT image reference. */
export function ntImageUrlFromMsgInfo(body: MsgInfoBody | undefined, isGroup: boolean): string {
if (body?.picture?.urlPath) {
const { domain, urlPath, ext } = body.picture;
return `https://${domain ?? 'multimedia.nt.qq.com.cn'}${urlPath}${ext?.originalParameter ?? ''}`;
}
return ntImageUrlFromFileId(body?.index?.fileUuid ?? '', isGroup);
}
export function decompressData(
data: Uint8Array,
maxOutputBytes = MAX_RICH_CARD_OUTPUT_BYTES,
): DecompressDataResult {
if (!data || data.length === 0) {
return { ok: false, reason: 'empty_payload' };
}
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes <= 0) {
throw new Error(`invalid decompression output limit: ${maxOutputBytes}`);
}
if ((data[0] === 0x00 || data[0] === 0x01) && data.length === 1) {
return { ok: false, reason: 'marker_without_payload' };
}
if (data[0] === 0x01) {
try {
const inflated = inflateSync(Buffer.from(data.subarray(1)), {
maxOutputLength: maxOutputBytes,
});
return inflated.length > 0
? { ok: true, text: inflated.toString('utf8'), outputBytes: inflated.length }
: { ok: false, reason: 'empty_output' };
} catch (error) {
const code =
typeof error === 'object' && error !== null && 'code' in error
? String(error.code)
: '';
return {
ok: false,
reason:
code === 'ERR_BUFFER_TOO_LARGE'
? 'output_limit_exceeded'
: code
? `inflate_failed:${code}`
: 'inflate_failed',
};
}
}
if (data[0] === 0x00) {
if (data.length - 1 > maxOutputBytes) {
return { ok: false, reason: 'output_limit_exceeded' };
}
return {
ok: true,
text: Buffer.from(data.subarray(1)).toString('utf8'),
outputBytes: data.length - 1,
};
}
if (data.length > maxOutputBytes) {
return { ok: false, reason: 'output_limit_exceeded' };
}
return { ok: true, text: Buffer.from(data).toString('utf8'), outputBytes: data.length };
}
/** Sync Identity lookup. Miss is unresolved (0). Decoder must not invent a UIN. */
export function resolveUidToUin(identity: IdentityService, groupId: number, uid: string): number {
if (!uid) {
return 0;
}
return identity.findUinByUid(uid, groupId || undefined) ?? 0;
}
/**
* C2C routing hop. Prefer Identity on the hop UID; otherwise use that hop's
* own wire UIN. This is not a fallback that assigns an unrelated header UIN
* to a different UID.
*/
export function c2cRoutingUin(identity: IdentityService, uid: string, wireUin: number): number {
const resolved = resolveUidToUin(identity, 0, uid);
if (resolved > 0) {
return resolved;
}
return wireUin > 0 ? wireUin : 0;
}
const OPERATOR_BYTES_PREVIEW_LENGTH = 32;
function describeOperatorBytes(bytes: Uint8Array): string {
return `bytes=${bytes.length} preview=${hexPreview(bytes, OPERATOR_BYTES_PREVIEW_LENGTH)}`;
}
function hasControlCharacter(value: string): boolean {
for (const char of value) {
const codePoint = char.codePointAt(0);
if (codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f)) {
return true;
}
}
return false;
}
function assertOperatorUid(uid: string, context: string, bytes: Uint8Array): string {
if (hasControlCharacter(uid)) {
throw new Error(
`${context} operator UID contains control characters (${describeOperatorBytes(bytes)})`,
);
}
return uid;
}
export function decodeRawOperatorUid(bytes: Uint8Array, context: string): string {
if (!bytes || bytes.length === 0) {
return '';
}
let uid: string;
try {
uid = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
} catch (error) {
throw new Error(
`${context} operator UID is not valid UTF-8 (${describeOperatorBytes(bytes)})`,
{ cause: error },
);
}
return assertOperatorUid(uid, context, bytes);
}
export function decodeNestedOperatorUid(bytes: Uint8Array, context: string): string {
if (!bytes || bytes.length === 0) {
return '';
}
let info: OperatorInfo | null;
try {
info = protobuf_decode<OperatorInfo>(bytes);
} catch (error) {
throw new Error(
`${context} operator info decode failed (${describeOperatorBytes(bytes)})`,
{ cause: error },
);
}
const uid = info?.operatorField?.uid;
if (!uid) {
throw new Error(
`${context} operator info is missing UID (${describeOperatorBytes(bytes)})`,
);
}
return assertOperatorUid(uid, context, bytes);
}
const OPERATOR_INFO_FIELD_TAG = 0x0a;
export function decodeGroupChangeOperatorUid(bytes: Uint8Array, context: string): string {
if (!bytes || bytes.length === 0) {
return '';
}
return bytes[0] === OPERATOR_INFO_FIELD_TAG
? decodeNestedOperatorUid(bytes, context)
: decodeRawOperatorUid(bytes, context);
}
export function buildTemplateMap(
params: Array<{ name?: string; value?: string }>,
): Map<string, string> {
const map = new Map<string, string>();
for (const p of params) {
if (p.name !== undefined) {
map.set(p.name, p.value ?? '');
}
}
return map;
}
export function findTemplateValue(map: Map<string, string>, ...keys: string[]): string {
for (const k of keys) {
const v = map.get(k);
if (v) {
return v;
}
}
return '';
}
export function unwrapGroupNotifyPayload(content: Uint8Array): Uint8Array | null {
if (content.length <= 7) {
return null;
}
const lenBe = (content[5] << 8) | content[6];
const lenLe = content[5] | (content[6] << 8);
if (7 + lenBe <= content.length) {
return content.subarray(7, 7 + lenBe);
}
if (7 + lenLe <= content.length) {
return content.subarray(7, 7 + lenLe);
}
return content.subarray(7);
}
+236
View File
@@ -0,0 +1,236 @@
import { createLogger } from '@snowluma/common/logger';
import type { PacketInfo } from '@snowluma/common/protocol-types';
import type { QQEventVariant } from '../events';
import type { IdentityService } from '../identity-service';
import { classifyMessageSurvival, warnUndecodedMessage } from './blank-filter';
import { buildContext } from './context';
import { decodeEvent0x210 } from './decoders/event-0x210';
import { decodeEvent0x2DC } from './decoders/event-0x2dc';
import { decodeFriendMessage } from './decoders/friend-message';
import { decodeGroupAdmin } from './decoders/group-admin';
import {
decodeGroupInvitation,
decodeGroupInvite,
decodeGroupJoinRequest,
} from './decoders/group-join-request';
import {
decodeGroupMemberJoin,
decodeGroupMemberLeave,
decodeGroupSelfJoined,
} from './decoders/group-member-change';
import { decodeGroupMessage } from './decoders/group-message';
import { decodeTempMessage } from './decoders/temp-message';
import { PkgType } from './enums';
import { MsgPushRegistry } from './registry';
import { deriveSysMsgDedupIdentity, SysMsgDedup } from './sysmsg-dedup';
export { deriveSysMsgDedupIdentity, SysMsgDedup } from './sysmsg-dedup';
export type { SysMsgDedupIdentity } from './sysmsg-dedup';
export { decodeClientMessageReceipt, fetchClientMessage } from './client-message-receipt';
export { SSO_GET_GROUP_MSG_CMD, fetchGroupMessageRange } from './fetch-group-history';
export {
SSO_GET_C2C_MSG_CMD,
SSO_GET_ROAM_MSG_CMD,
fetchC2cMessageRange,
fetchC2cRoamMessagePage,
type C2cRoamPage,
} from './fetch-c2c-history';
export const MSG_PUSH_CMD = 'trpc.msg.olpush.OlPushService.MsgPush';
const registry = new MsgPushRegistry();
registry.register(PkgType.GroupMemberIncreaseNotice, decodeGroupMemberJoin);
registry.register(PkgType.GroupMemberDecreaseNotice, decodeGroupMemberLeave);
registry.register(PkgType.GroupSelfJoinedNotice, decodeGroupSelfJoined);
registry.register(PkgType.GroupAdminChangedNotice, decodeGroupAdmin);
registry.register(PkgType.GroupRequestJoinNotice, decodeGroupJoinRequest);
registry.register(PkgType.GroupRequestInvitationNotice, decodeGroupInvitation);
registry.register(PkgType.GroupInviteNotice, decodeGroupInvite);
registry.register(PkgType.Event0x210, decodeEvent0x210);
registry.register(PkgType.Event0x2DC, decodeEvent0x2DC);
registry.register(PkgType.GroupMessage, decodeGroupMessage);
registry.register(PkgType.TempMessage, decodeTempMessage);
registry.register(
[
PkgType.PrivateMessage,
PkgType.ForwardFakePrivateMessage,
PkgType.PrivateRecordMessage,
PkgType.PrivateFileMessage,
],
decodeFriendMessage,
);
const log = createLogger('MsgPush');
function messageElements(ev: QQEventVariant): readonly unknown[] | undefined {
if (ev.kind === 'friend_message' || ev.kind === 'group_message' || ev.kind === 'temp_message') {
return ev.elements;
}
return undefined;
}
function warnMissingC2cSequence(ev: QQEventVariant, fromUin: number): void {
if (
(ev.kind === 'friend_message' || ev.kind === 'temp_message') &&
ev.sequenceAuthoritative === false
) {
log.warn(
'c2c message omitted NT sequence; keeping non-authoritative client sequence (kind=%s from=%d clientSeq=%d time=%d)',
ev.kind,
fromUin,
ev.clientSeq ?? ev.msgSeq,
ev.time,
);
}
}
export function parseMsgPush(
pkt: PacketInfo,
identity: IdentityService,
dedup?: SysMsgDedup,
): QQEventVariant[] {
return parseMsgPushInternal(pkt, identity, dedup, false);
}
export function parseMsgPushOrThrow(
pkt: PacketInfo,
identity: IdentityService,
dedup?: SysMsgDedup,
): QQEventVariant[] {
return parseMsgPushInternal(pkt, identity, dedup, true);
}
function parseMsgPushInternal(
pkt: PacketInfo,
identity: IdentityService,
dedup: SysMsgDedup | undefined,
throwDecoderErrors: boolean,
): QQEventVariant[] {
const ctx = buildContext(pkt, identity);
if (!ctx) {
log.trace(() => [
'packet_branch serviceCmd=%j seqId=%d branch=push_context_invalid',
pkt.serviceCmd,
pkt.seqId,
]);
return [];
}
// #137/#266: mirror QQ NT `sys_msg_mgr.cc::ProcessRecvSysMsg` before
// dispatch. Its static routing table is the scope boundary: listed routes
// use the native global key, while unlisted routes fail open. Running this
// before registry decode also covers native system packets whose outer type
// SnowLuma otherwise interprets as a chat message or does not decode at all.
if (dedup) {
const dedupIdentity = deriveSysMsgDedupIdentity(ctx);
if (dedupIdentity && dedup.seenDuplicate(dedupIdentity)) {
log.trace(() => [
'packet_branch serviceCmd=%j seqId=%d branch=duplicate_system_push peer=%j chatType=%d messageSeq=%d messageRandom=%d',
pkt.serviceCmd,
pkt.seqId,
dedupIdentity.peerUid,
dedupIdentity.chatType,
dedupIdentity.sequence,
dedupIdentity.random,
]);
log.debug(
'dropped duplicate system push (peer=%s chatType=%d seq=%d msgType=%d/%d msgId=%d)',
dedupIdentity.peerUid,
dedupIdentity.chatType,
ctx.head.sequence,
ctx.head.msgType,
ctx.head.subType,
ctx.head.msgId,
);
return [];
}
}
const events = throwDecoderErrors ? registry.decodeOrThrow(ctx) : registry.decode(ctx);
const out = events.filter((ev) => {
const elements = messageElements(ev);
if (elements === undefined) {
return true;
}
const survival = classifyMessageSurvival(ctx.head, elements, ctx.body);
if (survival === 'drop-control') {
log.trace(() => [
'packet_branch serviceCmd=%j seqId=%d branch=c2c_control_push msgType=%d subType=%d c2cCmd=%d messageSeq=%d',
pkt.serviceCmd,
pkt.seqId,
ctx.head.msgType,
ctx.head.subType,
ctx.head.c2cCmd,
ctx.head.sequence,
]);
if (elements.length > 0) {
log.debug(
'dropped c2c control push that carried %d element(s) (kind=%s seq=%d from=%d msgType=%d cmd=%d)',
elements.length,
ev.kind,
ctx.head.sequence,
ctx.fromUin,
ctx.head.msgType,
ctx.head.c2cCmd,
);
}
return false;
}
if (survival === 'drop-receipt') {
log.trace(() => [
'packet_branch serviceCmd=%j seqId=%d branch=offline_file_receipt msgType=%d subType=%d messageSeq=%d',
pkt.serviceCmd,
pkt.seqId,
ctx.head.msgType,
ctx.head.subType,
ctx.head.sequence,
]);
log.debug(
'dropped offline file receipt (kind=%s seq=%d from=%d msgType=%d)',
ev.kind,
ctx.head.sequence,
ctx.fromUin,
ctx.head.msgType,
);
return false;
}
if (survival === 'drop-blank') {
log.trace(() => [
'packet_branch serviceCmd=%j seqId=%d branch=empty_message msgType=%d subType=%d messageSeq=%d',
pkt.serviceCmd,
pkt.seqId,
ctx.head.msgType,
ctx.head.subType,
ctx.head.sequence,
]);
return false;
}
if (survival === 'keep-undecoded') {
warnUndecodedMessage({
kind: ev.kind,
sequence: ctx.head.sequence,
fromUin: ctx.fromUin,
msgType: ctx.head.msgType,
subType: ctx.head.subType,
body: ctx.body,
});
}
warnMissingC2cSequence(ev, ctx.fromUin);
return true;
});
return out;
}
@@ -0,0 +1,15 @@
import type { SendPacketResult } from '@snowluma/common/packet-sender';
export function requirePacketResponse(result: SendPacketResult, command: string): Uint8Array {
if (!result.success) {
throw new Error(
`${command} transport failed: ${result.errorMessage || 'unknown transport error'}`,
);
}
if (!result.gotResponse || !result.responseData || result.responseData.length === 0) {
throw new Error(`${command} response is empty`);
}
return result.responseData;
}
@@ -0,0 +1,83 @@
import { createLogger } from '@snowluma/common/logger';
import type { QQEventVariant } from '../events';
import type { MsgPushContext } from './context';
import type { PkgType } from './enums';
const log = createLogger('MsgPush');
const unknownLog = createLogger('MsgPush.Unknown');
export type MsgPushDecoder = (ctx: MsgPushContext) => QQEventVariant[];
export class MsgPushRegistry {
private readonly decoders_ = new Map<PkgType, MsgPushDecoder>();
register(pkgType: PkgType | PkgType[], decoder: MsgPushDecoder): void {
const list = Array.isArray(pkgType) ? pkgType : [pkgType];
for (const t of list) {
if (this.decoders_.has(t)) {
throw new Error(`MsgPushRegistry: PkgType ${t} already registered`);
}
this.decoders_.set(t, decoder);
}
}
decode(ctx: MsgPushContext): QQEventVariant[] {
try {
return this.runDecoder(ctx);
} catch (e) {
this.traceDecoderException(ctx, e);
log.error(
'decoder error for PkgType=%d subType=%d: %s',
ctx.head.msgType,
ctx.head.subType,
e instanceof Error ? (e.stack ?? e.message) : String(e),
);
return [];
}
}
decodeOrThrow(ctx: MsgPushContext): QQEventVariant[] {
try {
return this.runDecoder(ctx);
} catch (e) {
this.traceDecoderException(ctx, e);
throw e;
}
}
private runDecoder(ctx: MsgPushContext): QQEventVariant[] {
const decoder = this.decoders_.get(ctx.head.msgType as PkgType);
if (!decoder) {
unknownLog.trace(() => [
'packet_branch branch=decoder_unregistered msgType=%d subType=%d messageSeq=%d',
ctx.head.msgType,
ctx.head.subType,
ctx.head.sequence,
]);
// Unrecognized PkgType: either we don't care about this notify, or
// the QQ NT client added a new variant. Surface at debug so a
// protocol change can be spotted without spamming production logs.
unknownLog.debug(
'no decoder for PkgType=%d subType=%d',
ctx.head.msgType,
ctx.head.subType,
);
return [];
}
return decoder(ctx);
}
private traceDecoderException(ctx: MsgPushContext, error: unknown): void {
log.trace(() => [
'packet_branch branch=decoder_exception msgType=%d subType=%d messageSeq=%d error=%j',
ctx.head.msgType,
ctx.head.subType,
ctx.head.sequence,
error instanceof Error ? error.message : String(error),
]);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,117 @@
import type { MsgPushContext } from './context';
export type SysMsgChatType = 1 | 2;
export interface SysMsgDedupIdentity {
readonly peerUid: string;
readonly chatType: SysMsgChatType;
readonly sequence: number;
readonly random: number;
}
function getNativeSysMsgChatType(msgType: number, subType: number): SysMsgChatType | null {
if (msgType === 528 && subType === 290) {
return 1;
}
if (
msgType === 166 &&
(subType === 75 || subType === 129 || subType === 131 || subType === 133 || subType === 135)
) {
return 1;
}
if (msgType === 167 && subType === 133) {
return 1;
}
if (msgType === 732 && (subType === 12 || subType === 16 || subType === 20)) {
return 2;
}
if ((msgType === 33 || msgType === 34 || msgType === 85) && subType === 0) {
return 2;
}
return null;
}
/**
* Derive the identity used by QQ NT's system-message dedup. Only the static
* `(msgType, subType)` routes proven in `sys_msg_mgr.cc` participate; unknown
* routes fail open rather than risking suppression of unrelated events.
*/
export function deriveSysMsgDedupIdentity(
ctx: Pick<MsgPushContext, 'head' | 'fromUin' | 'fromUid'>,
): SysMsgDedupIdentity | null {
const { head } = ctx;
const chatType = getNativeSysMsgChatType(head.msgType, head.subType);
// Decoded zero is also the protobuf default for an absent field. Without a
// complete server identity, prefer a duplicate event over suppressing two
// unrelated pushes that collapse to the same default-valued key.
if (chatType === null || head.sequence === 0 || head.msgId === 0) {
return null;
}
const peerUid = chatType === 1 ? ctx.fromUid : String(ctx.fromUin);
if (!peerUid || (chatType === 2 && ctx.fromUin === 0)) {
return null;
}
return {
peerUid,
chatType,
sequence: head.sequence,
random: head.msgId,
};
}
/**
* Mirrors QQ NT's system-message dedup so SnowLuma stops double-reporting raw
* OlPush system events that the QQ kernel would discard before notifying its
* listeners.
*
* macOS QQ 6.9.98 `sys_msg_mgr.cc::ProcessRecvSysMsg` (`sub_E4B94A`) builds:
*
* global_key = `{peerUid}_{chatType}_{msg_seq}_{random}`
*
* `sub_E523C8` maps content-head fields 5 and 4 to `msg_seq` and `random`.
* `sub_E4B30D` supplies chatType from a static `(msgType, subType)` table, and
* `sub_E4B62A` selects response-head `fromUid` for C2C or decimal `fromUin` for
* groups. {@link deriveSysMsgDedupIdentity} mirrors those routes exactly.
*/
export class SysMsgDedup {
private readonly seen = new Set<string>();
private readonly ring: (string | undefined)[];
private cursor = 0;
constructor(private readonly capacity = 1024) {
this.ring = new Array<string | undefined>(capacity);
}
/** Returns true when this native system-message identity was already seen. */
seenDuplicate(identity: SysMsgDedupIdentity): boolean {
if (identity.sequence === 0 || identity.random === 0 || !identity.peerUid) {
return false;
}
const key = `${identity.peerUid}_${identity.chatType}_${identity.sequence}_${identity.random}`;
if (this.seen.has(key)) {
return true;
}
const evicted = this.ring[this.cursor];
if (evicted !== undefined) {
this.seen.delete(evicted);
}
this.ring[this.cursor] = key;
this.seen.add(key);
this.cursor = (this.cursor + 1) % this.capacity;
return false;
}
}
+121
View File
@@ -0,0 +1,121 @@
const NAME_START =
':A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D' +
'\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF' +
'\\uF900-\\uFDCF\\uFDF0-\\uFFFD\\u{10000}-\\u{EFFFF}';
const ATTRIBUTE = new RegExp(
`([${NAME_START}][\\u0300-\\u036F${NAME_START}\\-.0-9\\u00B7\\u203F-\\u2040]*)` +
`[\\t\\r\\n ]*=[\\t\\r\\n ]*(?:"([^"<]*)"|'([^'<]*)')`,
'uy',
);
const SPACE = /[\t\r\n ]*/y;
const ENTITIES = new Map([
['amp', '&'],
['lt', '<'],
['gt', '>'],
['quot', '"'],
['apos', "'"],
]);
function decodeAttribute(value: string): string | undefined {
let valid = true;
// Literal whitespace is normalized before expanding character references.
const decoded = value
.replace(/\r\n|[\t\r\n]/g, ' ')
.replace(
/&(#x[0-9a-fA-F]+|#[0-9]+|amp|lt|gt|quot|apos);|&/g,
(_match, entity: string | undefined) => {
const named = entity === undefined ? undefined : ENTITIES.get(entity);
if (named !== undefined) {
return named;
}
if (entity?.startsWith('#')) {
const code = entity.startsWith('#x')
? Number.parseInt(entity.slice(2), 16)
: Number.parseInt(entity.slice(1), 10);
if (
code === 9 ||
code === 10 ||
code === 13 ||
(code >= 0x20 && code <= 0xd7ff) ||
(code >= 0xe000 && code <= 0xfffd) ||
(code >= 0x10000 && code <= 0x10ffff)
) {
return String.fromCodePoint(code);
}
}
valid = false;
return '';
},
);
return valid ? decoded : undefined;
}
/** Read the first element's attributes without searching comments, values or descendants. */
export function readXmlMsgAttributes(content: string): ReadonlyMap<string, string> | undefined {
let cursor = content.startsWith('\uFEFF') ? 1 : 0;
for (;;) {
SPACE.lastIndex = cursor;
SPACE.exec(content);
cursor = SPACE.lastIndex;
const closing = content.startsWith('<!--', cursor)
? '-->'
: content.startsWith('<?', cursor)
? '?>'
: undefined;
if (!closing) {
break;
}
const end = content.indexOf(closing, cursor + (closing === '-->' ? 4 : 2));
if (end < 0) {
return undefined;
}
cursor = end + closing.length;
}
if (!content.startsWith('<msg', cursor)) {
return undefined;
}
cursor += 4;
const attributes = new Map<string, string>();
for (;;) {
const beforeSpace = cursor;
SPACE.lastIndex = cursor;
SPACE.exec(content);
cursor = SPACE.lastIndex;
if (content[cursor] === '>' || content.startsWith('/>', cursor)) {
return attributes;
}
if (cursor === beforeSpace) {
return undefined;
}
ATTRIBUTE.lastIndex = cursor;
const match = ATTRIBUTE.exec(content);
if (!match || attributes.has(match[1]!)) {
return undefined;
}
const value = decodeAttribute(match[2] ?? match[3]!);
if (value === undefined) {
return undefined;
}
attributes.set(match[1]!, value);
cursor = ATTRIBUTE.lastIndex;
}
}
+225
View File
@@ -0,0 +1,225 @@
// OIDB service layer — the next architectural revision on top of the
// area-grouped Api facade pattern (InteractionApi / ProfileApi / …).
//
// Each OIDB cmd is modelled as a TypeScript namespace whose exported
// members ARE the `OidbCallSpec` shape (structural typing makes this
// implicit — no `implements` clause needed). The namespace also exports
// a thin `invoke()` that hands itself to `invokeOidb` for dispatch.
//
// Why namespace-as-spec instead of class:
// - stateless: no `new` overhead at the call site
// - no `this` binding, no implicit lifecycle
// - tree-shakes per-export (unused services drop out of the bundle)
// - spec ↔ code 1:1 (one file = one (cmd, subcmd) tuple)
//
// Why Pick-style minimal `Deps` per service:
// - Interface Segregation: a wire-only cmd doesn't see identity/events
// - tests mock only what's used (typically just `sendRawPacket`)
// - changes to BridgeContext don't bleed into services
// - the dep list IS the documentation of side-effects
//
// Why `ctx` threads into serialize/deserialize:
// - the namespace is then genuinely self-contained — "what does this
// cmd do on the wire + what state does it touch" lives in one file
// - earlier shape forced an `invoke = { ...Self, serialize: p =>
// localSerialize(p, await ctx.resolveUserUid(...)) }` rewrite for
// every cmd that needed identity/uid resolution; ctx-aware
// serialize collapses that to a single async serialize.
// - serialize is now `TReq | Promise<TReq>` so async lookups
// (resolveUserUid, identity reads) happen in place.
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase, OidbBaseMeta } from '@snowluma/proto-defs/oidb';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { makeOidbEnvelope } from './bridge-oidb';
/**
* Capability narrow enough for any wire-only OIDB call. Anything that
* also needs identity / event bus / upload cache declares those via
* `Pick<BridgeContext, 'identity' | …>` and intersects with this.
*/
export interface OidbSender {
sendRawPacket(
serviceCmd: string,
body: Uint8Array,
timeoutMs?: number,
): Promise<SendPacketResult>;
}
/**
* The shape every cmd namespace exports. Structural typing means a
* namespace whose top-level exports match these names + types IS an
* `OidbCallSpec` — no `implements` clause needed.
*
* `TCtx` is the namespace's capability surface — always at least
* `OidbSender` (so `invokeOidb` can dispatch through it) plus any
* `Pick<BridgeContext, ...>` slices the cmd actually needs.
*
* One of `subCommand` (static) or `resolveSubCommand` (dynamic from
* params and context) must be present. `resolveSubCommand` wins if both
* are given. `resolveCommand` likewise wins over static `command`.
*/
export interface OidbCallSpec<TCtx extends OidbSender, TReq, TResp, TParams, TResult> {
command: number;
resolveCommand?(params: TParams, ctx: TCtx): number;
subCommand?: number;
resolveSubCommand?(params: TParams, ctx: TCtx): number;
/** Packet and body envelope codes treated as success in addition to 0. */
acceptedEnvelopeCodes?: readonly number[];
/** Set OIDB envelope `reserved = 1` (UIN-form variant, see makeOidbEnvelope's isUid). */
uinForm?: boolean;
/** Override the default `OidbSvcTrpcTcp.0xNNNN_N` wire name. A few
* cmds route through different SSO names (e.g. 0xE17_0 lives at
* `MQUpdateSvc_com_qq_ti.web.OidbSvc.0xe17_0`); supply a function
* that takes the resolved `(cmd, subCmd)` and returns the actual
* wire name. Omit for the default scheme. */
wireName?(command: number, subCommand: number): string;
/** Business params → wire-shaped request body. May be async (e.g.
* when ctx.resolveUserUid hits the cache or the network). */
serialize(ctx: TCtx, params: TParams): TReq | Promise<TReq>;
/** Wire-shaped response body → business result. */
deserialize(ctx: TCtx, body: TResp): TResult;
/** Per-type protobuf encoder — concrete generic call so the Vite plugin can monomorphize. */
encode(env: OidbBase<TReq>): Uint8Array;
/** Per-type protobuf decoder. */
decode(bytes: Uint8Array): OidbBase<TResp>;
}
function resolveOidbCall<TCtx extends OidbSender, TReq, TResp, TParams, TResult>(
spec: OidbCallSpec<TCtx, TReq, TResp, TParams, TResult>,
params: TParams,
ctx: TCtx,
): { command: number; subCommand: number } {
const command = spec.resolveCommand ? spec.resolveCommand(params, ctx) : spec.command;
const subCommand = spec.resolveSubCommand
? spec.resolveSubCommand(params, ctx)
: spec.subCommand!;
return { command, subCommand };
}
function isAcceptedEnvelopeCode<TCtx extends OidbSender, TReq, TResp, TParams, TResult>(
spec: OidbCallSpec<TCtx, TReq, TResp, TParams, TResult>,
code: number | null | undefined,
): boolean {
if (code == null || code === 0) {
return true;
}
return spec.acceptedEnvelopeCodes?.includes(code) === true;
}
export class OidbError extends Error {
constructor(
readonly code: number,
readonly serverMsg: string,
readonly command: number,
readonly subCommand: number,
) {
super(`OIDB error ${code} on 0x${command.toString(16)}_${subCommand}: ${serverMsg}`);
this.name = 'OidbError';
}
}
/**
* Template method for every OIDB call. Builds the envelope, encodes it,
* sends through the ctx's `sendRawPacket`, validates the envelope's
* `errorCode`, decodes the body, and hands the wire-shaped response to
* the spec's `deserialize` for transformation into the business result.
*
* `ctx` is threaded into both `serialize` and `deserialize` so the
* namespace can do uid resolution / identity reads in-place rather
* than forcing the caller to pre-bake values into the params.
*/
export async function invokeOidb<TCtx extends OidbSender, TReq, TResp, TParams, TResult>(
ctx: TCtx,
spec: OidbCallSpec<TCtx, TReq, TResp, TParams, TResult>,
params: TParams,
timeoutMs?: number,
): Promise<TResult> {
const { command, subCommand } = resolveOidbCall(spec, params, ctx);
const reqBody = await spec.serialize(ctx, params);
const env = makeOidbEnvelope(command, subCommand, reqBody, spec.uinForm ?? false);
const reqBytes = spec.encode(env);
const wireName = spec.wireName
? spec.wireName(command, subCommand)
: `OidbSvcTrpcTcp.0x${command.toString(16)}_${subCommand}`;
const result =
timeoutMs === undefined
? await ctx.sendRawPacket(wireName, reqBytes)
: await ctx.sendRawPacket(wireName, reqBytes, timeoutMs);
if (!result.gotResponse) {
throw new Error(result.errorMessage || 'no response');
}
if (!result.success) {
if (result.errorCode && result.errorCode !== 0) {
if (!isAcceptedEnvelopeCode(spec, result.errorCode)) {
throw new OidbError(
result.errorCode,
result.errorMessage || '',
command,
subCommand,
);
}
} else {
// success=false without an OIDB error code = the packet send itself failed;
// surface it instead of silently resolving with an undecoded response.
throw new Error(result.errorMessage || 'packet send failed');
}
}
const respBytes = result.responseData ?? new Uint8Array(0);
if (respBytes.length > 0) {
const meta = protobuf_decode<OidbBaseMeta>(respBytes);
const code = meta?.errorCode;
if (code && code !== 0 && !isAcceptedEnvelopeCode(spec, code)) {
throw new OidbError(code, meta?.errorMsg ?? '', command, subCommand);
}
}
// Server may legitimately respond with an empty envelope (ack-only
// cmds like 0x9082_1 — the response carries only `errorCode = 0`
// and no body). Substitute an empty placeholder so `deserialize`
// always receives a defined object; specs that read fields off the
// body must already guard with `?? []` etc.
const respBody = spec.decode(respBytes).body ?? ({} as TResp);
return spec.deserialize(ctx, respBody);
}
/**
* Build the request wire bytes for a spec without sending — useful for
* wire dump debugging, capability probing, and unit tests of the
* encode path. Threads `ctx` because `serialize` may want it (and
* because matching `invokeOidb`'s shape avoids two call patterns).
*/
export async function buildOidbRequest<TCtx extends OidbSender, TReq, TResp, TParams, TResult>(
ctx: TCtx,
spec: OidbCallSpec<TCtx, TReq, TResp, TParams, TResult>,
params: TParams,
): Promise<{ wireName: string; bytes: Uint8Array }> {
const { command, subCommand } = resolveOidbCall(spec, params, ctx);
const reqBody = await spec.serialize(ctx, params);
const env = makeOidbEnvelope(command, subCommand, reqBody, spec.uinForm ?? false);
const bytes = spec.encode(env);
const wireName = spec.wireName
? spec.wireName(command, subCommand)
: `OidbSvcTrpcTcp.0x${command.toString(16)}_${subCommand}`;
return { wireName, bytes };
}
/**
* Decode raw wire bytes into the business result via a spec's
* deserialize path — symmetric debug helper for buildOidbRequest.
*/
export function parseOidbResponse<TCtx extends OidbSender, TReq, TResp, TParams, TResult>(
ctx: TCtx,
spec: OidbCallSpec<TCtx, TReq, TResp, TParams, TResult>,
bytes: Uint8Array,
): TResult {
return spec.deserialize(ctx, spec.decode(bytes).body ?? ({} as TResp));
}
@@ -0,0 +1,54 @@
// 0x9067_202 — fetch download-rkey tokens for private/group/fallback
// image scopes. The rkey is a server-issued signed download token used
// to authenticate every CDN GET against `gchat.qpic.cn` / similar
// endpoints.
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { NTV2RichMediaReq, NTV2RichMediaResp } from '@snowluma/proto-defs/oidb-actions/media';
import { invokeOidb, type OidbSender } from '../../oidb-service';
const PRIVATE_IMAGE = 10;
const GROUP_IMAGE = 20;
const FALLBACK_IMAGE = 2;
export namespace FetchDownloadRkeys {
export const command = 0x9067;
export const subCommand = 202;
export const uinForm = true;
export interface Params {}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, _: Params): NTV2RichMediaReq => ({
reqHead: {
common: { requestId: 1, command: 202 },
scene: { requestType: 2, businessType: 1, sceneType: 0 },
client: { agentType: 2 },
},
downloadRkey: {
types: [PRIVATE_IMAGE, GROUP_IMAGE, FALLBACK_IMAGE],
},
});
export const deserialize = (_ctx: Deps, body: NTV2RichMediaResp): NTV2RichMediaResp => {
// Propagate server-side OIDB-equivalent error via the embedded
// respHead.retCode — bytes-level OIDB errorCode would already
// have thrown OidbError before deserialize was called.
if (body.respHead?.retCode && body.respHead.retCode !== 0) {
throw new Error(body.respHead.message ?? 'fetch download rkey failed');
}
return body;
};
export const encode = (env: OidbBase<NTV2RichMediaReq>): Uint8Array =>
protobuf_encode<OidbBase<NTV2RichMediaReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<NTV2RichMediaResp> =>
protobuf_decode<OidbBase<NTV2RichMediaResp>>(bytes);
export const invoke = (deps: Deps): Promise<NTV2RichMediaResp> =>
invokeOidb(deps, FetchDownloadRkeys, {});
}
@@ -0,0 +1,135 @@
// 0xFD4_1 — single-page friend-roster fetch.
//
// QQ paginates the friend list with an opaque wire-level cookie.
// This namespace handles ONE round-trip — the facade drives the
// while-loop until the server stops emitting a cookie and
// concatenates results into the public FriendInfo[] shape.
import type {
OidbBase,
OidbFriend,
OidbSvcTrpcTcp0xFD4_1Response,
} from '@snowluma/proto-defs/oidb';
import type { OidbFriendListRequest } from '@snowluma/proto-defs/oidb-actions/base';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { FriendInfo } from '../../qq-info';
import { invokeOidb, type OidbSender } from '../../oidb-service';
import { decodePublicProfile, PUBLIC_PROFILE_KEYS } from './public-profile';
export interface FriendListPageEntry {
friend: FriendInfo;
categoryId: number;
}
export interface FriendListPageCategory {
categoryId: number;
categoryName: string;
memberCount: number;
sortId: number;
}
export interface FriendListPage {
entries: FriendListPageEntry[];
categories: FriendListPageCategory[];
cookie?: Uint8Array;
}
function friendProperties(raw: OidbFriend) {
const props = new Map<number, Uint8Array>();
const numbers = new Map<number, number>();
for (const additional of raw.additional ?? []) {
if ((additional.type ?? 0) !== 1 || !additional.layer1) {
continue;
}
for (const property of additional.layer1.properties ?? []) {
props.set(property.code ?? 0, property.value ?? new Uint8Array(0));
}
for (const property of additional.layer1.numberProperties ?? []) {
numbers.set(property.key ?? 0, property.value ?? 0);
}
}
return { props, numbers };
}
function mapFriend(raw: OidbFriend): FriendListPageEntry {
const { props, numbers } = friendProperties(raw);
const profile = decodePublicProfile(props, numbers);
return {
categoryId: raw.customGroup ?? 0,
friend: {
uin: raw.uin ?? 0,
uid: raw.uid ?? '',
nickname: props.has(20002)
? Buffer.from(props.get(20002)!).toString('utf8')
: String(raw.uin ?? 0),
remark: Buffer.from(props.get(103) ?? []).toString('utf8'),
categoryId: raw.customGroup ?? 0,
...(Object.keys(profile).length ? { profile } : {}),
},
};
}
export namespace FetchFriendListPage {
export const command = 0xfd4;
export const subCommand = 1;
export interface Params {
/** Opaque cookie returned by the previous page. Omit on page one. */
cookie?: Uint8Array;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): OidbFriendListRequest => {
const body: OidbFriendListRequest = {
friendCount: 300,
field4: 0,
field6: 1,
field7: 0x7fffffff,
body: [
{
type: 1,
number: {
numbers: [...new Set([103, 102, 20002, 27394, ...PUBLIC_PROFILE_KEYS])],
},
},
{ type: 4, number: { numbers: [100, 101, 102] } },
],
field10002: [13578, 13579, 13573, 13572, 13568],
field10003: 4051,
};
if (p.cookie?.length) {
body.cookie = p.cookie;
}
return body;
};
export const deserialize = (
_ctx: Deps,
body: OidbSvcTrpcTcp0xFD4_1Response,
): FriendListPage => ({
entries: (body.friends ?? []).map(mapFriend),
categories: (body.categories ?? []).map((raw) => ({
categoryId: raw.categoryId ?? 0,
categoryName: raw.categoryName ?? '',
memberCount: raw.memberCount ?? 0,
sortId: raw.sortId ?? 0,
})),
cookie: body.cookie?.length ? Uint8Array.from(body.cookie) : undefined,
});
export const encode = (env: OidbBase<OidbFriendListRequest>): Uint8Array =>
protobuf_encode<OidbBase<OidbFriendListRequest>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbSvcTrpcTcp0xFD4_1Response> =>
protobuf_decode<OidbBase<OidbSvcTrpcTcp0xFD4_1Response>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<FriendListPage> =>
invokeOidb(deps, FetchFriendListPage, params);
}
@@ -0,0 +1,71 @@
// 0x88D_0 — fetch a single group's detail by group uin.
//
// Unlike 0xFE5_2 (the bot's joined-groups list), this resolves ANY group by id,
// including ones the bot hasn't joined — used to put a name on a group invite
// (where the invite push itself carries no group name). The `flags` block is a
// request mask: a present bool(true)/string("") asks the server to include that
// field in the response. Envelope is reserved=0 (NO uinForm), unlike 0xFE5_2.
// Mute expire request/response tag is 45 (official 60027), not Lagrange's 59.
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase, OidbSvcTrpcTcp0x88D_0Response } from '@snowluma/proto-defs/oidb';
import type { OidbGroupDetailRequest } from '@snowluma/proto-defs/oidb-actions/base';
import { invokeOidb, type OidbSender } from '../../oidb-service';
export namespace FetchGroupDetail {
export const command = 0x88d;
export const subCommand = 0;
// NOTE: deliberately no `uinForm` — Lagrange sends this with reserved=0.
export interface Params {
groupUin: number;
}
export type Deps = OidbSender;
const on = true;
export const serialize = (_ctx: Deps, p: Params): OidbGroupDetailRequest => ({
field1: 537099973,
config: {
uin: BigInt(p.groupUin),
// Mirror Lagrange's flag set so the server returns the full detail block.
flags: {
ownerUid: on,
createTime: on,
maxMemberCount: on,
memberCount: on,
level: on,
name: '',
noticePreview: '',
uin: on,
lastSequence: on,
lastMessageTime: on,
question: '',
answer: '',
maxAdminCount: '',
// Tag 45 (not Lagrange's 59): official request mask for 60027.
shutUpAllTimestamp: on,
addType: on,
privilegeFlag: on,
groupFlagExt4: on,
noFingerOpen: on,
noCodeFingerOpen: on,
},
},
});
export const deserialize = (
_ctx: Deps,
body: OidbSvcTrpcTcp0x88D_0Response,
): OidbSvcTrpcTcp0x88D_0Response => body;
export const encode = (env: OidbBase<OidbGroupDetailRequest>): Uint8Array =>
protobuf_encode<OidbBase<OidbGroupDetailRequest>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbSvcTrpcTcp0x88D_0Response> =>
protobuf_decode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>(bytes);
export const invoke = (deps: Deps, p: Params): Promise<OidbSvcTrpcTcp0x88D_0Response> =>
invokeOidb(deps, FetchGroupDetail, p);
}
@@ -0,0 +1,91 @@
// 0xFE5_2 — fetch the bot's joined-groups list with full per-group
// info.
//
// The verbose `config` blob in the request body asks QQ which
// per-group fields to include. Asking for everything blows the
// named-pipe write buffer on accounts with ~200+ groups (see #42),
// so we keep `config1.*` mostly on but turn off the costly
// `field5002`/`field5003` and the unused `config2` fields. `config2.remark`
// is the bot-local group remark returned as `customInfo.remark`, so it is the
// sole optional field enabled in that block.
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase, OidbSvcTrpcTcp0xFE5_2Response } from '@snowluma/proto-defs/oidb';
import type { OidbGroupListRequest } from '@snowluma/proto-defs/oidb-actions/base';
import { invokeOidb, type OidbSender } from '../../oidb-service';
export namespace FetchGroupList {
export const command = 0xfe5;
export const subCommand = 2;
export const uinForm = true;
export interface Params {}
export type Deps = OidbSender;
const allTrue = true;
export const serialize = (_ctx: Deps, _: Params): OidbGroupListRequest => ({
config: {
config1: {
groupOwner: allTrue,
field2: allTrue,
memberMax: allTrue,
memberCount: allTrue,
groupName: allTrue,
field8: allTrue,
field9: allTrue,
field10: allTrue,
field11: allTrue,
field12: allTrue,
field13: allTrue,
field14: allTrue,
field15: allTrue,
field16: allTrue,
field17: allTrue,
field18: allTrue,
question: allTrue,
field20: allTrue,
field22: allTrue,
field23: allTrue,
field24: allTrue,
field25: allTrue,
field26: allTrue,
field27: allTrue,
field28: allTrue,
field29: allTrue,
field30: allTrue,
field31: allTrue,
field32: allTrue,
field5001: allTrue,
field5002: false,
field5003: false,
},
config2: {
field1: false,
field2: false,
remark: true,
field4: false,
field5: false,
field6: false,
field7: false,
field8: false,
},
config3: { field5: allTrue, field6: allTrue },
},
});
export const deserialize = (
_ctx: Deps,
body: OidbSvcTrpcTcp0xFE5_2Response,
): OidbSvcTrpcTcp0xFE5_2Response => body;
export const encode = (env: OidbBase<OidbGroupListRequest>): Uint8Array =>
protobuf_encode<OidbBase<OidbGroupListRequest>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbSvcTrpcTcp0xFE5_2Response> =>
protobuf_decode<OidbBase<OidbSvcTrpcTcp0xFE5_2Response>>(bytes);
export const invoke = (deps: Deps): Promise<OidbSvcTrpcTcp0xFE5_2Response> =>
invokeOidb(deps, FetchGroupList, {});
}
@@ -0,0 +1,108 @@
// 0xFE7_3 — single-page group-member-roster fetch.
//
// Pagination cursor is the server-issued `token` string. The facade
// drives the loop until `token` comes back empty; per-group caching
// + inflight coalescing also stays on the facade since both need
// state that's per-Bridge (and is what stops Tencent risk-control
// from banning the account on busy clients — see #42).
import type { OidbBase, OidbSvcTrpcTcp0xFE7_3Response } from '@snowluma/proto-defs/oidb';
import type { OidbGroupMemberListRequest } from '@snowluma/proto-defs/oidb-actions/base';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import { decodeGroupMemberCard } from '../../group-member-card';
import type { GroupMemberInfo } from '../../qq-info';
import { invokeOidb, type OidbSender } from '../../oidb-service';
export interface GroupMemberListPage {
members: GroupMemberInfo[];
token: string;
}
function permissionToRole(permission: number): string {
switch (permission) {
case 1:
return 'owner';
case 2:
return 'admin';
default:
return 'member';
}
}
export namespace FetchGroupMemberListPage {
export const command = 0xfe7;
export const subCommand = 3;
export interface Params {
groupId: number;
/** Empty string for the first page; pass the previous response's
* `token` to fetch the next page. */
token: string;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): OidbGroupMemberListRequest => {
const body: OidbGroupMemberListRequest = {
groupUin: p.groupId,
field2: 5,
field3: 2,
body: {
memberName: true,
memberCard: true,
level: true,
field13: true,
field16: true,
specialTitle: true,
field18: true,
field20: true,
field21: true,
joinTimestamp: true,
lastMsgTimestamp: true,
shutUpTimestamp: true,
field103: true,
field104: true,
field105: true,
field106: true,
permission: true,
field200: true,
field201: true,
},
};
if (p.token) {
body.token = p.token;
}
return body;
};
export const deserialize = (
_ctx: Deps,
body: OidbSvcTrpcTcp0xFE7_3Response,
): GroupMemberListPage => ({
members: (body.members ?? []).map((raw) => ({
uin: raw.uin?.uin ?? 0,
uid: raw.uin?.uid ?? '',
nickname: raw.memberName ?? '',
card: decodeGroupMemberCard(raw.memberCard?.memberCard),
isRobot: false,
role: permissionToRole(raw.permission ?? 0),
level: raw.level?.level ?? 0,
title: raw.specialTitle ?? '',
joinTime: raw.joinTimestamp ?? 0,
lastSentTime: raw.lastMsgTimestamp ?? 0,
shutUpTime: raw.shutUpTimestamp ?? 0,
})),
token: body.token ?? '',
});
export const encode = (env: OidbBase<OidbGroupMemberListRequest>): Uint8Array =>
protobuf_encode<OidbBase<OidbGroupMemberListRequest>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbSvcTrpcTcp0xFE7_3Response> =>
protobuf_decode<OidbBase<OidbSvcTrpcTcp0xFE7_3Response>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<GroupMemberListPage> =>
invokeOidb(deps, FetchGroupMemberListPage, params);
}
@@ -0,0 +1,205 @@
// 0x10C0 — fetch pending group-add requests.
// subCommand 1 = main inbox, 2 = filtered (low-priority) inbox
//
// Current QQ uses the UIN-form envelope for the native list path, but the
// per-user record still carries a string account on field 1. Decode both
// shapes and merge so neither identifier is dropped. The UID-form request
// remains necessary for correlating UID-only real-time pushes.
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type {
OidbBase,
OidbSvcTrpcTcp0x10C0Response,
OidbSvcTrpcTcp0x10C0ResponseByUin,
OidbSvcTrpcTcp0x10C0ResponseRequest,
OidbSvcTrpcTcp0x10C0ResponseRequestByUin,
OidbSvcTrpcTcp0x10C0ResponseUser,
OidbSvcTrpcTcp0x10C0ResponseUserByUin,
} from '@snowluma/proto-defs/oidb';
import type { OidbGroupRequestList } from '@snowluma/proto-defs/oidb-actions/base';
import { invokeOidb, type OidbSender } from '../../oidb-service';
// Field 2 in a list entry and in its approval uses the same wire value.
// Only the client-facing notification type needs translation.
const GROUP_REQUEST_NOTIFY_TYPES = new Map<number, number>([
[0, 0],
[1, 7],
[2, 1],
[3, 8],
[6, 9],
[7, 10],
[8, 16],
[10, 2],
[11, 3],
[12, 4],
[13, 11],
[15, 12],
[16, 13],
[17, 14],
[19, 15],
[22, 5],
[35, 6],
[100, 17],
]);
export function groupRequestNotifyType(eventType: number): number | undefined {
return GROUP_REQUEST_NOTIFY_TYPES.get(eventType);
}
export interface DecodedGroupRequestUser {
uid: string;
uin: number;
name: string;
}
export interface DecodedGroupRequest {
sequence?: bigint;
eventType?: number;
state?: number;
group?: { groupUin?: number; groupName?: string };
target?: DecodedGroupRequestUser;
invitor?: DecodedGroupRequestUser;
operatorUser?: DecodedGroupRequestUser;
comment?: string;
operateTransInfo?: Uint8Array;
}
export interface DecodedGroupRequestList {
requests?: DecodedGroupRequest[];
field2?: bigint;
newLatestSeq?: bigint;
field4?: number;
field5?: bigint;
field6?: number;
}
function mergeRequestUser(
byUin?: OidbSvcTrpcTcp0x10C0ResponseUserByUin,
byUid?: OidbSvcTrpcTcp0x10C0ResponseUser,
): DecodedGroupRequestUser {
return {
uid: byUid?.uid ?? '',
uin: byUin?.uin ?? 0,
name: (byUin?.name || byUid?.name) ?? '',
};
}
function mergeRequestItem(
byUin?: OidbSvcTrpcTcp0x10C0ResponseRequestByUin,
byUid?: OidbSvcTrpcTcp0x10C0ResponseRequest,
): DecodedGroupRequest {
const src = byUin ?? byUid;
return {
sequence: src?.sequence,
eventType: src?.eventType,
state: src?.state,
group: src?.group,
target: mergeRequestUser(byUin?.target, byUid?.target),
invitor: mergeRequestUser(byUin?.invitor, byUid?.invitor),
operatorUser: mergeRequestUser(byUin?.operatorUser, byUid?.operatorUser),
comment: src?.comment ?? '',
operateTransInfo: src?.operateTransInfo,
};
}
function mergeRequestList(
byUin?: OidbSvcTrpcTcp0x10C0ResponseByUin,
byUid?: OidbSvcTrpcTcp0x10C0Response,
): DecodedGroupRequestList {
const src = byUin ?? byUid;
const uinItems = byUin?.requests ?? [];
const uidItems = byUid?.requests ?? [];
const count = Math.max(uinItems.length, uidItems.length);
const requests: DecodedGroupRequest[] = [];
for (let i = 0; i < count; i++) {
requests.push(mergeRequestItem(uinItems[i], uidItems[i]));
}
return {
requests,
field2: src?.field2,
newLatestSeq: src?.newLatestSeq,
field4: src?.field4,
field5: src?.field5,
field6: src?.field6,
};
}
export namespace FetchGroupRequests {
export const command = 0x10c0;
export const uinForm = true;
export interface Params {
/** false → subCmd 1 (main inbox), true → subCmd 2 (filtered). */
filtered: boolean;
/** Maximum records in this screen. QQ's own default is 50. */
count?: number;
/** Cursor returned by response field 2; zero starts at the newest screen. */
cursor?: bigint;
}
export type Deps = OidbSender;
export const resolveSubCommand = (p: Params): number => (p.filtered ? 2 : 1);
export const serialize = (_ctx: Deps, p: Params): OidbGroupRequestList => ({
count: p.count ?? 50,
field2: p.cursor ?? 0n,
});
export const deserialize = (
_ctx: Deps,
body: DecodedGroupRequestList,
): DecodedGroupRequestList => body;
export const encode = (env: OidbBase<OidbGroupRequestList>): Uint8Array =>
protobuf_encode<OidbBase<OidbGroupRequestList>>(env);
export const decode = (bytes: Uint8Array): OidbBase<DecodedGroupRequestList> => {
const byUin = protobuf_decode<OidbBase<OidbSvcTrpcTcp0x10C0ResponseByUin>>(bytes);
const byUid = protobuf_decode<OidbBase<OidbSvcTrpcTcp0x10C0Response>>(bytes);
return {
...byUin,
body: mergeRequestList(byUin.body, byUid.body),
};
};
export const invoke = (deps: Deps, params: Params): Promise<DecodedGroupRequestList> =>
invokeOidb(deps, FetchGroupRequests, params);
}
/** UID-form compatibility path used only when an incoming push supplies UID
* but no UIN. It intentionally leaves the envelope's reserved field unset. */
export namespace FetchGroupRequestsByUid {
export const command = 0x10c0;
export interface Params {
filtered: boolean;
count?: number;
cursor?: bigint;
}
export type Deps = OidbSender;
export const resolveSubCommand = (p: Params): number => (p.filtered ? 2 : 1);
export const serialize = (_ctx: Deps, p: Params): OidbGroupRequestList => ({
count: p.count ?? 50,
field2: p.cursor ?? 0n,
});
export const deserialize = (
_ctx: Deps,
body: OidbSvcTrpcTcp0x10C0Response,
): OidbSvcTrpcTcp0x10C0Response => body;
export const encode = (env: OidbBase<OidbGroupRequestList>): Uint8Array =>
protobuf_encode<OidbBase<OidbGroupRequestList>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbSvcTrpcTcp0x10C0Response> =>
protobuf_decode<OidbBase<OidbSvcTrpcTcp0x10C0Response>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<OidbSvcTrpcTcp0x10C0Response> =>
invokeOidb(deps, FetchGroupRequestsByUid, params);
}
@@ -0,0 +1,96 @@
// 0x496_0 — fetch QQ's server-versioned robot UIN ranges.
//
// The 0xFE7_3 group-member response has no robot flag. QQ's own renderer
// initializes its robot service with this read-only request and classifies
// non-buddy UINs against the returned inclusive ranges. Keeping the config
// dynamic avoids baking Tencent's current allocation into SnowLuma.
import type { OidbBase, OidbRobotUinRangeResponse } from '@snowluma/proto-defs/oidb';
import type { OidbRobotUinRangeRequest } from '@snowluma/proto-defs/oidb-actions/base';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import { invokeOidb, type OidbSender } from '../../oidb-service';
export interface RobotUinRange {
minUin: number;
maxUin: number;
}
export interface RobotUinRangeSnapshot {
version: number;
ranges: RobotUinRange[];
}
function decodeUin(value: bigint | number | undefined, label: string): number {
const decoded = Number(value ?? 0);
if (!Number.isSafeInteger(decoded) || decoded <= 0 || decoded > 0xffff_ffff) {
throw new Error(`0x496_0 response has invalid ${label}: ${String(value)}`);
}
return decoded;
}
export namespace FetchRobotUinRanges {
export const command = 0x496;
export const subCommand = 0;
export type Deps = OidbSender;
export const serialize = (): OidbRobotUinRangeRequest => ({
justFetchMsgConfig: 1,
type: 1,
version: 0,
aioKeywordVersion: 0,
});
export const deserialize = (
_ctx: Deps,
body: OidbRobotUinRangeResponse,
): RobotUinRangeSnapshot => {
const config = body.robotConfig;
if (!config) {
throw new Error('0x496_0 response missing robot range config');
}
if (config.version === undefined) {
throw new Error('0x496_0 response missing robot range config version');
}
const version = Number(config.version);
if (!Number.isSafeInteger(version) || version < 0) {
throw new Error(
`0x496_0 response has invalid config version: ${String(config.version)}`,
);
}
if (!config.ranges || config.ranges.length === 0) {
throw new Error('0x496_0 response has no robot UIN ranges');
}
const ranges = config.ranges.map((range, index) => {
const minUin = decodeUin(range.minUin, `range[${index}].minUin`);
const maxUin = decodeUin(range.maxUin, `range[${index}].maxUin`);
if (minUin > maxUin) {
throw new Error(
`0x496_0 response has inverted range[${index}]: ${minUin}-${maxUin}`,
);
}
return { minUin, maxUin };
});
return { version, ranges };
};
export const encode = (env: OidbBase<OidbRobotUinRangeRequest>): Uint8Array =>
protobuf_encode<OidbBase<OidbRobotUinRangeRequest>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbRobotUinRangeResponse> =>
protobuf_decode<OidbBase<OidbRobotUinRangeResponse>>(bytes);
export const invoke = (deps: Deps): Promise<RobotUinRangeSnapshot> =>
invokeOidb(deps, FetchRobotUinRanges, undefined);
}
@@ -0,0 +1,153 @@
// 0xFE1_2 (UID form) — fetch a user's profile when only the UID is
// known. Same OIDB cmd + subcmd as `FetchUserProfile` (by UIN) but
// the request body's field 1 is `uid: string` instead of `uin:
// uint32`, and `reserved` stays 0 (no `uinForm`).
//
// Why two namespaces for the same (cmd, subcmd):
// - Group join requests / invitations carry the stranger's UID
// only — we never get a UIN for someone who hasn't joined yet.
// - The Tencent server WILL reject a UIN-form request with a uid-
// shaped value or vice versa, so the two variants must encode
// to byte-different wires.
// - Matches Lagrange's `OidbSvcTrpcTcp0xFE1_2Uid` vs `…Uin` split
// at `dev/Lagrange.Core/.../OidbSvcTrpcTcp0xFE1_2.cs:9-26`
// and the dispatch at
// `dev/Lagrange.Core/.../FetchUserInfoService.cs:66-78`.
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
AvatarInfo,
OidbUserInfoByUidRequest,
OidbUserInfoResponse,
} from '@snowluma/proto-defs/oidb-actions/base';
import type { UserProfileInfo } from '../../qq-info';
import { applyBirthday, applyOnlineStatus, applyProfileDetails } from './fetch-user-profile';
import { invokeOidb, type OidbSender } from '../../oidb-service';
import { decodePublicProfile, PUBLIC_PROFILE_KEYS } from './public-profile';
const REQUESTED_KEYS = [
20002,
27394,
20009,
20031,
101,
103,
102,
20020,
20003,
20026,
105,
27372,
27406,
20037,
41756,
41757,
42241,
// 企点标志(QQ 企点 / 企业版 QQ 账号):员工号实测为 1,普通账号为 0
40410,
42031,
...PUBLIC_PROFILE_KEYS,
];
export namespace FetchUserProfileByUid {
export const command = 0xfe1;
export const subCommand = 2;
// No `uinForm` — the UID variant uses the default `reserved=0`
// envelope, mirroring Lagrange's untagged constructor at
// `OidbSvcTrpcTcp0xFE1_2.cs:9` (isUid=false → Reserved=0).
export interface Params {
uid: string;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): OidbUserInfoByUidRequest => ({
uid: p.uid,
keys: [...new Set(REQUESTED_KEYS)].map((k) => ({ key: k })),
});
export const deserialize = (_ctx: Deps, body: OidbUserInfoResponse): UserProfileInfo => {
if (!body.body) {
throw new Error('user info response body missing');
}
const info: UserProfileInfo = {
uin: body.body.uin ?? 0,
uid: body.body.uid ?? '',
nickname: '',
remark: '',
qid: '',
sex: 'unknown',
age: 0,
sign: '',
avatar: '',
level: 0,
qidianMasterFlag: 0,
qidianCrewFlag: 0,
qidianCrewFlag2: 0,
};
if (body.body.properties) {
const bytesMap = new Map<number, Uint8Array>();
const numMap = new Map<number, number>();
for (const bp of body.body.properties.bytesProperties ?? []) {
bytesMap.set(bp.code ?? 0, bp.value ?? new Uint8Array(0));
}
for (const np of body.body.properties.numberProperties ?? []) {
numMap.set(np.number1 ?? 0, np.number2 ?? 0);
}
const getString = (code: number): string => {
const b = bytesMap.get(code);
return b ? Buffer.from(b).toString('utf8') : '';
};
info.nickname = getString(20002);
info.remark = getString(103);
info.qid = getString(27394);
info.sign = getString(102);
const avatarBytes = bytesMap.get(101);
if (avatarBytes) {
const av = protobuf_decode<AvatarInfo>(avatarBytes);
if (av?.url) {
info.avatar = av.url + '640';
}
}
const sexNum = numMap.get(20009) ?? 0;
info.sex = sexNum === 1 ? 'male' : sexNum === 2 ? 'female' : 'unknown';
info.age = numMap.get(20037) ?? 0;
info.level = numMap.get(105) ?? 0;
applyOnlineStatus(info, bytesMap, numMap);
applyProfileDetails(info, numMap);
applyBirthday(info, bytesMap);
const profile = decodePublicProfile(bytesMap, numMap);
if (Object.keys(profile).length > 0) {
info.profile = profile;
}
// 企点标志:40410 / 42031,服务器仅在返回时才出现(普通账号缺省 → 0)
info.qidianMasterFlag = numMap.get(42031) ?? 0;
info.qidianCrewFlag = numMap.get(40410) ?? 0;
}
return info;
};
export const encode = (env: OidbBase<OidbUserInfoByUidRequest>): Uint8Array =>
protobuf_encode<OidbBase<OidbUserInfoByUidRequest>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbUserInfoResponse> =>
protobuf_decode<OidbBase<OidbUserInfoResponse>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<UserProfileInfo> =>
invokeOidb(deps, FetchUserProfileByUid, params);
}
@@ -0,0 +1,249 @@
// 0xFE1_2 — fetch a user's profile (nickname / qid / sex / age / sign
// / avatar / QQ level).
//
// `uinForm` is set so the server takes the UIN-form validation path;
// without it newer NTQQ versions reject the request with
// `[oidb] one of uid/openid is invaild`. Matches Lagrange.Core's
// FetchStrangerByUin (Reserved = 1).
//
// Property key catalogue (cross-checked against LagrangeV2's
// FetchStrangerService.cs):
// 101 — avatar info (proto-encoded AvatarInfo)
// 102 — sign (signature)
// 103 — remark
// 105 — QQ level (numeric)
// 20002 — nickname
// 20009 — gender (1 male / 2 female / 255 unknown)
// 20031 — birthday (4 bytes: uint16 BE year, month, day)
// 20037 — age
// 27394 — QID
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
AvatarInfo,
OidbCustomStatus,
OidbUserInfoRequest,
OidbUserInfoResponse,
} from '@snowluma/proto-defs/oidb-actions/base';
import type { UserProfileInfo } from '../../qq-info';
import { unpackStatusWord } from '../extras/get-stranger-status';
import { invokeOidb, type OidbSender } from '../../oidb-service';
import { decodeMembership, decodePublicProfile, PUBLIC_PROFILE_KEYS } from './public-profile';
export function applyOnlineStatus(
info: UserProfileInfo,
bytesMap: Map<number, Uint8Array>,
numMap: Map<number, number>,
): void {
const statusRaw = numMap.get(27372);
if (statusRaw !== undefined) {
const unpacked = unpackStatusWord(statusRaw);
info.status = unpacked.status;
info.extStatus = unpacked.ext_status;
info.batteryStatus = 0;
}
const customBytes = bytesMap.get(27406);
if (customBytes && customBytes.length > 0) {
const custom = protobuf_decode<OidbCustomStatus>(customBytes);
info.customStatus = {
faceId: custom.faceId ?? 0,
wording: custom.msg ?? '',
};
info.customStatusDesc = custom.msg ?? '';
}
}
export function applyProfileDetails(info: UserProfileInfo, numMap: Map<number, number>): void {
const registered = numMap.get(20026);
if (registered !== undefined && registered > 0) {
info.regTime = registered;
}
Object.assign(info, decodeMembership(numMap));
}
/**
* 生日(bytes-property 20031):年 uint16 大端 + 月 + 日,与 Lagrange.Core
* `FetchStrangerService.cs` 的解析一致。月或日为 0 表示未设置生日;
* 年份为 0 表示未公开出生年份,此时只省略年份。
*/
export function applyBirthday(info: UserProfileInfo, bytesMap: Map<number, Uint8Array>): void {
const raw = bytesMap.get(20031);
if (raw?.length !== 4) {
return;
}
const bytes = Buffer.from(raw);
const year = bytes.readUInt16BE(0);
const month = bytes.readUInt8(2);
const day = bytes.readUInt8(3);
// A hidden year can still represent February 29.
const date = new Date(0);
date.setUTCFullYear(year || 2000, month - 1, day);
if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
return;
}
if (year > 0) {
info.birthdayYear = year;
}
info.birthdayMonth = month;
info.birthdayDay = day;
}
const REQUESTED_KEYS = [
20002,
27394,
20009,
20031,
101,
103,
102,
20020,
20003,
20026,
105,
27372,
27406,
20037,
41756,
41757,
42241,
// 企点标志(QQ 企点 / 企业版 QQ 账号):员工号实测为 1,普通账号为 0
40410,
42031,
...PUBLIC_PROFILE_KEYS,
];
export namespace FetchUserProfile {
export const command = 0xfe1;
export const subCommand = 2;
export const uinForm = true;
export interface Params {
uin: number;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): OidbUserInfoRequest => ({
uin: p.uin,
keys: [...new Set(REQUESTED_KEYS)].map((k) => ({ key: k })),
});
/** Defaults the response uin to `requestedUin` because the server
* sometimes elides its echo. `invoke` below binds `params.uin` via
* the spec override so the namespace's call surface still takes
* only `(ctx, body)`. */
export const deserializeWithFallback = (
body: OidbUserInfoResponse,
requestedUin: number,
): UserProfileInfo => {
if (!body.body) {
throw new Error('user info response body missing');
}
if (requestedUin > 0 && body.body.uin != null && body.body.uin !== requestedUin) {
throw new Error('profile response does not match the requested account');
}
const info: UserProfileInfo = {
uin: body.body.uin ?? requestedUin,
uid: body.body.uid ?? '',
nickname: '',
remark: '',
qid: '',
sex: 'unknown',
age: 0,
sign: '',
avatar: '',
level: 0,
qidianMasterFlag: 0,
qidianCrewFlag: 0,
qidianCrewFlag2: 0,
};
if (body.body.properties) {
const bytesMap = new Map<number, Uint8Array>();
const numMap = new Map<number, number>();
for (const bp of body.body.properties.bytesProperties ?? []) {
bytesMap.set(bp.code ?? 0, bp.value ?? new Uint8Array(0));
}
for (const np of body.body.properties.numberProperties ?? []) {
numMap.set(np.number1 ?? 0, np.number2 ?? 0);
}
const getString = (code: number): string => {
const b = bytesMap.get(code);
return b ? Buffer.from(b).toString('utf8') : '';
};
info.nickname = getString(20002);
info.remark = getString(103);
info.qid = getString(27394);
info.sign = getString(102);
const avatarBytes = bytesMap.get(101);
if (avatarBytes) {
const av = protobuf_decode<AvatarInfo>(avatarBytes);
if (av?.url) {
info.avatar = av.url + '640';
}
}
const sexNum = numMap.get(20009) ?? 0;
info.sex = sexNum === 1 ? 'male' : sexNum === 2 ? 'female' : 'unknown';
info.age = numMap.get(20037) ?? 0;
info.level = numMap.get(105) ?? 0;
applyOnlineStatus(info, bytesMap, numMap);
applyProfileDetails(info, numMap);
applyBirthday(info, bytesMap);
const profile = decodePublicProfile(bytesMap, numMap);
if (Object.keys(profile).length > 0) {
info.profile = profile;
}
// 企点标志:40410 / 42031,服务器仅在返回时才出现(普通账号缺省 → 0)
info.qidianMasterFlag = numMap.get(42031) ?? 0;
info.qidianCrewFlag = numMap.get(40410) ?? 0;
}
return info;
};
// Default deserialize uses uin=0 fallback — invoke() rewires it to
// the requested uin via the per-call spec override below. Tests
// that call deserialize directly should use deserializeWithFallback.
export const deserialize = (_ctx: Deps, body: OidbUserInfoResponse): UserProfileInfo =>
deserializeWithFallback(body, 0);
export const encode = (env: OidbBase<OidbUserInfoRequest>): Uint8Array =>
protobuf_encode<OidbBase<OidbUserInfoRequest>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbUserInfoResponse> =>
protobuf_decode<OidbBase<OidbUserInfoResponse>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<UserProfileInfo> =>
invokeOidb(
deps,
{
...FetchUserProfile,
deserialize: (_ctx, body) => deserializeWithFallback(body, params.uin),
},
params,
);
}
@@ -0,0 +1,44 @@
// 0x12b6_0 — getBuddyRecommendContactArkJson: ask the server to build a
// "recommend contact" ARK share card for a friend (by uin). Returns the
// ark JSON string the card is rendered from. The request body writes
// uin/phone/jump_url; response body field 1 is the ark string.
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbBuddyRecommendArkReq,
OidbBuddyRecommendArkResp,
} from '@snowluma/proto-defs/oidb-actions/contact-ark';
import { invokeOidb, type OidbSender } from '../../oidb-service';
export namespace GetBuddyRecommendArk {
export const command = 0x12b6;
export const subCommand = 0;
export const uinForm = false;
export interface Params {
userId: number;
phoneNumber?: string;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): OidbBuddyRecommendArkReq => ({
uin: p.userId,
phoneNumber: p.phoneNumber || '-',
// Client-side jump_url the kernel hard-codes alongside the uin; the
// server echoes it into the card. Mirrors the NT template exactly.
jumpUrl: `mqqapi://card/show_pslcard?src_type=internal&source=sharecard&version=1&uin=${p.userId}`,
});
export const deserialize = (_ctx: Deps, body: OidbBuddyRecommendArkResp): string =>
body.ark ?? '';
export const encode = (env: OidbBase<OidbBuddyRecommendArkReq>): Uint8Array =>
protobuf_encode<OidbBase<OidbBuddyRecommendArkReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbBuddyRecommendArkResp> =>
protobuf_decode<OidbBase<OidbBuddyRecommendArkResp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<string> =>
invokeOidb(deps, GetBuddyRecommendArk, params);
}
@@ -0,0 +1,44 @@
// 0x8b7_5 — getGroupRecommendContactArkJson: ask the server to build a
// "recommend contact" ARK share card for a group (by group code). Returns the
// ark JSON string. RE'd from QQNT group_get_ark_json_worker.cc (EncodeRequest
// writes {1:reqType=1, 2:groupCode, 5:flag=1}); response read in
// group_info_mgr.cc `[gp_get_ark_json]` as {1:bussness_error_code, 5:ark_json}.
// uin-form OIDB (envelope reserved=1).
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbGroupRecommendArkReq,
OidbGroupRecommendArkResp,
} from '@snowluma/proto-defs/oidb-actions/contact-ark';
import { invokeOidb, type OidbSender } from '../../oidb-service';
export namespace GetGroupRecommendArk {
export const command = 0x8b7;
export const subCommand = 5;
export const uinForm = true;
export interface Params {
groupId: number;
}
export type Deps = OidbSender;
// reqType and flag are constants the NT encoder always sends (1 and 1).
export const serialize = (_ctx: Deps, p: Params): OidbGroupRecommendArkReq => ({
reqType: 1,
groupCode: p.groupId,
flag: 1,
});
export const deserialize = (_ctx: Deps, body: OidbGroupRecommendArkResp): string =>
body.arkJson ?? '';
export const encode = (env: OidbBase<OidbGroupRecommendArkReq>): Uint8Array =>
protobuf_encode<OidbBase<OidbGroupRecommendArkReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbGroupRecommendArkResp> =>
protobuf_decode<OidbBase<OidbGroupRecommendArkResp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<string> =>
invokeOidb(deps, GetGroupRecommendArk, params);
}
@@ -0,0 +1,50 @@
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbGroupShareLinkRequest,
OidbGroupShareLinkResponse,
} from '@snowluma/proto-defs/oidb-actions/group-share-link';
import { invokeOidb, type OidbSender } from '../../oidb-service';
export namespace GetGroupShareLink {
export const command = 0x9060;
export const subCommand = 3;
export const uinForm = true;
export interface Params {
groupId: number;
needShortUrl: boolean;
srcId: number;
additionalParam: string;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): OidbGroupShareLinkRequest => ({
type: 2n,
group: {
groupCode: BigInt(p.groupId),
additionalParam: p.additionalParam,
needShortUrl: p.needShortUrl,
},
});
export const deserialize = (_ctx: Deps, body: OidbGroupShareLinkResponse): string => {
const url = body.group?.url;
if (!url?.trim()) {
throw new Error('group share link response did not include a URL');
}
return url;
};
export const encode = (env: OidbBase<OidbGroupShareLinkRequest>): Uint8Array =>
protobuf_encode<OidbBase<OidbGroupShareLinkRequest>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbGroupShareLinkResponse> =>
protobuf_decode<OidbBase<OidbGroupShareLinkResponse>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<string> =>
invokeOidb(deps, GetGroupShareLink, params);
}
@@ -0,0 +1,194 @@
import { protobuf_decode, protobuf_getUnknownFields } from '@snowluma/proton';
import { createLogger } from '@snowluma/common/logger';
import type {
OidbProfilePrivileges,
OidbProfilePrivilege,
} from '@snowluma/proto-defs/oidb-actions/base';
import type { ProfilePrivilege, PublicProfileFields } from '../../qq-info';
// Shared by the roster and both profile request forms.
export const PUBLIC_PROFILE_KEYS = [
102, 105, 107, 20003, 20004, 20020, 20009, 20011, 20016, 20021, 20031, 20037, 41756, 41757,
42241,
];
const log = createLogger('Protocol.Profile');
function text(value: Uint8Array, code: number): string {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(value);
} catch (cause) {
throw new Error(`invalid UTF-8 in profile property ${code}`, { cause });
}
}
export function decodeBirthday(
raw: Uint8Array,
): Pick<PublicProfileFields, 'birthdayYear' | 'birthdayMonth' | 'birthdayDay'> {
if (raw.length !== 4) {
throw new Error('invalid birthday length in profile property 20031');
}
const year = (raw[0]! << 8) | raw[1]!;
const month = raw[2]!;
const day = raw[3]!;
if (month === 0 && day === 0) {
return {};
}
const date = new Date(0);
date.setUTCFullYear(year || 2000, month - 1, day);
if (year > 9999 || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
throw new Error('invalid date in profile property 20031');
}
return {
...(year > 0 ? { birthdayYear: year } : {}),
birthdayMonth: month,
birthdayDay: day,
};
}
function privilege(entry: OidbProfilePrivilege): ProfilePrivilege {
checkKnownFields(entry, [1, 4, 5, 6, 7, 8, 9]);
return {
...(entry.type != null ? { type: entry.type } : {}),
...(entry.level != null ? { level: entry.level } : {}),
...(entry.flag != null ? { flag: entry.flag } : {}),
...(entry.isBig != null ? { isBig: entry.isBig } : {}),
...(entry.jumpUrl != null ? { jumpUrl: text(entry.jumpUrl, 107) } : {}),
...(entry.iconUrl != null ? { iconUrl: text(entry.iconUrl, 107) } : {}),
...(entry.deluxeIconUrl != null ? { deluxeIconUrl: text(entry.deluxeIconUrl, 107) } : {}),
};
}
function checkKnownFields(value: unknown, tags: readonly number[]): void {
for (const field of protobuf_getUnknownFields(value)) {
if (tags.includes(field.fieldNumber)) {
throw new Error(`unexpected wire type for privilege field ${field.fieldNumber}`);
}
}
}
export function decodeMembership(
numbers: ReadonlyMap<number, number>,
): Pick<PublicProfileFields, 'vipFlag' | 'yearVipFlag' | 'svipFlag' | 'vipLevel'> {
const result: Pick<PublicProfileFields, 'vipFlag' | 'yearVipFlag' | 'svipFlag' | 'vipLevel'> =
{};
const flags = numbers.get(41756);
if (flags !== undefined) {
result.vipFlag = (flags & 1) !== 0;
result.yearVipFlag = (flags & 2) !== 0;
result.svipFlag = (flags & 0x100) !== 0;
}
const level = numbers.get(41757);
if (level !== undefined) {
const mask = level & 0xfff;
if (mask !== 0) {
result.vipLevel = 33 - Math.clz32(mask);
} else if (
result.vipFlag ||
result.yearVipFlag ||
result.svipFlag ||
numbers.get(42241) === 1
) {
result.vipLevel = 1;
} else if (flags !== undefined && numbers.has(42241)) {
result.vipLevel = 0;
}
}
return result;
}
export function decodePublicProfile(
bytes: ReadonlyMap<number, Uint8Array>,
numbers: ReadonlyMap<number, number>,
): PublicProfileFields {
const result: PublicProfileFields = {};
for (const [code, field] of [
[102, 'sign'],
[20011, 'email'],
[20016, 'phoneNum'],
[20003, 'country'],
[20004, 'province'],
[20020, 'city'],
[20021, 'college'],
] as const) {
const value = bytes.get(code);
if (value !== undefined) {
result[field] = text(value, code);
}
}
for (const [code, field] of [
[20037, 'age'],
[105, 'level'],
] as const) {
const value = numbers.get(code);
if (value !== undefined && Number.isInteger(value) && value >= 0) {
result[field] = value;
}
}
const sex = numbers.get(20009);
if (sex !== undefined) {
result.sex = sex === 1 ? 'male' : sex === 2 ? 'female' : 'unknown';
}
const birthday = bytes.get(20031);
if (birthday !== undefined && birthday.length > 0) {
// Keep the existing optional-birthday behavior, but make malformed
// upstream values diagnosable without recording personal data.
try {
Object.assign(result, decodeBirthday(birthday));
} catch (error) {
log.warn(
'birthday unavailable: bytes=%d error=%s',
birthday.length,
error instanceof Error ? error.message : String(error),
);
}
}
Object.assign(result, decodeMembership(numbers));
const icons = bytes.get(107);
if (icons !== undefined) {
try {
const decoded = protobuf_decode<OidbProfilePrivileges>(icons);
checkKnownFields(decoded, [3]);
if (!decoded.lists) {
// The client treats an absent list body as unavailable, not
// as a returned list containing zero entries.
return result;
}
checkKnownFields(decoded.lists, [2, 3, 4]);
result.privilegeIcons = {
...(decoded.lists.jumpUrl != null
? { jumpUrl: text(decoded.lists.jumpUrl, 107) }
: {}),
open: (decoded.lists.open ?? []).map(privilege),
closed: (decoded.lists.closed ?? []).map(privilege),
};
} catch (cause) {
throw new Error('invalid privilege data in profile property 107', { cause });
}
}
return result;
}
@@ -0,0 +1,82 @@
// 0xdc2_34 — send a custom 图文 (URL-share) ark card to a C2C peer or group.
// RE'd from QQ Android 9.3.25 captures. Default SSO: OidbSvcTrpcTcp.0xdc2_34.
// Fixed fields: appId = 100446242, field2 = 1, field3 = 0, field5 = {1:1}
// targetId appears at AppInfo[11] and Meta[2]; Meta.peerType: 0=C2C, 1=group.
// peerType, field3, and previewUrl are pb_optional — proton preserves 0/empty on wire.
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
Oidb0xdc2_34Req,
Oidb0xdc2_34Resp,
} from '@snowluma/proto-defs/oidb-actions/contact-ark';
import { invokeOidb, OidbError, type OidbSender } from '../../oidb-service';
const TUWEN_ARK_APPID = 100446242;
export type SendTuwenArkParams = {
targetId: number;
peerType: 0 | 1;
title: string;
desc: string;
summary: string;
jumpUrl: string;
previewUrl: string;
};
export namespace SendTuwenArk {
export const command = 0xdc2;
export const subCommand = 34;
export const uinForm = false;
export type Params = SendTuwenArkParams;
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): Oidb0xdc2_34Req => ({
appInfo: {
appId: TUWEN_ARK_APPID,
field2: 1,
field3: 0,
field5: { field1: 1 },
targetId: p.targetId,
content: {
flag: 1,
title: p.title,
desc: p.desc,
summary: p.summary,
jumpUrl: p.jumpUrl,
previewUrl: p.previewUrl,
},
},
meta: {
peerType: p.peerType,
targetId: p.targetId,
},
});
export const deserialize = (_ctx: Deps, body: Oidb0xdc2_34Resp): void => {
if (!body.results?.length) {
throw new Error('send_tuwen_ark response has no delivery result');
}
for (const result of body.results) {
if (result.errorCode) {
throw new OidbError(
result.errorCode,
result.errorMessage ?? '',
command,
subCommand,
);
}
}
};
export const encode = (env: OidbBase<Oidb0xdc2_34Req>): Uint8Array =>
protobuf_encode<OidbBase<Oidb0xdc2_34Req>>(env);
export const decode = (bytes: Uint8Array): OidbBase<Oidb0xdc2_34Resp> =>
protobuf_decode<OidbBase<Oidb0xdc2_34Resp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<void> =>
invokeOidb(deps, SendTuwenArk, params);
}
@@ -0,0 +1,40 @@
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbSetFriendCategoryRequest } from '@snowluma/proto-defs/oidb-actions/base';
import { invokeOidb, type OidbSender } from '../../oidb-service';
/**
* Move one friend into a category.
*
* The desktop client treats 2001002 as an idempotent success result.
*/
export namespace SetFriendCategory {
export const command = 0x1255;
export const subCommand = 0;
export const uinForm = true;
export const alreadyAppliedCode = 2_001_002;
export const acceptedEnvelopeCodes = [alreadyAppliedCode] as const;
export interface Params {
uid: string;
categoryId: number;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, params: Params): OidbSetFriendCategoryRequest => ({
uid: params.uid,
categoryId: params.categoryId,
});
export const deserialize = (): void => undefined;
export const encode = (env: OidbBase<OidbSetFriendCategoryRequest>): Uint8Array =>
protobuf_encode<OidbBase<OidbSetFriendCategoryRequest>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbSetFriendCategoryRequest> =>
protobuf_decode<OidbBase<OidbSetFriendCategoryRequest>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<void> =>
invokeOidb(deps, SetFriendCategory, params);
}
@@ -0,0 +1,139 @@
// Faceroam add — 添加收藏表情(custom face):BDHExpressionRoam 申请 → highway HTTP 上传。
//
// 走的是 trpc service "ImgStore.BDHExpressionRoam"(不是 OIDB),申请上传
// 拿到 token,然后把图片数据打成 highway 帧用 HTTP POST 发到
// httpconn?htcmd=0x6FF0087。上传节点 host:port 复用 fetchHighwaySession
// (0x6ff_501)的 serverInfos,端口 80。
//
// head segHead.serviceTicket = BDHExpressionRoam 响应 token(不是 sigSession,
// 端到端验证过)。head field5 (82B) 抓包见过但非必需,不填也能上传。
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type {
BDHExpressionRoamReq,
BDHExpressionRoamResp,
FavEmojiHighwayHead,
} from '@snowluma/proto-defs/oidb-actions/base';
import type { BridgeContext } from '../../bridge-context';
import { fetchHighwaySession } from '../../highway/highway-client';
import { computeMd5, packHighwayFrame } from '../../highway/utils';
export namespace AddCustomFace {
const BDH_SERVICE = 'ImgStore.BDHExpressionRoam';
export interface Params {
uin: string;
imageBytes: Uint8Array;
}
// fetchHighwaySession 需要 sendRawPacket + identity,所以 Deps 比纯 OidbSender 多 identity。
export type Deps = Pick<BridgeContext, 'sendRawPacket' | 'identity'>;
function md5Hex(md5: Uint8Array): string {
let s = '';
for (const b of md5) {
s += b.toString(16).padStart(2, '0');
}
return s;
}
export async function invoke(deps: Deps, params: Params): Promise<string> {
const { uin, imageBytes } = params;
const md5 = computeMd5(imageBytes);
const emojiId = `${uin}_0_0_0_${md5Hex(md5).toUpperCase()}_0_0`;
// 1. fetchHighwaySession 拿上传节点 host:port(复用 SnowLuma highway-client)
const session = await fetchHighwaySession(deps as unknown as BridgeContext);
const { host, port } = session.servers[0]!;
// 2. BDHExpressionRoam 申请上传 token
const reqBody = protobuf_encode<BDHExpressionRoamReq>({
field1: 3,
field2: 1,
inner: {
field1: 0,
uin: BigInt(uin),
field3: 0,
md5,
filesize: imageBytes.length,
field7: 2,
field8: 0,
field9: 1,
ver: '1.0.0',
field16: 1,
},
field7: 9,
tail: { inner: { field1: 0, field2: 0, field3: '0' }, field2: 1 },
});
const result = await deps.sendRawPacket(BDH_SERVICE, reqBody);
if (!result.gotResponse) {
throw new Error(result.errorMessage || 'BDHExpressionRoam: no response');
}
if (!result.success) {
throw new Error(result.errorMessage || 'BDHExpressionRoam: send failed');
}
const resp = protobuf_decode<BDHExpressionRoamResp>(
result.responseData ?? new Uint8Array(0),
);
const token = resp.inner?.token;
if (!token) {
throw new Error('BDHExpressionRoam: response missing token (field8)');
}
// 3. 构造 highway head。serviceTicket = BDHExpressionRoam token。
const head = protobuf_encode<FavEmojiHighwayHead>({
baseHead: {
version: 1,
uin,
command: 'PicUp.DataUp',
seq: 1,
retryTimes: 0,
filesize: BigInt(imageBytes.length),
dataFlag: 16,
commandId: 9,
},
segHead: {
serviceId: 0,
filesize: BigInt(imageBytes.length),
dataOffset: 0n,
dataLength: BigInt(imageBytes.length),
serviceTicket: token,
md5,
fileMd5: md5,
},
emojiIdWrap: { emojiId },
field4: 0,
field8: 9,
// field5 (82B) 抓包见过但非必需,不填
});
// 4. highway 帧(复用 SnowLuma packHighwayFrame,结构和抓包一致)
const frame = packHighwayFrame(head, imageBytes);
// 5. HTTP POST 上传。端口用 fetchHighwaySession 的 port(抓包确认 httpconn 端口 80)。
const url = `http://${host}:${port}/cgi-bin/httpconn?htcmd=0x6FF0087&uin=${uin}`;
const httpResp = await fetch(url, {
method: 'POST',
body: frame,
headers: {
Accept: '*/*',
Connection: 'Keep-Alive',
'User-Agent': 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.2)',
'Cache-Control': 'no-cache',
Pragma: 'no-cache',
},
});
if (!httpResp.ok) {
throw new Error(`highway upload failed: HTTP ${httpResp.status}`);
}
return emojiId;
}
}
@@ -0,0 +1,47 @@
// Faceroam.OpReq opType=2 — 删除一个收藏表情(custom face)。
//
// 和 fetch 同一个 trpc service,区别在 field3=2、inner 不带 qqVersion、
// 业务体 field5 塞 emoji_id(即 custom face id)。id 格式是
// `<uin 段>_0_0_0_<MD5>_0_0`,直接用 fetch 响应里解析出来的值回传即可,
// 不用自己拼。field2 同样是当前账号 UIN。
//
// 请求 wire 同样对齐 9.9.26-44343 抓包(单测样本)。响应暂时只看
// success / errorCode,业务字段等需要时再解。
import { protobuf_encode } from '@snowluma/proton';
import type { FaceroamOpReq } from '@snowluma/proto-defs/oidb-actions/base';
import type { OidbSender } from '../../oidb-service';
import { FACEROAM_SERVICE, makeInner } from './shared';
export namespace DeleteCustomFace {
export interface Params {
/** 当前账号 UIN,写入 field2。 */
uin: string;
/** 形如 `<UIN>_0_0_0_<MD5>_0_0`,来自 fetch 响应。 */
emojiId: string;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): FaceroamOpReq => ({
inner: makeInner(false),
uin: BigInt(p.uin),
field3: 2,
body: { emojiId: p.emojiId },
});
export const encode = (req: FaceroamOpReq): Uint8Array => protobuf_encode<FaceroamOpReq>(req);
export async function invoke(deps: Deps, params: Params): Promise<void> {
const body = encode(serialize(deps, params));
const result = await deps.sendRawPacket(FACEROAM_SERVICE, body);
if (!result.gotResponse) {
throw new Error(result.errorMessage || 'Faceroam.OpReq delete: no response');
}
if (!result.success) {
throw new Error(result.errorMessage || 'Faceroam.OpReq delete: send failed');
}
}
}
@@ -0,0 +1,81 @@
// 0x902e_1 opType=2 — supplement the Faceroam custom-face id list with the
// server-side descriptions. QQ first fetches ids through Faceroam.OpReq, then
// sends every {emojiId, md5} here. The response returns repeated entries in f4.
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type {
CustomFaceModifyResp,
CustomFaceMoveBody,
} from '@snowluma/proto-defs/oidb-actions/base';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import { invokeOidb, type OidbSender } from '../../oidb-service';
import { CLIENT_VERSION } from './shared';
export namespace FetchCustomFaceDetail {
export const command = 0x902e;
export const subCommand = 1;
export const uinForm = true;
export interface EmojiItem {
emojiId: string;
md5: string;
}
export interface Params {
emojis: EmojiItem[];
}
export interface Detail {
emojiId: string;
desc: string;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): CustomFaceMoveBody => ({
field1: 1,
osVersion: CLIENT_VERSION,
opType: 2,
emojis: p.emojis.map((emoji) => ({
emojiId: emoji.emojiId,
md5: emoji.md5,
})),
});
export const deserialize = (_ctx: Deps, body: CustomFaceModifyResp): Detail[] => {
if (body.retCode && body.retCode !== 0) {
throw new Error(`fetch custom face detail error: ${body.errMsg || body.retCode}`);
}
const seen = new Set<string>();
return (body.entries ?? []).map((entry, index) => {
const emojiId = entry.emoji?.emojiId;
if (!emojiId) {
throw new Error(`fetch custom face detail response entry ${index} has no emoji id`);
}
if (seen.has(emojiId)) {
throw new Error(
`fetch custom face detail response has duplicate emoji id: ${emojiId}`,
);
}
seen.add(emojiId);
return {
emojiId,
// Current builds use tag 3. Older builds use tag 2 when tag 3 is empty.
desc: entry.desc || entry.legacyDesc || '',
};
});
};
export const encode = (env: OidbBase<CustomFaceMoveBody>): Uint8Array =>
protobuf_encode<OidbBase<CustomFaceMoveBody>>(env);
export const decode = (bytes: Uint8Array): OidbBase<CustomFaceModifyResp> =>
protobuf_decode<OidbBase<CustomFaceModifyResp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<Detail[]> =>
invokeOidb(deps, FetchCustomFaceDetail, params);
}
@@ -0,0 +1,47 @@
// Faceroam.OpReq opType=1 — 拉取当前账号的收藏表情 id 列表。
//
// 和 delete 同一个 trpc service,区别在 field3=1、inner 带 qqVersion、
// 并置 field6=1。响应 item.faceIds 是完整列表;截条数由调用方做。
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { FaceroamOpReq, FaceroamOpResp } from '@snowluma/proto-defs/oidb-actions/base';
import type { OidbSender } from '../../oidb-service';
import { FACEROAM_SERVICE, makeInner } from './shared';
export namespace FetchCustomFaceList {
export interface Params {
/** 当前账号 UIN,写入 field2。 */
uin: string;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): FaceroamOpReq => ({
inner: makeInner(true),
uin: BigInt(p.uin),
field3: 1,
field6: 1,
});
export const encode = (req: FaceroamOpReq): Uint8Array => protobuf_encode<FaceroamOpReq>(req);
export const decode = (bytes: Uint8Array): FaceroamOpResp =>
protobuf_decode<FaceroamOpResp>(bytes);
export async function invoke(deps: Deps, params: Params): Promise<string[]> {
const body = encode(serialize(deps, params));
const result = await deps.sendRawPacket(FACEROAM_SERVICE, body);
if (!result.success || !result.gotResponse || !result.responseData) {
throw new Error(result.errorMessage || 'fetch custom face failed');
}
const resp = decode(result.responseData);
if (!resp || (resp.retCode ?? 0) !== 0) {
throw new Error(`fetch custom face error: ${resp?.message || 'unknown'}`);
}
return resp.item?.faceIds || [];
}
}
@@ -0,0 +1,64 @@
// OIDB 0x902e_1 opType=3 — 修改收藏表情(custom face)备注。
//
// modify 和 move 共用 cmd 0x902e,靠业务体 f3 的 opType 区分(modify=3)。
// 走 OIDB 通道(OidbSvcTrpcTcp.0x902e_1),不是 Faceroam.OpReq——fetch/delete
// 走 Faceroam,modify/move 走 OIDB,是抓包确认的两条不同通道。
//
// 业务体(OIDB 信封 f4 内):
// { f1:1, f2:osVersion, f3:3, f5:{ f1:{emojiId,md5}, f2:desc }, f12:1 }
// f5.entry.emoji 是 {emojiId, md5},desc 是新备注。f12=1 是修改标志。
// emojiId 格式 `<uin>_0_0_0_<MD5>_0_0`,md5 是 32 位大写 hex,均来自 fetch。
//
// 响应 f4 repeated 返回受影响条目 {f1:{emojiId,md5}, f3:desc}(含改后 desc),
// 成功只看 retCode=0。字段编号严格对齐 9.9.26-44343 frida 抓包。
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type {
CustomFaceModifyBody,
CustomFaceModifyResp,
} from '@snowluma/proto-defs/oidb-actions/base';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import { invokeOidb, type OidbSender } from '../../oidb-service';
import { CLIENT_VERSION } from './shared';
export namespace ModifyCustomFace {
export const command = 0x902e;
export const subCommand = 1;
export interface Params {
/** 形如 `<UIN>_0_0_0_<MD5>_0_0`,来自 fetch 响应。 */
emojiId: string;
/** 32 位大写 hex MD5,emoji_id 中段。 */
md5: string;
/** 新备注(可为空字符串清空备注)。 */
desc: string;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): CustomFaceModifyBody => ({
field1: 1,
osVersion: CLIENT_VERSION,
opType: 3,
entry: {
emoji: { emojiId: p.emojiId, md5: p.md5 },
desc: p.desc,
},
field12: 1,
});
export const deserialize = (_ctx: Deps, body: CustomFaceModifyResp): void => {
if (body.retCode && body.retCode !== 0) {
throw new Error(`modify custom face error: ${body.errMsg || body.retCode}`);
}
};
export const encode = (env: OidbBase<CustomFaceModifyBody>): Uint8Array =>
protobuf_encode<OidbBase<CustomFaceModifyBody>>(env);
export const decode = (bytes: Uint8Array): OidbBase<CustomFaceModifyResp> =>
protobuf_decode<OidbBase<CustomFaceModifyResp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<void> =>
invokeOidb(deps, ModifyCustomFace, params);
}
@@ -0,0 +1,63 @@
// OIDB 0x902e_1 opType=2 — 收藏表情(custom face)排序上传(move 第2步)。
//
// "移动到最前"两步流程的第2步:把新顺序的完整 emoji 列表上传,第一个就是
// 移到最前的。第1步是 OrderCustomFace(0x902f 移动指令)。必须先发 0x902f
// 再发本包——单发本包不生效(服务端无移动指令上下文)。
//
// modify(opType=3)和 move(opType=2)共用 cmd 0x902e,区分在业务体 f3。
// envelope 带 f12=1(reserved=1,uinForm)。业务体(OIDB 信封 f4 内):
// { f1:1, f2:osVersion, f3:2, f4:[repeated {emojiId, md5}] }
// f4 是完整排序后的列表,用 fetch 顺序把目标挪到第一即可(不需要 DB 显示顺序)。
// 字段编号严格对齐 QQ 9.9.26-44343 抓包。
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type {
CustomFaceModifyResp,
CustomFaceMoveBody,
} from '@snowluma/proto-defs/oidb-actions/base';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import { invokeOidb, type OidbSender } from '../../oidb-service';
import { CLIENT_VERSION } from './shared';
export namespace MoveCustomFace {
export const command = 0x902e;
export const subCommand = 1;
/** envelope reserved=1(UIN-form),抓包见信封 f12=1。不发则服务端不改顺序。 */
export const uinForm = true;
export interface EmojiItem {
/** 形如 `<UIN>_0_0_0_<MD5>_0_0`,来自 fetch 响应。 */
emojiId: string;
/** 32 位大写 hex MD5,emoji_id 中段。 */
md5: string;
}
export interface Params {
/** 完整且有序的 emoji 列表,整表上传。 */
emojis: EmojiItem[];
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): CustomFaceMoveBody => ({
field1: 1,
osVersion: CLIENT_VERSION,
opType: 2,
emojis: p.emojis.map((e) => ({ emojiId: e.emojiId, md5: e.md5 })),
});
export const deserialize = (_ctx: Deps, body: CustomFaceModifyResp): void => {
if (body.retCode && body.retCode !== 0) {
throw new Error(`move custom face error: ${body.errMsg || body.retCode}`);
}
};
export const encode = (env: OidbBase<CustomFaceMoveBody>): Uint8Array =>
protobuf_encode<OidbBase<CustomFaceMoveBody>>(env);
export const decode = (bytes: Uint8Array): OidbBase<CustomFaceModifyResp> =>
protobuf_decode<OidbBase<CustomFaceModifyResp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<void> =>
invokeOidb(deps, MoveCustomFace, params);
}
@@ -0,0 +1,59 @@
// OIDB 0x902f_1 — 收藏表情(custom face)移动指令(move 第1步)。
//
// "移动到最前"两步流程的第1步:发 0x902f 指明目标 emoji + 目标位置。
// 第2步是 MoveCustomFace(0x902e opType=2 上传新顺序列表)。单发本包不生效,
// 必须配合第2步。envelope 带 f12=1(uinForm)。
//
// 业务体(OIDB 信封 f4 内):
// { f1:{f1:1024, f2:osVersion, f3:buildVersion}, f2:emojiId, f3:position }
// f1 是客户端环境(1024 是 client type 标志),f2 要移动的 emoji_id,
// f3 目标位置(1=最前)。字段编号对齐 9.9.26-44343 frida 抓包。
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type {
CustomFaceModifyResp,
CustomFaceOrderBody,
} from '@snowluma/proto-defs/oidb-actions/base';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import { invokeOidb, type OidbSender } from '../../oidb-service';
import { CLIENT_VERSION } from './shared';
// build 版本简写:抓包见 f1.f3 = "9.9.26"(去掉 build 号后缀)。
const BUILD_VERSION_SHORT = '9.9.26';
export namespace OrderCustomFace {
export const command = 0x902f;
export const subCommand = 1;
/** envelope reserved=1(UIN-form),抓包见信封 f12=1。 */
export const uinForm = true;
export interface Params {
/** 形如 `<UIN>_0_0_0_<MD5>_0_0`,来自 fetch 响应。 */
emojiId: string;
/** 目标位置(从 1 开始,1=最前)。 */
position: number;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): CustomFaceOrderBody => ({
env: { field1: 1024, osVersion: CLIENT_VERSION, buildVersion: BUILD_VERSION_SHORT },
emojiId: p.emojiId,
position: p.position,
});
export const deserialize = (_ctx: Deps, body: CustomFaceModifyResp): void => {
if (body.retCode && body.retCode !== 0) {
throw new Error(`order custom face error: ${body.errMsg || body.retCode}`);
}
};
export const encode = (env: OidbBase<CustomFaceOrderBody>): Uint8Array =>
protobuf_encode<OidbBase<CustomFaceOrderBody>>(env);
export const decode = (bytes: Uint8Array): OidbBase<CustomFaceModifyResp> =>
protobuf_decode<OidbBase<CustomFaceModifyResp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<void> =>
invokeOidb(deps, OrderCustomFace, params);
}
@@ -0,0 +1,33 @@
// Faceroam.OpReq 共用的常量与请求 inner 构造。
//
// 放在 custom-face/ 目录而不是上一层 oidb-services/shared.ts,因为这些值
// 只有 Faceroam 这一族操作要用——那边存的是所有 OIDB namespace 共享
// 的 toInt / ensureRetCodeZero 那一类小工具,混进去会让无关 service 也
// 看到 Faceroam 的版本号字符串。
import type { FaceroamOpReqInner } from '@snowluma/proto-defs/oidb-actions/base';
/** trpc service 名,sendRawPacket 的第一个参数。 */
export const FACEROAM_SERVICE = 'Faceroam.OpReq';
// 客户端版本标识。直接硬编码抓包看到的值——QQ 不同小版本之间这部分
// 理论上会变,但服务端对版本号并不敏感,先按当前目标版本写死,
// 真出问题再从 identity 里取。
/** osVersion 字符串,Faceroam inner 与 0x902e 业务体 f2 共用。 */
export const CLIENT_VERSION = '10.0.26200';
/** qqVersion on the Faceroam list inner. Keep the value list already sends. Delete omits this field. */
const LIST_QQ_VERSION = '9.9.28-46928';
/**
* 构造请求 inner(field1 客户端环境)。fetch 带 qqVersion,delete 不带——
* 这不是随手写的,是 wire dump 里两者 inner 长度差 14 字节的直接来源。
*/
export function makeInner(withQqVersion: boolean): FaceroamOpReqInner {
const inner: FaceroamOpReqInner = { field1: 1, osVersion: CLIENT_VERSION };
if (withQqVersion) {
inner.qqVersion = LIST_QQ_VERSION;
}
return inner;
}
@@ -0,0 +1,51 @@
// 0x929D_0 — fetch the catalog of AI voices available for synthesis
// in a given group, grouped by category. Returns the categories
// straight from the wire (caller already expects the wire shape).
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbAiVoiceListReq,
OidbAiVoiceListResp,
} from '@snowluma/proto-defs/oidb-actions/media';
import { invokeOidb, type OidbSender } from '../../oidb-service';
export interface AiVoiceItem {
voiceId: string;
voiceDisplayName: string;
voiceExampleUrl: string;
}
export interface AiVoiceCategory {
category: string;
voices: AiVoiceItem[];
}
export namespace FetchAiVoiceList {
export const command = 0x929d;
export const subCommand = 0;
export interface Params {
groupId: number;
chatType: number;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): OidbAiVoiceListReq => ({
groupUin: p.groupId,
chatType: p.chatType,
});
export const deserialize = (_ctx: Deps, body: OidbAiVoiceListResp): AiVoiceCategory[] =>
(body.content as AiVoiceCategory[] | undefined) ?? [];
export const encode = (env: OidbBase<OidbAiVoiceListReq>): Uint8Array =>
protobuf_encode<OidbBase<OidbAiVoiceListReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbAiVoiceListResp> =>
protobuf_decode<OidbBase<OidbAiVoiceListResp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<AiVoiceCategory[]> =>
invokeOidb(deps, FetchAiVoiceList, params);
}
@@ -0,0 +1,66 @@
// 0x929B_0 — kick off AI voice synthesis and poll until the rendered
// media is available. The server may return an empty msgInfo while
// the render is in-flight; the facade polls up to `maxRetries` times.
//
// `sessionId` is a random 32-bit value used by the server to
// de-duplicate retries of the same synthesis request.
//
// Each `invoke()` is a SINGLE poll — the retry loop lives on the
// facade so callers can plug their own back-off / cancellation in
// without forking the namespace.
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbAiVoiceReq, OidbAiVoiceResp } from '@snowluma/proto-defs/oidb-actions/media';
import { invokeOidb, type OidbSender } from '../../oidb-service';
/** Subset of AiVoiceMediaIndex (defined in @snowluma/core/apis/shared)
* that the AI voice response payload populates. Kept inline here so
* the namespace stays self-contained against @snowluma/protocol — the
* facade casts back to the richer core-side type. */
export interface AiVoiceMediaIndex {
info?: { fileSize?: number; fileName?: string; type?: { type?: number; voiceFormat?: number } };
fileUuid?: string;
storeId?: number;
uploadTime?: number;
ttl?: number;
subType?: number;
}
export namespace FetchAiVoice {
export const command = 0x929b;
export const subCommand = 0;
export interface Params {
groupId: number;
voiceId: string;
text: string;
chatType: number;
/** Random 32-bit session id; server uses this to deduplicate polls. */
sessionId: number;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): OidbAiVoiceReq => ({
groupUin: p.groupId,
voiceId: p.voiceId,
text: p.text,
chatType: p.chatType,
session: { sessionId: p.sessionId },
});
export const deserialize = (_ctx: Deps, body: OidbAiVoiceResp): AiVoiceMediaIndex | null => {
const node = body.msgInfo?.msgInfoBody?.[0]?.index as AiVoiceMediaIndex | undefined;
return node ?? null;
};
export const encode = (env: OidbBase<OidbAiVoiceReq>): Uint8Array =>
protobuf_encode<OidbBase<OidbAiVoiceReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbAiVoiceResp> =>
protobuf_decode<OidbBase<OidbAiVoiceResp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<AiVoiceMediaIndex | null> =>
invokeOidb(deps, FetchAiVoice, params);
}
@@ -0,0 +1,175 @@
// 0x9474_0 — query the group's current todo banners.
//
// Current QQ exposes this as a packet-backed query. Request flag 1 selects
// todos. Each active banner identifies its source message twice: msgId carries
// `sequence_random`, while commonBanner.jumpInfo.jumpParam carries JSON with
// the same pair. Both sources are checked when present so a malformed identity
// can never poison SnowLuma's message-id cache.
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbOnlineBanner,
OidbQueryGroupTopBannersReq,
OidbQueryGroupTopBannersResp,
} from '@snowluma/proto-defs/oidb-actions/base';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import { invokeOidb, type OidbSender } from '../../oidb-service';
export interface GroupTodoListItem {
sourceId: string;
sequence: number;
random: number;
text: string;
createdAt: number;
updatedAt: number;
}
interface MessageIdentity {
sequence: number;
random: number;
}
const TODO_BANNER_FLAG = 1;
const SOURCE_ID_PATTERN = /^([1-9]\d*)_(-?\d+)$/;
const utf8 = new TextDecoder('utf-8', { fatal: true });
function decodeUtf8(bytes: Uint8Array, description: string): string {
try {
return utf8.decode(bytes);
} catch {
throw new Error(`invalid group todo ${description}`);
}
}
function parseSafeInteger(value: unknown, description: string): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
throw new Error(`invalid group todo ${description}`);
}
return value;
}
function parseSourceIdentity(
bytes: Uint8Array | undefined,
): MessageIdentity & { sourceId: string } {
if (!bytes || bytes.length === 0) {
throw new Error('invalid group todo message identity');
}
const sourceId = decodeUtf8(bytes, 'message identity');
const match = SOURCE_ID_PATTERN.exec(sourceId);
if (!match) {
throw new Error('invalid group todo message identity');
}
const sequence = Number(match[1]);
const random = Number(match[2]);
if (!Number.isSafeInteger(sequence) || sequence <= 0 || !Number.isSafeInteger(random)) {
throw new Error('invalid group todo message identity');
}
return { sourceId, sequence, random };
}
function parseJumpIdentity(bytes: Uint8Array | undefined): MessageIdentity | null {
if (!bytes || bytes.length === 0) {
return null;
}
let decoded: unknown;
try {
decoded = JSON.parse(decodeUtf8(bytes, 'jump identity'));
} catch (error) {
if (error instanceof Error && error.message.startsWith('invalid group todo')) {
throw error;
}
throw new Error('invalid group todo jump identity');
}
if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) {
throw new Error('invalid group todo jump identity');
}
const record = decoded as Record<string, unknown>;
const sequence = parseSafeInteger(record.seq, 'jump identity');
const random = parseSafeInteger(record.random, 'jump identity');
if (sequence <= 0) {
throw new Error('invalid group todo jump identity');
}
return { sequence, random };
}
function parseTimestamp(value: bigint | number | null | undefined, description: string): number {
if (value === undefined || value === null) {
return 0;
}
const timestamp = typeof value === 'bigint' ? Number(value) : value;
if (!Number.isSafeInteger(timestamp) || timestamp < 0) {
throw new Error(`invalid group todo ${description}`);
}
return timestamp;
}
function parseBanner(banner: OidbOnlineBanner): GroupTodoListItem {
const source = parseSourceIdentity(banner.msgId);
const jump = parseJumpIdentity(banner.commonBanner?.jumpInfo?.jumpParam);
if (jump && (jump.sequence !== source.sequence || jump.random !== source.random)) {
throw new Error('group todo message identity mismatch');
}
return {
sourceId: source.sourceId,
sequence: source.sequence,
random: source.random,
text: banner.commonBanner?.ui?.text ?? banner.todoBanner?.text ?? '',
createdAt: parseTimestamp(banner.commonBanner?.createTime, 'create time'),
updatedAt: parseTimestamp(banner.commonBanner?.updateTime, 'update time'),
};
}
export namespace GetGroupTodoList {
export const command = 0x9474;
export const subCommand = 0;
export interface Params {
groupId: number;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): OidbQueryGroupTopBannersReq => {
if (!Number.isSafeInteger(p.groupId) || p.groupId <= 0) {
throw new Error('invalid group id');
}
return {
groupId: BigInt(p.groupId),
bannerFlag: TODO_BANNER_FLAG,
};
};
export const deserialize = (
_ctx: Deps,
body: OidbQueryGroupTopBannersResp,
): GroupTodoListItem[] =>
(body.banners ?? []).filter((banner) => banner.isDisappear !== true).map(parseBanner);
export const encode = (env: OidbBase<OidbQueryGroupTopBannersReq>): Uint8Array =>
protobuf_encode<OidbBase<OidbQueryGroupTopBannersReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbQueryGroupTopBannersResp> =>
protobuf_decode<OidbBase<OidbQueryGroupTopBannersResp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<GroupTodoListItem[]> =>
invokeOidb(deps, GetGroupTodoList, params);
}
@@ -0,0 +1,74 @@
// 0xFE1_2 — fetch a user's online / extended status. The request asks
// for property 27372; the response returns a property-list wrapper,
// whose entries must be matched by key rather than by position.
//
// `uinForm` is set so the server takes the UIN-form validation path;
// without it newer NTQQ rejects with `[oidb] one of uid/openid is
// invaild`. Same flag fetchUserProfile uses.
//
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbStrangerStatusReq,
OidbStrangerStatusResp,
} from '@snowluma/proto-defs/oidb-actions/base';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import { invokeOidb, type OidbSender } from '../../oidb-service';
export interface StrangerStatus {
status: number;
ext_status: number;
}
export function unpackStatusWord(raw: bigint | number): { status: number; ext_status: number } {
const extBig = typeof raw === 'bigint' ? raw : BigInt(raw);
if (extBig <= 10n) {
return { status: Number(extBig) * 10, ext_status: 0 };
}
const status = Number((extBig & 0xff00n) + ((extBig >> 16n) & 0xffn));
return { status: 10, ext_status: status };
}
const STATUS_PROPERTY_KEY = 27372;
export namespace GetStrangerStatus {
export const command = 0xfe1;
export const subCommand = 2;
export const uinForm = true;
export interface Params {
uin: number;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): OidbStrangerStatusReq => ({
uin: p.uin,
key: [{ key: STATUS_PROPERTY_KEY }],
});
export const deserialize = (
_ctx: Deps,
body: OidbStrangerStatusResp,
): StrangerStatus | null => {
const raw = body.data?.properties?.entries?.find(
(entry) => entry.key === STATUS_PROPERTY_KEY,
)?.value;
if (raw === undefined || raw === null) {
return null;
}
return unpackStatusWord(raw);
};
export const encode = (env: OidbBase<OidbStrangerStatusReq>): Uint8Array =>
protobuf_encode<OidbBase<OidbStrangerStatusReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbStrangerStatusResp> =>
protobuf_decode<OidbBase<OidbStrangerStatusResp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<StrangerStatus | null> =>
invokeOidb(deps, GetStrangerStatus, params);
}
@@ -0,0 +1,48 @@
// 0xF90 — group todo (群待办) CRUD.
// subCommand 1 = set, 2 = complete, 3 = cancel
//
// All three sub-cmds share the same request shape ((groupUin, msgSeq))
// so they collapse into one namespace with a dynamic subcmd resolver.
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase, OidbEmpty } from '@snowluma/proto-defs/oidb';
import type { OidbGroupTodo } from '@snowluma/proto-defs/oidb-actions/base';
import { invokeOidb, type OidbSender } from '../../oidb-service';
export type GroupTodoAction = 'set' | 'complete' | 'cancel';
const ACTION_TO_SUBCMD: Record<GroupTodoAction, number> = {
set: 1,
complete: 2,
cancel: 3,
};
export namespace GroupTodo {
export const command = 0xf90;
export interface Params {
groupId: number;
msgSeq: bigint;
action: GroupTodoAction;
}
export type Deps = OidbSender;
export const resolveSubCommand = (p: Params): number => ACTION_TO_SUBCMD[p.action];
export const serialize = (_ctx: Deps, p: Params): OidbGroupTodo => ({
groupUin: p.groupId,
msgSeq: p.msgSeq,
});
export const deserialize = (_ctx: Deps, _: OidbEmpty): void => {};
export const encode = (env: OidbBase<OidbGroupTodo>): Uint8Array =>
protobuf_encode<OidbBase<OidbGroupTodo>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbEmpty> =>
protobuf_decode<OidbBase<OidbEmpty>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<void> =>
invokeOidb(deps, GroupTodo, params);
}
@@ -0,0 +1,67 @@
// OIDB 0x93cf_1 — 申请创建 fileSet(上传起点)。
// 请求 f1=1, f2=FileInfo{filename,origName,type=1,size,uploader}, f3=类型码, f12=1。
// 响应 f1=filesetUuid, f2=uploadKey(同f1), f3=上传URL(qfile.qq.com/q/<code>), f4=expire, f5=ttl。
// subCommand=1, reserved=0(真客户端 envelope 实测确认)。
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
FlashApplyFilesetReq,
FlashApplyFilesetResp,
} from '@snowluma/proto-defs/oidb-actions/flash-transfer';
import { invokeOidb, type OidbSender } from '../../oidb-service';
export interface FlashUploaderInfo {
uin: string;
nickname: string;
uid: string;
}
export namespace ApplyFileset {
export const command = 0x93cf;
export const subCommand = 1;
export interface Params {
fileName: string;
origName: string;
fileSize: number;
/** 文件类型码:rar=2, png/mp4=7。 */
typeCode: number;
uploader: FlashUploaderInfo;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): FlashApplyFilesetReq => ({
field1: 1,
fileInfo: {
fileName: p.fileName,
origName: p.origName,
fileType: 1,
fileSize: BigInt(p.fileSize),
uploader: {
uin: p.uploader.uin,
nickname: p.uploader.nickname,
uid: p.uploader.uid,
field4: {},
},
field16: 1,
field20: 0,
field21: 0,
},
typeCode: p.typeCode,
field12: 1,
});
export const deserialize = (_ctx: Deps, body: FlashApplyFilesetResp): FlashApplyFilesetResp =>
body;
export const encode = (env: OidbBase<FlashApplyFilesetReq>): Uint8Array =>
protobuf_encode<OidbBase<FlashApplyFilesetReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<FlashApplyFilesetResp> =>
protobuf_decode<OidbBase<FlashApplyFilesetResp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<FlashApplyFilesetResp> =>
invokeOidb(deps, ApplyFileset, params);
}
@@ -0,0 +1,108 @@
// OIDB 0x12a9_103 — apply-upload(注册 fileId,sliceupload 前调用)。
// uinForm=true。带 MD5 + 客户端构造的 fileId。响应无 rkey(rkey 来自 sub=100)。
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
FlashApplyUploadReq,
FlashApplyUploadResp,
} from '@snowluma/proto-defs/oidb-actions/flash-transfer';
import { invokeOidb, type OidbSender } from '../../oidb-service';
let seqCounter = 100;
export namespace ApplyUpload {
export const command = 0x12a9;
export const subCommand = 103;
export const uinForm = true;
export interface Params {
filesetUuid: string;
fileUuid: string;
fileId: string; // 来自 sub=100 响应
fileName: string;
fileSize: number;
md5: string; // 32 hex
sha1: string; // 40 hex
/** fileset 内序号(1,2,3...),与 0x93d0 commit f6 一致。 */
fileIndex: number;
/** 格式码:与 commit f7 一致(mp4=2, rar/zip=4)。主文件 filesetWrap.f7 用此值;
* 缩略图固定 png=26/jpg=2。 */
formatCode: number;
/** 缩略图类型:undefined=主文件, 'png'=png 缩略图, 'jpg'=jpg 缩略图。 */
thumbType?: 'png' | 'jpg';
width?: number;
height?: number;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): FlashApplyUploadReq => {
// 缩略图与主文件字段差异:config.f103、FileInfo.f5.f1/f6/f7/f9、
// filesetWrap.f4/f5/f6/f7/f9。filesetWrap.f4 统一用 fileIndex。
const isThumb = p.thumbType !== undefined;
const isJpg = p.thumbType === 'jpg';
return {
head: {
sub: { seq: seqCounter++, sub: 103 },
config: {
field101: 2,
field102: 4,
field103: isThumb ? (isJpg ? 24 : 23) : 22,
field200: 5,
},
field3: { field1: 1 },
},
payload: {
wrapper: {
fileInfo: {
fileSize: p.fileSize,
md5: p.md5,
sha1: p.sha1,
fileName: p.fileName,
field5: { field1: isJpg ? 1 : 0, field2: 0, field3: 0, field4: 0 },
field6: p.width ?? 0,
field7: p.height ?? 0,
field8: 0,
field9: isJpg ? 0 : 1,
},
fileId: p.fileId,
field3: 1,
field4: Math.floor(Date.now() / 1000),
field5: 1209600, // TTL 14 天
field6: 0,
},
flag2: { field1: 2 },
field3: { field1: 0, field2: 0, field3: 0, field4: {} },
filesetWrap: {
filesetUuid: p.filesetUuid,
uploadKey: p.filesetUuid,
fileUuid: p.fileUuid,
field4: p.fileIndex,
field5: isThumb && !isJpg ? 1 : 0, // png=1, jpg/mp4=0
field6: isThumb ? (isJpg ? 1 : 0) : 0, // png缩略图=0, jpg缩略图=1, 主文件=0
field7: isThumb ? (isJpg ? 2 : 26) : p.formatCode, // 主文件=formatCode(mp4=2), png缩略图=26, jpg缩略图=2
field8: {},
field9: 1,
field10: 0,
field11: 0,
field12: 0,
field13: 0,
field14: 0,
},
},
};
};
export const deserialize = (_ctx: Deps, _body: FlashApplyUploadResp): void => {
// sub=103 resp f12={f1:filesetWrap}(注册确认,无 rkey)。rkey 来自 sub=100。
};
export const encode = (env: OidbBase<FlashApplyUploadReq>): Uint8Array =>
protobuf_encode<OidbBase<FlashApplyUploadReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<FlashApplyUploadResp> =>
protobuf_decode<OidbBase<FlashApplyUploadResp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<void> =>
invokeOidb(deps, ApplyUpload, params);
}
@@ -0,0 +1,70 @@
// OIDB 0x93d0_1 — fileset 内所有文件的元数据上报(commit)。
// 多文件机制:f4 是 repeated,一个请求同时携带 fileset 内全部文件条目,
// 每条 f6=文件序号(1,2,3...),f7=formatCode。单文件时只有一个条目。
// 响应 f1=1(ack), f2/f3=filesetUuid。subCommand=1, reserved=0。
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
FlashCommitFileReq,
FlashCommitFileResp,
} from '@snowluma/proto-defs/oidb-actions/flash-transfer';
import { invokeOidb, type OidbSender } from '../../oidb-service';
export namespace CommitFile {
export const command = 0x93d0;
export const subCommand = 1;
/** 单个文件的 commit 元数据。fileIndex 为 fileset 内序号(从 1 递增)。 */
export interface CommitEntry {
fileUuid: string;
fileName: string;
origName: string;
fileSize: number;
/** 格式码:rar=4, mp4=2。 */
formatCode: number;
/** fileset 内序号(1,2,3...),与 0x12a9 filesetWrap.f4 一致。 */
fileIndex: number;
}
export interface Params {
filesetUuid: string;
/** fileset 内全部文件条目;一次 0x93d0 请求同时上报。 */
entries: CommitEntry[];
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): FlashCommitFileReq => ({
field1: 1,
filesetUuid: p.filesetUuid,
uploadKey: p.filesetUuid,
commitInfo: p.entries.map((e) => ({
filesetUuid: p.filesetUuid,
fileUuid: e.fileUuid,
field3: 0,
field4: {},
field5: 1,
field6: e.fileIndex,
formatCode: e.formatCode,
fileName: e.fileName,
origName: e.origName,
field10: 0,
fileSize: BigInt(e.fileSize),
field12: 0,
field24: {},
})),
field5: 1,
field6: 1,
});
export const deserialize = (_ctx: Deps, body: FlashCommitFileResp): FlashCommitFileResp => body;
export const encode = (env: OidbBase<FlashCommitFileReq>): Uint8Array =>
protobuf_encode<OidbBase<FlashCommitFileReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<FlashCommitFileResp> =>
protobuf_decode<OidbBase<FlashCommitFileResp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<FlashCommitFileResp> =>
invokeOidb(deps, CommitFile, params);
}
@@ -0,0 +1,38 @@
// OIDB 0x93db_1 — fileSet 完成。请求 f1=filesetUuid, f2=""。响应 ack。sub=1, reserved=0。
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
FlashCompleteFilesetReq,
FlashCompleteFilesetResp,
} from '@snowluma/proto-defs/oidb-actions/flash-transfer';
import { invokeOidb, type OidbSender } from '../../oidb-service';
export namespace CompleteFileset {
export const command = 0x93db;
export const subCommand = 1;
export interface Params {
filesetUuid: string;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): FlashCompleteFilesetReq => ({
filesetUuid: p.filesetUuid,
field2: '',
});
export const deserialize = (_ctx: Deps, _body: FlashCompleteFilesetResp): void => {
// 成功靠 envelope errorCode=0。
};
export const encode = (env: OidbBase<FlashCompleteFilesetReq>): Uint8Array =>
protobuf_encode<OidbBase<FlashCompleteFilesetReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<FlashCompleteFilesetResp> =>
protobuf_decode<OidbBase<FlashCompleteFilesetResp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<void> =>
invokeOidb(deps, CompleteFileset, params);
}
@@ -0,0 +1,40 @@
// OIDB 0x9407_1 — 删除闪传文件(OneBot 标准未定义,QQ 面板有此操作)。
// 请求 f1=filesetUuid, f2="", f3=7。响应短 ack(envelope errorCode=0 即成功)。
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
FlashDeleteFileReq,
FlashDeleteFileResp,
} from '@snowluma/proto-defs/oidb-actions/flash-transfer';
import { invokeOidb, type OidbSender } from '../../oidb-service';
export namespace DeleteFlashFile {
export const command = 0x9407;
export const subCommand = 1;
export interface Params {
filesetUuid: string;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): FlashDeleteFileReq => ({
filesetUuid: p.filesetUuid,
field2: '',
field3: 7,
});
export const deserialize = (_ctx: Deps, _body: FlashDeleteFileResp): void => {
// 成功靠 envelope errorCode=0(invokeOidb 已校验),业务体空。
};
export const encode = (env: OidbBase<FlashDeleteFileReq>): Uint8Array =>
protobuf_encode<OidbBase<FlashDeleteFileReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<FlashDeleteFileResp> =>
protobuf_decode<OidbBase<FlashDeleteFileResp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<void> =>
invokeOidb(deps, DeleteFlashFile, params);
}
@@ -0,0 +1,78 @@
// OIDB 0x93d4_1 — 拉取下载URL(完整参数 + fldc)。
// 响应下载URL: multimedia.qfile.qq.com/download?appid=14902&client_type=win
// &client_ver=...&fileid=...&fldc=...(无 rkey,用 fldc)。
// 响应结构与 0x93d3 不同:downloadUrl 在 f1.f3.f13.f2.f2(非 f9.fileIdWrap)。
// 用于 get_flash_file_url / download_fileset 的完整链接变体。
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
FlashGetDownloadUrlReq,
FlashGetDownloadUrlResp,
} from '@snowluma/proto-defs/oidb-actions/flash-transfer';
import { invokeOidb, type OidbSender } from '../../oidb-service';
export namespace GetDownloadUrl {
export const command = 0x93d4;
export const subCommand = 1;
export interface Params {
filesetUuid: string;
/** 指定下载 fileset 内第几个文件(1-based)。默认 1(主文件)。 */
fileIndex?: number;
}
/** 单个文件的下载元信息;无主文件 fileId 时该条目 null。 */
export type FileMeta = {
fileIndex: number;
fileId: string;
filesetUuid: string;
fileUuid: string;
fileName: string;
fileSize: number;
};
/** fileset 内所有文件元信息(按 fileIndex 升序)。 */
export type Result = FileMeta[];
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): FlashGetDownloadUrlReq => ({
filesetUuid: p.filesetUuid,
inner: {
field1: '',
field2: 1,
field3: 18,
field4: '',
field5: { field1: 0 },
field6: { field1: 0, field2: 0 },
},
field3: 7,
field4: 1,
});
export const deserialize = (_ctx: Deps, body: FlashGetDownloadUrlResp): Result => {
// f1.f3 是 repeated fileInfo,多文件 fileset 时每个文件一条(f6=序号,f14=主文件 fileId)。
// f13 是缩略图(appid=14902),主文件下载 URL 需 0x12a9 sub=200 拿。
const infos = body.entry?.fileInfo ?? [];
return infos.map((info, idx) => {
const fileId = info?.mainFile?.fileId ?? '';
const fileIndex = info?.field6 ?? idx + 1;
return {
fileIndex,
fileId,
filesetUuid: info?.filesetUuid ?? '',
fileUuid: info?.fileUuid ?? '',
fileName: info?.fileName ?? '',
fileSize: Number(info?.fileSize ?? 0),
};
});
};
export const encode = (env: OidbBase<FlashGetDownloadUrlReq>): Uint8Array =>
protobuf_encode<OidbBase<FlashGetDownloadUrlReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<FlashGetDownloadUrlResp> =>
protobuf_decode<OidbBase<FlashGetDownloadUrlResp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<Result> =>
invokeOidb(deps, GetDownloadUrl, params);
}
@@ -0,0 +1,41 @@
// OIDB 0x93d3_1 — 拉取文件集详情(点"分享"显示链接时触发)。
// 请求 f1=filesetUuid, f2=7。响应 f1=repeated FlashFileEntry(含文件名/大小/
// 上传分享URL qfile.qq.com/q/<code> / fileId / 下载URL,URL 里已含 rkey)。
// 用于 get_fileset_info / get_flash_file_list / get_flash_file_url / get_share_link。
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
FlashGetDetailReq,
FlashGetDetailResp,
FlashFileEntry,
} from '@snowluma/proto-defs/oidb-actions/flash-transfer';
import { invokeOidb, type OidbSender } from '../../oidb-service';
export namespace GetFilesetDetail {
export const command = 0x93d3;
export const subCommand = 1;
export interface Params {
filesetUuid: string;
}
export type Result = FlashFileEntry[];
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): FlashGetDetailReq => ({
filesetUuid: p.filesetUuid,
field2: 7,
});
export const deserialize = (_ctx: Deps, body: FlashGetDetailResp): Result => body.entries ?? [];
export const encode = (env: OidbBase<FlashGetDetailReq>): Uint8Array =>
protobuf_encode<OidbBase<FlashGetDetailReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<FlashGetDetailResp> =>
protobuf_decode<OidbBase<FlashGetDetailResp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<Result> =>
invokeOidb(deps, GetFilesetDetail, params);
}
@@ -0,0 +1,106 @@
// OIDB 0x12a9_200 — get-download(拿主文件下载直链)。
// 请求 payload @ f3(不同于 sub=100 的 f2 / sub=103 的 f12)。响应 f3 含 host + path
// (主文件下载 URL,appid=14901 + 主文件fileId + fldc + rkey)。
// 主文件 fileId 来自 0x93d4 f14.f1。0x93d3/0x93d4 的 downloadUrl 是缩略图(appid=14903/14902),
// 主文件下载 URL 必须走 sub=200。
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
FlashGetDownloadReq,
FlashGetDownloadResp,
} from '@snowluma/proto-defs/oidb-actions/flash-transfer';
import { invokeOidb, type OidbSender } from '../../oidb-service';
let seqCounter = 300;
export namespace GetFlashDownload {
export const command = 0x12a9;
export const subCommand = 200;
export const uinForm = true;
export interface Params {
filesetUuid: string;
fileUuid: string; // 主文件 fileUuid(0x93d4 f1.f3.f2)
fileId: string; // 主文件 fileId(0x93d4 f14.f1)
fileName: string;
}
export type Result = string | null; // 主文件下载 URL
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): FlashGetDownloadReq => ({
head: {
sub: { seq: seqCounter++, sub: 200 },
config: { field101: 2, field102: 4, field103: 22, field200: 5 }, // mp4 f103=22
field3: { field1: 1 },
},
payload: {
wrapper: {
fileInfo: {
field1: 0,
field4: p.fileName,
field5: { field1: 0, field2: 0, field3: 0, field4: 0 },
field6: 0,
field7: 0,
field8: 0,
field9: 0,
},
fileId: p.fileId,
field3: 0,
field4: 0,
field5: 0,
field6: 0,
},
field2: {
// 固定下载参数(QQ 客户端实测值,未观察到随文件变化)。
field2: {
field1: 4294967294,
field3: 4294967295,
field5: 111,
field6: { field1: 3403722988 },
},
field4: { field1: 0 },
// field3 必须是主文件类型码 26;用 2 会被服务端拒(170019002 Service Failure)。
filesetWrap: {
filesetUuid: p.filesetUuid,
fileUuid: p.fileUuid,
field3: 26,
field4: p.fileUuid,
},
},
field3: 0,
},
});
export const deserialize = (_ctx: Deps, body: FlashGetDownloadResp): Result => {
// 响应 f3 含 rkey + host(multimedia.qfile.qq.com)+ path(/download?appid=14901...&fldc=...)。
// rkey 在 f3 单独字段(path 不含 rkey),拼完整 URL 需拼接 &rkey=。
const raw = body.body;
if (!raw || raw.length === 0) {
return null;
}
const text = Buffer.from(raw).toString('latin1');
const host = /multimedia\.qfile\.qq\.com/.exec(text);
const path = /\/download\?appid=14901[\x20-\x7e]*/.exec(text);
const rkey = /rkey=([A-Za-z0-9_-]+)/.exec(text);
if (!host || !path) {
return null;
}
const base = `https://${host[0]}${path[0]}`;
return rkey ? `${base}&${rkey[0]}` : base;
};
export const encode = (env: OidbBase<FlashGetDownloadReq>): Uint8Array =>
protobuf_encode<OidbBase<FlashGetDownloadReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<FlashGetDownloadResp> =>
protobuf_decode<OidbBase<FlashGetDownloadResp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<Result> =>
invokeOidb(deps, GetFlashDownload, params);
}
@@ -0,0 +1,42 @@
// OIDB 0x93d2_1 — 查询 fileset 列表(全部 fileset,无 UUID 入参)。
// 请求 f1=3, f2="", f3=10。响应 f1=repeated FlashFileEntry。
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
FlashListFilesetsReq,
FlashListFilesetsResp,
FlashFileEntry,
} from '@snowluma/proto-defs/oidb-actions/flash-transfer';
import { invokeOidb, type OidbSender } from '../../oidb-service';
export namespace ListFilesets {
export const command = 0x93d2;
export const subCommand = 1;
export interface Params {
field1?: number; // 默认 3
field3?: number; // 默认 10
}
export type Result = FlashFileEntry[];
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): FlashListFilesetsReq => ({
field1: p.field1 ?? 3,
field2: '',
field3: p.field3 ?? 10,
});
export const deserialize = (_ctx: Deps, body: FlashListFilesetsResp): Result =>
body.entries ?? [];
export const encode = (env: OidbBase<FlashListFilesetsReq>): Uint8Array =>
protobuf_encode<OidbBase<FlashListFilesetsReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<FlashListFilesetsResp> =>
protobuf_decode<OidbBase<FlashListFilesetsResp>>(bytes);
export const invoke = (deps: Deps, params: Params = {}): Promise<Result> =>
invokeOidb(deps, ListFilesets, params);
}
@@ -0,0 +1,114 @@
// OIDB 0x12a9_100 — prepare-upload(大文件分片上传前申请 sliceupload rkey)。
// sub=100,uinForm=true。sub=100 的 payload 在 f2(sub=103 在 f12),结构完全不同。
// 响应 f2.f1 是 sliceupload rkey;秒传(文件已在服务端)时 f1 缺失,deserialize 返回 null。
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
FlashPrepareUploadReq,
FlashPrepareUploadResp,
} from '@snowluma/proto-defs/oidb-actions/flash-transfer';
import { invokeOidb, type OidbSender } from '../../oidb-service';
let seqCounter = 200;
export namespace PrepareUpload {
export const command = 0x12a9;
export const subCommand = 100;
export const uinForm = true;
export interface Params {
filesetUuid: string;
fileUuid: string;
fileName: string;
fileSize: number;
sha1: string; // 40 hex
/** fileset 内序号(1,2,3...),与 0x93d0 commit f6 一致。缩略图序号在主文件之后递增。 */
fileIndex: number;
/** 格式码:与 commit f7 一致(mp4=2, rar/zip=4)。主文件 filesetWrap.f7 用此值;
* 缩略图固定 png=26/jpg=2。 */
formatCode: number;
/** 缩略图类型:undefined=主文件, 'png'=png 缩略图, 'jpg'=jpg 缩略图。
* 主文件下载入口需要缩略图关联才会被服务端填充,缩略图字段差异据此切换。 */
thumbType?: 'png' | 'jpg';
width?: number; // 缩略图宽(主文件=0)
height?: number; // 缩略图高(主文件=0)
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): FlashPrepareUploadReq => {
// 缩略图与主文件字段差异:config.f103、FileInfo.f5.f1/f6/f7/f9、filesetWrap.f6/f7。
// filesetWrap.f4 统一用 fileIndex(主文件和缩略图都从 facade 传入,保证与 commit f6 一致)。
const isThumb = p.thumbType !== undefined;
const isJpg = p.thumbType === 'jpg';
return {
head: {
sub: { seq: seqCounter++, sub: 100 },
config: {
field101: 2,
field102: 4,
field103: isThumb ? (isJpg ? 24 : 23) : 22,
field200: 5,
},
field3: { field1: 1 },
},
payload: {
wrapper: {
fileInfo: {
fileSize: p.fileSize,
md5: '', // sub=100 不带 MD5
sha1: p.sha1,
fileName: p.fileName,
field5: { field1: isJpg ? 1 : 0, field2: 0, field3: 0, field4: 0 },
field6: p.width ?? 0,
field7: p.height ?? 0,
field8: 0,
field9: isJpg ? 0 : 1,
},
field2: 0, // wrapper.f2=0(varint,不是 fileId)
},
field2: 1,
field3: 0,
field4: 0,
field5: 0,
field6: {
field1: { field1: 0, field2: {} },
field2: { field3: {} },
field3: { field11: {}, field12: {} },
field10: 0,
},
field7: 0,
field8: 0,
filesetWrap: {
filesetUuid: p.filesetUuid,
uploadKey: p.filesetUuid,
fileUuid: p.fileUuid,
field4: p.fileIndex,
field5: 0,
field6: isThumb ? 1 : 0, // 缩略图=1, 主文件=0
field7: isThumb ? (isJpg ? 2 : 26) : p.formatCode, // 主文件=formatCode(mp4=2), png缩略图=26, jpg缩略图=2
field8: {},
field9: 1,
field10: 0,
field11: 0,
field12: 0,
field13: 0,
field14: 0,
},
},
};
};
export const deserialize = (_ctx: Deps, body: FlashPrepareUploadResp): string | null => {
// sub-100 resp f2={f1:rkey}(正常上传,CAES/CAIS/CAQS/CAMS)。
// 秒传(文件已在服务端)时 f2 无 f1(rkey),返回 null,调用方仍要 apply,
// 只跳过 sliceupload。
return body.rkeyWrap?.rkey ?? null;
};
export const encode = (env: OidbBase<FlashPrepareUploadReq>): Uint8Array =>
protobuf_encode<OidbBase<FlashPrepareUploadReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<FlashPrepareUploadResp> =>
protobuf_decode<OidbBase<FlashPrepareUploadResp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<string | null> =>
invokeOidb(deps, PrepareUpload, params);
}
@@ -0,0 +1,45 @@
// OIDB 0x9427_0 — 重命名闪传文件(OneBot 标准未定义,QQ 面板有此操作)。
// 请求 f1=filesetUuid, f2={f1=新文件名, f2=显示名}, f3={f1=0}。
// 注意 envelope 与其他闪传 cmd 不同:subCommand=0(不是 1)+ uinForm=true(reserved=1)。
// 不能按 0x93cf_1 类推,用 sub=1 会被服务端拒(no privilege)。
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
FlashRenameFileReq,
FlashRenameFileResp,
} from '@snowluma/proto-defs/oidb-actions/flash-transfer';
import { invokeOidb, type OidbSender } from '../../oidb-service';
export namespace RenameFlashFile {
export const command = 0x9427;
export const subCommand = 0;
export const uinForm = true;
export interface Params {
filesetUuid: string;
/** 新文件名(同时用作显示名,与 QQ 面板行为一致)。 */
newName: string;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): FlashRenameFileReq => ({
filesetUuid: p.filesetUuid,
name: { newName: p.newName, displayName: p.newName },
flag: { field1: 0 },
});
export const deserialize = (_ctx: Deps, _body: FlashRenameFileResp): void => {
// 成功靠 envelope errorCode=0。
};
export const encode = (env: OidbBase<FlashRenameFileReq>): Uint8Array =>
protobuf_encode<OidbBase<FlashRenameFileReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<FlashRenameFileResp> =>
protobuf_decode<OidbBase<FlashRenameFileResp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<void> =>
invokeOidb(deps, RenameFlashFile, params);
}
@@ -0,0 +1,50 @@
// OIDB 0x93d7_1 — 发送闪传文件给用户(send_flash_msg)。
// 请求 f1={f1:1, f2:{f1:targetUid}}, f2=filesetUuid。响应仅回显目标 uid(无 message_id)。
// 0x93d7 是「分享 fileset 给用户」(对端通过 fileset 链接下载),非传统消息。
// envelope 用 0x93xx 默认的 sub=1/reserved=0,实测通过。
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { FlashSendReq, FlashSendResp } from '@snowluma/proto-defs/oidb-actions/flash-transfer';
import { invokeOidb, type OidbSender } from '../../oidb-service';
export namespace SendFlashMsg {
export const command = 0x93d7;
export const subCommand = 1;
export interface Params {
targetUid?: string; // 私聊(与 groupId 二选一)
groupId?: number; // 群聊
filesetUuid: string;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): FlashSendReq => {
// 群聊:f1={f1:2, f3:{f1:groupId}}(groupId 直接用群号,无需转 uid)。
if (p.groupId !== undefined) {
return {
target: { field1: 2, targetGroup: { groupId: p.groupId } },
filesetUuid: p.filesetUuid,
};
}
// 私聊:f1={f1:1, f2:{f1:targetUid}}。
return {
target: { field1: 1, targetUid: { targetUid: p.targetUid! } },
filesetUuid: p.filesetUuid,
};
};
export const deserialize = (_ctx: Deps, _body: FlashSendResp): void => {
// 响应仅回显目标 uid,无 message_id。
};
export const encode = (env: OidbBase<FlashSendReq>): Uint8Array =>
protobuf_encode<OidbBase<FlashSendReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<FlashSendResp> =>
protobuf_decode<OidbBase<FlashSendResp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<void> =>
invokeOidb(deps, SendFlashMsg, params);
}
@@ -0,0 +1,39 @@
// OIDB 0x93d1_1 — 设置 fileSet 状态。请求 f1=filesetUuid, f2=6(状态码)。响应 ack。sub=1, reserved=0。
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
FlashSetStatusReq,
FlashSetStatusResp,
} from '@snowluma/proto-defs/oidb-actions/flash-transfer';
import { invokeOidb, type OidbSender } from '../../oidb-service';
export namespace SetFilesetStatus {
export const command = 0x93d1;
export const subCommand = 1;
export interface Params {
filesetUuid: string;
status?: number; // 默认 6
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): FlashSetStatusReq => ({
filesetUuid: p.filesetUuid,
status: p.status ?? 6,
});
export const deserialize = (_ctx: Deps, _body: FlashSetStatusResp): void => {
// 成功靠 envelope errorCode=0。
};
export const encode = (env: OidbBase<FlashSetStatusReq>): Uint8Array =>
protobuf_encode<OidbBase<FlashSetStatusReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<FlashSetStatusResp> =>
protobuf_decode<OidbBase<FlashSetStatusResp>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<void> =>
invokeOidb(deps, SetFilesetStatus, params);
}
@@ -0,0 +1,49 @@
// Approve an existing filtered friend request.
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase, OidbEmpty } from '@snowluma/proto-defs/oidb';
import type { OidbDoubtApprovalReq } from '@snowluma/proto-defs/oidb-actions/doubt-buddy';
import type { BridgeContext } from '../../bridge-context';
import { invokeOidb, type OidbSender } from '../../oidb-service';
import { resolveSelfUid } from '../../self-uid';
export namespace ApproveDoubtBuddyReq {
export const command = 0xd72;
export const subCommand = 0;
export const uinForm = false;
export interface Params {
uid: string;
}
export type Deps = OidbSender & Pick<BridgeContext, 'identity' | 'resolveUserUid'>;
export const serialize = async (ctx: Deps, p: Params): Promise<OidbDoubtApprovalReq> => {
if (!p.uid.trim()) {
throw new Error('cannot approve doubt request: applicant uid is unavailable');
}
const selfUid = await resolveSelfUid(ctx);
if (!selfUid.trim()) {
throw new Error('cannot approve doubt request: self uid is unavailable');
}
return {
selfUid,
targetUid: p.uid,
field3: 0,
field4: '',
};
};
export const deserialize = (_ctx: Deps, _: OidbEmpty): void => {};
export const encode = (env: OidbBase<OidbDoubtApprovalReq>): Uint8Array =>
protobuf_encode<OidbBase<OidbDoubtApprovalReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbEmpty> =>
protobuf_decode<OidbBase<OidbEmpty>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<void> =>
invokeOidb(deps, ApproveDoubtBuddyReq, params);
}
@@ -0,0 +1,35 @@
// 0x912F_0 — clear the remark (备注) stored for a friend. Current QQ
// uses a separate StrangerRemarkSetWorker command instead of sending an
// empty string through 0x912E_0.
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase, OidbEmpty } from '@snowluma/proto-defs/oidb';
import type { OidbClearFriendRemark } from '@snowluma/proto-defs/oidb-actions/base';
import { invokeOidb, type OidbSender } from '../../oidb-service';
import type { BridgeContext } from '../../bridge-context';
export namespace ClearFriendRemark {
export const command = 0x912f;
export const subCommand = 0;
export interface Params {
userId: number;
}
export type Deps = OidbSender & Pick<BridgeContext, 'resolveUserUid'>;
export const serialize = async (ctx: Deps, p: Params): Promise<OidbClearFriendRemark> => ({
target: { targetUid: await ctx.resolveUserUid(p.userId) },
});
export const deserialize = (_ctx: Deps, _: OidbEmpty): void => {};
export const encode = (env: OidbBase<OidbClearFriendRemark>): Uint8Array =>
protobuf_encode<OidbBase<OidbClearFriendRemark>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbEmpty> =>
protobuf_decode<OidbBase<OidbEmpty>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<void> =>
invokeOidb(deps, ClearFriendRemark, params);
}
@@ -0,0 +1,54 @@
// 0x126B_0 — delete a friend (optionally with block flag).
//
// The deeply-nested field2 sub-message is a server-side discriminator
// that must be sent verbatim — the magic numbers (130 / 109 / 8/8/50)
// match the wire shape NTQQ's own client emits and aren't a SnowLuma
// invention. Server rejects the request if any of them is wrong.
//
// Cache invalidation (re-fetching the friend list after deletion)
// lives in the facade — keeps this namespace dependency-free of
// `apis.contacts` and the OneBot-side caches.
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase, OidbEmpty } from '@snowluma/proto-defs/oidb';
import type { OidbDeleteFriend } from '@snowluma/proto-defs/oidb-actions/base';
import { invokeOidb, type OidbSender } from '../../oidb-service';
import type { BridgeContext } from '../../bridge-context';
export namespace DeleteFriend {
export const command = 0x126b;
export const subCommand = 0;
export interface Params {
userId: number;
/** Also block (拉黑) the user. Default false. */
block?: boolean;
}
export type Deps = OidbSender & Pick<BridgeContext, 'resolveUserUid'>;
export const serialize = async (ctx: Deps, p: Params): Promise<OidbDeleteFriend> => ({
field1: {
targetUid: await ctx.resolveUserUid(p.userId),
field2: {
field1: 130,
field2: 109,
field3: { field1: 8, field2: 8, field3: 50 },
},
// DelBuddyWorker receives both booleans explicitly from current QQ.
block: p.block ?? false,
field4: true,
},
});
export const deserialize = (_ctx: Deps, _: OidbEmpty): void => {};
export const encode = (env: OidbBase<OidbDeleteFriend>): Uint8Array =>
protobuf_encode<OidbBase<OidbDeleteFriend>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbEmpty> =>
protobuf_decode<OidbBase<OidbEmpty>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<void> =>
invokeOidb(deps, DeleteFriend, params);
}
@@ -0,0 +1,95 @@
// 0xd69_0 — getDoubtBuddyReq: list the "doubtful" friend-add requests
// (可能认识的人 / 被过滤的好友申请). RE'd from QQNT doubt_codec.cc.
// Request {1:1, 2:{1:num, 2:uk}} (reqId is NOT on the wire). Response body
// holds a repeated item list. Two wire shapes exist for the per-item id:
// Linux 3.2.32 sends the account number as a tag-1 varint (no string uid),
// while some builds send a string uid at tag 1. The item is decoded twice so
// the numeric and string shapes merge; reqTime(8) and groupCode(9) are
// numeric on Linux. String names (nick/source/msg) are MEDIUM confidence.
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { pb, pb_repeated, uint_32, uint_64 } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbDoubtGetReq,
OidbDoubtGetResp,
} from '@snowluma/proto-defs/oidb-actions/doubt-buddy';
import { invokeOidb, type OidbSender } from '../../oidb-service';
interface OidbDoubtItemAccountOnTag1 {
uin?: pb<1, uint_64>;
}
interface OidbDoubtGetRespAccountOnTag1Body {
list?: pb_repeated<1, OidbDoubtItemAccountOnTag1>;
}
interface OidbDoubtGetRespAccountOnTag1 {
status?: pb<1, uint_32>;
body?: pb<2, OidbDoubtGetRespAccountOnTag1Body>;
}
type DecodedDoubtItem = NonNullable<NonNullable<OidbDoubtGetResp['body']>['list']>[number] & {
uin?: bigint;
};
interface DecodedDoubtGetResp extends Omit<OidbDoubtGetResp, 'body'> {
body?: Omit<NonNullable<OidbDoubtGetResp['body']>, 'list'> & { list?: DecodedDoubtItem[] };
}
export interface DoubtBuddyRequest {
[key: string]: import('@snowluma/common/json').JsonValue;
/** Opaque uid — pass back as `flag` to set_doubt_friends_add_request. */
uid: string;
user_id: number;
nick: string;
source: string;
reason: string;
msg: string;
group_code: string;
reqTime: number;
}
export namespace GetDoubtBuddyReq {
export const command = 0xd69;
export const subCommand = 0;
export const uinForm = true;
export interface Params {
count: number;
cookie?: string;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): OidbDoubtGetReq => ({
field1: 1,
inner: { num: p.count, uk: p.cookie ?? '' },
});
export const deserialize = (_ctx: Deps, body: DecodedDoubtGetResp): DoubtBuddyRequest[] =>
(body.body?.list ?? []).map((it) => ({
uid: it.uid ?? '',
user_id: Number(it.uin ?? 0),
nick: it.nick ?? '',
source: it.source ?? '',
reason: it.reason ?? '',
msg: it.msg ?? '',
group_code: it.groupCode ? String(it.groupCode) : '',
reqTime: Number(it.reqTime ?? 0),
}));
export const encode = (env: OidbBase<OidbDoubtGetReq>): Uint8Array =>
protobuf_encode<OidbBase<OidbDoubtGetReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<DecodedDoubtGetResp> => {
const decoded = protobuf_decode<OidbBase<OidbDoubtGetResp>>(bytes);
const numeric = protobuf_decode<OidbBase<OidbDoubtGetRespAccountOnTag1>>(bytes);
const items = decoded.body?.body?.list ?? [];
const numericItems = numeric.body?.body?.list ?? [];
for (let i = 0; i < items.length; i++) {
(items[i] as DecodedDoubtItem).uin = numericItems[i]?.uin;
}
return decoded as OidbBase<DecodedDoubtGetResp>;
};
export const invoke = (deps: Deps, params: Params): Promise<DoubtBuddyRequest[]> =>
invokeOidb(deps, GetDoubtBuddyReq, params);
}
@@ -0,0 +1,47 @@
// 0xB5D_44 — accept (3) / reject (5) an inbound friend request.
//
// Caller passes either a pre-resolved UID string or a numeric uin
// (encoded as a digit-only string); we detect the latter and resolve
// to UID first.
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase, OidbEmpty } from '@snowluma/proto-defs/oidb';
import type { OidbFriendRequestAction } from '@snowluma/proto-defs/oidb-actions/base';
import { invokeOidb, type OidbSender } from '../../oidb-service';
import type { BridgeContext } from '../../bridge-context';
export namespace HandleFriendRequest {
export const command = 0xb5d;
export const subCommand = 44;
export interface Params {
/** Pre-resolved UID, or a digit-only numeric uin to resolve on demand. */
uidOrFlag: string;
approve: boolean;
}
export type Deps = OidbSender & Pick<BridgeContext, 'resolveUserUid'>;
export const serialize = async (ctx: Deps, p: Params): Promise<OidbFriendRequestAction> => {
const targetUid = /^\d+$/.test(p.uidOrFlag)
? await ctx.resolveUserUid(parseInt(p.uidOrFlag, 10))
: p.uidOrFlag;
return {
// Server discriminator: 3 = accept, 5 = reject. Pre-fix values
// verified against current production behaviour.
accept: p.approve ? 3 : 5,
targetUid,
};
};
export const deserialize = (_ctx: Deps, _: OidbEmpty): void => {};
export const encode = (env: OidbBase<OidbFriendRequestAction>): Uint8Array =>
protobuf_encode<OidbBase<OidbFriendRequestAction>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbEmpty> =>
protobuf_decode<OidbBase<OidbEmpty>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<void> =>
invokeOidb(deps, HandleFriendRequest, params);
}
@@ -0,0 +1,38 @@
// 0xd69_0 — delDoubtBuddyReq: delete/reject (decline) a doubtful friend-add
// request. RE'd from QQNT doubt_buddy_del_worker.cc (worker ctor sub_3F3F320
// → cmd 0xd69 sub 0; EncodeRequest sub_3F3E860 writes {1: const 3, 3:{1:uid}}).
// The top-level tag-1 constant 3 is the op discriminator (getDoubtBuddyReq
// uses 1). Rejection shares the listing command, not the approval command.
// uin-form OIDB (envelope reserved=1).
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase, OidbEmpty } from '@snowluma/proto-defs/oidb';
import type { OidbDoubtDelReq } from '@snowluma/proto-defs/oidb-actions/doubt-buddy';
import { invokeOidb, type OidbSender } from '../../oidb-service';
export namespace RejectDoubtBuddyReq {
export const command = 0xd69;
export const subCommand = 0;
export const uinForm = true;
export interface Params {
uid: string;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): OidbDoubtDelReq => ({
field1: 3,
inner: { uid: p.uid },
});
export const deserialize = (_ctx: Deps, _: OidbEmpty): void => {};
export const encode = (env: OidbBase<OidbDoubtDelReq>): Uint8Array =>
protobuf_encode<OidbBase<OidbDoubtDelReq>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbEmpty> =>
protobuf_decode<OidbBase<OidbEmpty>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<void> =>
invokeOidb(deps, RejectDoubtBuddyReq, params);
}
Loaded 100 of 1367 files, more files were not shown because too many files have changed in this diff. Show more