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
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:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
.replaceAll('\t', '	')
|
||||
.replaceAll('\n', '
')
|
||||
.replaceAll('\r', '
');
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user