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

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

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
// action-docs — self-generated OneBot API docs (D4).
//
// Walks `ActionSpec.describe()` over every declarative action and renders a
// structured doc list + a Markdown view. The metadata is carried on the same
// values that drive runtime validation, so docs cannot drift from behavior.
//
// The reserved `.handle_quick_operation` raw handler (and its `_async` alias)
// deliberately has no ActionSpec/doc entry; every catalogued action comes
// from ACTION_REGISTRY.
//
// The serving SURFACE (WebUI panel / OpenAPI export / static markdown) is a
// deferred product decision; this module produces format-agnostic data plus a
// Markdown default. Point any renderer at `collectActionDocs()`.
import type { ActionDoc } from './action-kit';
import { ACTION_REGISTRY } from './actions';
/** Every declarative action's doc (with category), sorted by name. */
export function collectActionDocs(): ActionDoc[] {
return (
ACTION_REGISTRY.actions
// Preserve the collector's value semantics: callers may sort/transform
// docs without mutating the process-wide compiled registry.
.map(({ doc }) => structuredClone(doc))
.sort((a, b) => a.name.localeCompare(b.name))
);
}
/** Distinct categories with action counts. */
export function collectCategories(): Array<{ category: string; count: number }> {
return ACTION_REGISTRY.categories.map(({ category, count }) => ({ category, count }));
}
function paramRow(p: ActionDoc['params'][number]): string {
const type = p.values ? p.values.map((v) => JSON.stringify(v)).join(' \\| ') : p.type;
const required = p.required ? '✓' : '–';
const def = !p.required && p.default !== undefined ? `\`${JSON.stringify(p.default)}\`` : '';
return `| \`${p.name}\` | ${type} | ${required} | ${def} | ${p.desc ?? ''} |`;
}
function renderAction(doc: ActionDoc): string {
const lines: string[] = [];
const alias = doc.aliases.length
? ` _(别名: ${doc.aliases.map((a) => `\`${a}\``).join(', ')})_`
: '';
const cat = doc.category ? ` · ${doc.category}` : '';
lines.push(`### \`${doc.name}\`${cat}${alias}`);
if (doc.summary) {
lines.push('', doc.summary);
}
if (doc.params.length) {
lines.push('', '| 参数 | 类型 | 必填 | 默认 | 说明 |', '| --- | --- | --- | --- | --- |');
for (const p of doc.params) {
lines.push(paramRow(p));
}
} else {
lines.push('', '_无参数_');
}
if (doc.invariants.length) {
lines.push('', `**约束:** ${doc.invariants.map((i) => `\`${i}\``).join(';')}`);
}
if (doc.returns) {
lines.push('', `**返回:** \`${doc.returns}\``);
}
for (const example of doc.examples ?? []) {
lines.push('', `**示例:${example.summary}**`);
if (example.notes) {
lines.push('', example.notes);
}
lines.push(
'',
'```json',
JSON.stringify({ action: doc.name, params: example.params }, null, 2),
'```',
);
}
return lines.join('\n');
}
/** Render the full Markdown doc. */
export function renderActionDocsMarkdown(docs: readonly ActionDoc[] = collectActionDocs()): string {
const header = [
'# OneBot Actions',
'',
'> 由 `packages/onebot/src/action-docs.ts` 从各 `ActionSpec.describe()` 自动生成,请勿手改。',
'> 覆盖完整声明式 Action registry;保留的 raw `.handle_quick_operation` / `.handle_quick_operation_async` handler 不生成文档。',
'',
`共 ${docs.length} 个声明式 action。`,
'',
];
return header.concat(docs.map(renderAction)).join('\n') + '\n';
}
+964
View File
@@ -0,0 +1,964 @@
// action-kit — declarative OneBot Action definitions.
//
// One Action declares its param contract once (a record of `Field`s + optional
// cross-field `rules`) and a `run` handler. The kit owns param coercion +
// validation + the `BAD_REQUEST` shaping (naming the offending field) +
// doc metadata. The handler receives typed, already-validated params.
//
// Narrow seam (ADR-0006): `ApiHandler` keeps dispatch / try-catch / response
// wrapping. `toHandler(ctx)` produces the plain `(params, sink?) => ApiResponse`
// that ActionRegistry.bind installs into the sealed handler table.
//
// Zero runtime npm deps (ADR-0004). Type inference via `InferParams` — no
// hand-written param interface. Param contract is intentionally a second
// definition from the SDK's client-side types (ADR-0005).
//
// Guard-rail (ADR-0007): the built-in coercers below are strict. Do NOT add a
// lenient `union`/`refine` escape that re-introduces the silent coercion this
// module exists to remove.
import { RETCODE, failedResponse } from './types';
import type { ApiResponse, JsonObject, JsonValue } from './types';
import type { ApiActionContext } from './api-handler';
import { type StreamSink, NOOP_SINK } from './streaming';
// ─────────────────────────── result currency ───────────────────────────
export type Ok<T> = { ok: true; value: T };
/** `field` is the offending param ('' for a cross-field rule). */
export type Err = { ok: false; field: string; reason: string };
export type CoerceResult<T> = Ok<T> | Err;
const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
const err = (field: string, reason: string): Err => ({ ok: false, field, reason });
/** Only an absent key produces MISSING — this is what lets a present `0` /
* `''` / `false` be distinguished from "not provided". */
const MISSING = Symbol('missing');
type Raw = JsonValue | typeof MISSING;
// ─────────────────────────── Field (param coercer) ───────────────────────────
/** A JSON Schema fragment (loose). Each coercer emits one; `describe()` composes
* them into an action's `inputSchema` (consumed by docs / the MCP). */
export type JsonSchema = Record<string, unknown>;
/** Semantic role of a param, orthogonal to `type` (which governs coercion).
* Two `uint` fields `group_id` and `user_id` coerce identically but mean
* different things — `role` is what a UI keys its picker/widget off of.
* Closed set: consumers (the WebUI debug console) switch on it exhaustively
* and fall back to the plain typed control for anything unrecognised. */
export type FieldRole =
| 'group_id'
| 'user_id'
| 'member_id' // a group member's uin — its picker depends on a sibling group_id
| 'message_id'
| 'file_id'
| 'file'
| 'image'
| 'record'
| 'video'
| 'duration'
| 'timestamp'
| 'face_id';
export interface FieldDoc {
type: string;
required: boolean;
default?: JsonValue;
desc?: string;
values?: readonly (string | number)[];
/** Semantic role driving UI widget selection; orthogonal to `type`. */
role?: FieldRole;
/** JSON Schema fragment for this field's value (constraints only; presence
* and default are applied at the object level by `describe()`). */
schema?: JsonSchema;
}
export interface Field<T> {
readonly doc: FieldDoc;
/** Coerce the raw value at this field's key (MISSING when the key is absent). */
coerce(raw: Raw, field: string): CoerceResult<T>;
/** Absent ⇒ value is `undefined`; a present value still must coerce. */
optional(): Field<T | undefined>;
/** Absent ⇒ `value`; a present value still must coerce (so `"abc"` for a
* numeric field is an error, not a silent fallback). */
default(value: T): Field<T>;
describe(text: string): Field<T>;
/** Stamp a semantic role (escape hatch for params whose name lies, or to
* override a preset). Most call sites use the `f.groupId()`-style semantic
* constructors instead. */
role(role: FieldRole): Field<T>;
/** Complete JSON Schema for this field's value (constraints + description +
* default). Object-level required-ness is handled by `describe()`. */
toJsonSchema(): JsonSchema;
}
/** Core coerces a PRESENT value (never MISSING). Presence policy is applied
* by the wrapping Field. */
type Core<T> = (raw: JsonValue, field: string) => CoerceResult<T>;
class FieldImpl<T> implements Field<T> {
constructor(
private readonly core: Core<T>,
readonly doc: FieldDoc,
) {}
coerce(raw: Raw, field: string): CoerceResult<T> {
if (raw === MISSING) {
if (this.doc.required) {
return err(field, 'is required');
}
return ok(this.doc.default as T); // default value, or undefined when optional
}
return this.core(raw, field);
}
optional(): Field<T | undefined> {
return new FieldImpl<T | undefined>(this.core as Core<T | undefined>, {
...this.doc,
required: false,
default: undefined,
});
}
default(value: T): Field<T> {
return new FieldImpl<T>(this.core, {
...this.doc,
required: false,
default: value as JsonValue,
});
}
describe(text: string): Field<T> {
return new FieldImpl<T>(this.core, { ...this.doc, desc: text });
}
role(role: FieldRole): Field<T> {
return new FieldImpl<T>(this.core, { ...this.doc, role });
}
toJsonSchema(): JsonSchema {
const out: JsonSchema = { ...(this.doc.schema ?? {}) };
if (this.doc.desc) {
out.description = this.doc.desc;
}
if (this.doc.default !== undefined) {
out.default = this.doc.default;
}
if (this.doc.role) {
out['x-role'] = this.doc.role;
}
return out;
}
}
// ─────────────────────────── coercer vocabulary `f` ───────────────────────────
interface IntOpts {
min?: number;
max?: number;
nonZero?: boolean;
}
/** Accept a number or numeric string, truncate to integer (matches the legacy
* `asNumber`), then apply bounds. Non-numeric / blank ⇒ Err. */
function intCore(typeName: string, opts: IntOpts): Core<number> {
return (raw, field) => {
let n: number;
if (typeof raw === 'number' && Number.isFinite(raw)) {
n = Math.trunc(raw);
} else if (typeof raw === 'string' && raw.trim() !== '' && Number.isFinite(Number(raw))) {
n = Math.trunc(Number(raw));
} else {
return err(field, `expected ${typeName}`);
}
if (opts.nonZero && n === 0) {
return err(field, 'must not be 0');
}
if (opts.min !== undefined && n < opts.min) {
return err(field, `must be >= ${opts.min}`);
}
if (opts.max !== undefined && n > opts.max) {
return err(field, `must be <= ${opts.max}`);
}
return ok(n);
};
}
function intSchema(opts: IntOpts): JsonSchema {
const s: JsonSchema = { type: 'integer' };
if (opts.min !== undefined) {
s.minimum = opts.min;
}
if (opts.max !== undefined) {
s.maximum = opts.max;
}
if (opts.nonZero) {
s.not = { const: 0 };
}
return s;
}
interface StrOpts {
allowEmpty?: boolean;
maxLen?: number;
}
function strSchema(opts: StrOpts): JsonSchema {
const s: JsonSchema = { type: 'string' };
if (opts.allowEmpty === false) {
s.minLength = 1;
}
if (opts.maxLen !== undefined) {
s.maxLength = opts.maxLen;
}
return s;
}
/** Coerce to string (numbers/booleans stringify; matches legacy `asString`),
* then apply emptiness / length bounds. Shared by `f.string()` and the
* string-valued semantic constructors. */
function stringCore(opts: StrOpts): Core<string> {
return (raw, field) => {
let s: string;
if (typeof raw === 'string') {
s = raw;
} else if (typeof raw === 'number' || typeof raw === 'boolean') {
s = String(raw);
} else {
return err(field, 'expected a string');
}
if (opts.allowEmpty === false && s === '') {
return err(field, 'must not be empty');
}
if (opts.maxLen !== undefined && s.length > opts.maxLen) {
return err(field, `must be <= ${opts.maxLen} chars`);
}
return ok(s);
};
}
function arrayField<E>(
el: Field<E>,
mustBeNonEmpty: boolean,
): Field<E[]> & { nonEmpty(): Field<E[]> } {
const core: Core<E[]> = (raw, field) => {
if (!Array.isArray(raw)) {
return err(field, 'expected an array');
}
const out: E[] = [];
for (let i = 0; i < raw.length; i++) {
const r = el.coerce(raw[i] as JsonValue, `${field}[${i}]`);
if (!r.ok) {
return r;
}
out.push(r.value);
}
if (mustBeNonEmpty && out.length === 0) {
return err(field, 'must not be empty');
}
return ok(out);
};
const base = new FieldImpl<E[]>(core, {
type: `${el.doc.type}[]`,
required: true,
schema: {
type: 'array',
items: el.toJsonSchema(),
...(mustBeNonEmpty ? { minItems: 1 } : {}),
},
});
return Object.assign(base, { nonEmpty: () => arrayField(el, true) });
}
export const f = {
/** Positive integer (>0): group_id / user_id. */
uint(): Field<number> {
return new FieldImpl<number>(intCore('a positive integer', { min: 1 }), {
type: 'uint',
required: true,
schema: intSchema({ min: 1 }),
});
},
/** Integer with optional bounds; allows 0 / negatives unless bounded.
* e.g. a duration where 0 is meaningful: `f.int({ min: 0 })`. */
int(opts: IntOpts = {}): Field<number> {
return new FieldImpl<number>(intCore('an integer', opts), {
type: 'int',
required: true,
schema: intSchema(opts),
});
},
/** OneBot message id: a non-zero integer. NEGATIVES ARE VALID (ids are a
* signed int32 hash) — do NOT use `uint()` for message_id. */
messageId(): Field<number> {
return new FieldImpl<number>(intCore('a message id', { nonZero: true }), {
type: 'messageId',
required: true,
role: 'message_id',
schema: intSchema({ nonZero: true }),
});
},
// ── Semantic constructors (role-stamped) ──
// Each wraps a coercion primitive and stamps a `role` so the WebUI debug
// console renders the right picker. The Agent backfill replaces a bare
// `f.uint()` named `group_id` with `f.groupId()`, etc. — purely additive to
// coercion (a groupId still coerces exactly like a uint).
/** Group number (uint) → group picker. */
groupId(): Field<number> {
return new FieldImpl<number>(intCore('a positive integer', { min: 1 }), {
type: 'uint',
required: true,
role: 'group_id',
schema: intSchema({ min: 1 }),
});
},
/** Friend / stranger uin (uint) → friend picker. */
userId(): Field<number> {
return new FieldImpl<number>(intCore('a positive integer', { min: 1 }), {
type: 'uint',
required: true,
role: 'user_id',
schema: intSchema({ min: 1 }),
});
},
/** Group member's uin (uint) → member picker, linked to a sibling group_id. */
memberId(): Field<number> {
return new FieldImpl<number>(intCore('a positive integer', { min: 1 }), {
type: 'uint',
required: true,
role: 'member_id',
schema: intSchema({ min: 1 }),
});
},
/** Opaque file id (string) → file picker / history. */
fileId(): Field<string> {
return new FieldImpl<string>(stringCore({ allowEmpty: false }), {
type: 'string',
required: true,
role: 'file_id',
schema: strSchema({ allowEmpty: false }),
});
},
// Media sources reject '' (an empty path/URL is meaningless) — this matches
// the `f.string({ allowEmpty: false })` they replace during backfill, so the
// swap preserves the empty-string guard.
/** Local path / URL / base64 of a generic file → FileSource control. */
file(): Field<string> {
return new FieldImpl<string>(stringCore({ allowEmpty: false }), {
type: 'string',
required: true,
role: 'file',
schema: strSchema({ allowEmpty: false }),
});
},
/** Image source (path / URL / base64) → FileSource control. */
image(): Field<string> {
return new FieldImpl<string>(stringCore({ allowEmpty: false }), {
type: 'string',
required: true,
role: 'image',
schema: strSchema({ allowEmpty: false }),
});
},
/** Voice/record source → FileSource control. */
record(): Field<string> {
return new FieldImpl<string>(stringCore({ allowEmpty: false }), {
type: 'string',
required: true,
role: 'record',
schema: strSchema({ allowEmpty: false }),
});
},
/** Video source → FileSource control. */
video(): Field<string> {
return new FieldImpl<string>(stringCore({ allowEmpty: false }), {
type: 'string',
required: true,
role: 'video',
schema: strSchema({ allowEmpty: false }),
});
},
/** Seconds (int ≥ 0) → duration control. */
duration(): Field<number> {
return new FieldImpl<number>(intCore('an integer', { min: 0 }), {
type: 'int',
required: true,
role: 'duration',
schema: intSchema({ min: 0 }),
});
},
/** Unix timestamp (int ≥ 0) → datetime control. */
timestamp(): Field<number> {
return new FieldImpl<number>(intCore('an integer', { min: 0 }), {
type: 'int',
required: true,
role: 'timestamp',
schema: intSchema({ min: 0 }),
});
},
/** QQ face id (non-negative int; face 0 is valid) → face grid. */
faceId(): Field<number> {
return new FieldImpl<number>(intCore('a non-negative integer', { min: 0 }), {
type: 'int',
required: true,
role: 'face_id',
schema: intSchema({ min: 0 }),
});
},
/** Finite number (fractions allowed). */
number(): Field<number> {
return new FieldImpl<number>(
(raw, field) => {
if (typeof raw === 'number' && Number.isFinite(raw)) {
return ok(raw);
}
if (typeof raw === 'string' && raw.trim() !== '' && Number.isFinite(Number(raw))) {
return ok(Number(raw));
}
return err(field, 'expected a number');
},
{ type: 'number', required: true, schema: { type: 'number' } },
);
},
/** String. Numbers/booleans stringify (matches legacy `asString`). Empty
* allowed by default; `{ allowEmpty: false }` rejects ''. */
string(opts: StrOpts = {}): Field<string> {
return new FieldImpl<string>(stringCore(opts), {
type: 'string',
required: true,
schema: strSchema(opts),
});
},
/** true/1/yes/on ⇒ true; false/0/no/off ⇒ false (matches legacy `asBoolean`). */
bool(): Field<boolean> {
return new FieldImpl<boolean>(
(raw, field) => {
if (typeof raw === 'boolean') {
return ok(raw);
}
if (typeof raw === 'number') {
return ok(raw !== 0);
}
if (typeof raw === 'string') {
const t = raw.trim().toLowerCase();
if (t === 'true' || t === '1' || t === 'yes' || t === 'on') {
return ok(true);
}
if (t === 'false' || t === '0' || t === 'no' || t === 'off') {
return ok(false);
}
}
return err(field, 'expected a boolean');
},
{ type: 'bool', required: true, schema: { type: 'boolean' } },
);
},
/** JSON object, preserving unknown fields without coercing scalars or arrays. */
object(): Field<JsonObject> {
return new FieldImpl<JsonObject>(
(raw, field) =>
typeof raw === 'object' && raw !== null && !Array.isArray(raw)
? ok(raw)
: err(field, 'expected an object'),
{
type: 'object',
required: true,
schema: { type: 'object', additionalProperties: true },
},
);
},
/** Literal text or upstream rich items; strings must never be decoded as messages. */
textOrObjectArray(): Field<string | JsonObject[]> {
return new FieldImpl<string | JsonObject[]>(
(raw, field) => {
if (typeof raw === 'string' && raw.length > 0) {
return ok(raw);
}
if (
Array.isArray(raw) &&
raw.length > 0 &&
raw.every(
(item): item is JsonObject =>
typeof item === 'object' && item !== null && !Array.isArray(item),
)
) {
return ok(raw);
}
return err(field, 'expected non-empty text or a non-empty array of objects');
},
{
type: 'string | object[]',
required: true,
schema: {
anyOf: [
{ type: 'string', minLength: 1 },
{
type: 'array',
minItems: 1,
items: { type: 'object', additionalProperties: true },
},
],
},
},
);
},
/** OneBot message union: string | segment[] | object. A string that looks
* like a JSON array is parsed (matches legacy `asMessage`); otherwise the
* value passes through. Output stays `JsonValue` — downstream ctx.* accept it. */
message(): Field<JsonValue> {
return new FieldImpl<JsonValue>(
(raw, _field) => {
if (typeof raw === 'string') {
const t = raw.trim();
if (t.startsWith('[') && t.endsWith(']')) {
try {
const parsed: unknown = JSON.parse(t);
if (Array.isArray(parsed)) {
return ok(parsed as JsonValue);
}
} catch {
// fall through to literal text
}
}
}
return ok(raw);
},
{
type: 'message',
required: true,
schema: {
description:
'OneBot message: string | segment[] | object. In segment arrays, text segments whose text field is exactly "" are compatibility placeholders and do not count as sendable segments; missing, null, and whitespace-only text remain subject to normal validation.',
},
},
);
},
/** Homogeneous array; `.nonEmpty()` rejects []. */
array<E>(el: Field<E>): Field<E[]> & { nonEmpty(): Field<E[]> } {
return arrayField(el, false);
},
/** Constrained literal set. */
enum<const V extends readonly (string | number)[]>(...values: V): Field<V[number]> {
return new FieldImpl<V[number]>(
(raw, field) => {
if (
(typeof raw === 'string' || typeof raw === 'number') &&
(values as readonly (string | number)[]).includes(raw)
) {
return ok(raw as V[number]);
}
return err(field, `expected one of: ${values.join(', ')}`);
},
{ type: 'enum', required: true, values, schema: { enum: [...values] } },
);
},
/** Escape hatch — pass the raw value (or undefined) through, validate nothing. */
raw(): Field<JsonValue | undefined> {
return new FieldImpl<JsonValue | undefined>((raw) => ok(raw), {
type: 'raw',
required: false,
default: undefined,
schema: {},
});
},
};
// ─────────────────────────── type inference ───────────────────────────
type Spec = Record<string, Field<unknown>>;
type FieldType<F> = F extends Field<infer T> ? T : never;
/** Post-validation every key is present; optional/raw fields carry `undefined`
* in their value type. */
export type InferParams<S extends Spec> = { [K in keyof S]: FieldType<S[K]> };
// ─────────────────────────── cross-field rules ───────────────────────────
export interface CrossFieldRule {
readonly doc: string;
check(params: Record<string, unknown>): Err | null;
}
type Key<P> = Extract<keyof P, string>;
type Group<P> = Key<P> | Key<P>[];
export interface RuleBuilders<P> {
/** Exactly one group satisfied. A group is a key, or an array of keys that
* must ALL be present. e.g. `exactlyOneOf('message_id', ['group_id','user_id'])`. */
exactlyOneOf(...groups: Group<P>[]): CrossFieldRule;
atLeastOneOf(...keys: Key<P>[]): CrossFieldRule;
requiredTogether(...keys: Key<P>[]): CrossFieldRule;
mutuallyExclusive(...keys: Key<P>[]): CrossFieldRule;
/** Arbitrary predicate; fails with `doc` as the reason. */
rule(doc: string, ok: (p: P) => boolean): CrossFieldRule;
}
const present = (p: Record<string, unknown>, key: string): boolean => p[key] !== undefined;
const groupOk = (p: Record<string, unknown>, g: string | string[]): boolean =>
Array.isArray(g) ? g.every((k) => present(p, k)) : present(p, g);
const groupLabel = (g: string | string[]): string => (Array.isArray(g) ? `(${g.join('+')})` : g);
const RULES: RuleBuilders<Record<string, unknown>> = {
exactlyOneOf(...groups) {
const doc = `exactly one of: ${groups.map(groupLabel).join(' | ')}`;
return {
doc,
check: (p) => (groups.filter((g) => groupOk(p, g)).length === 1 ? null : err('', doc)),
};
},
atLeastOneOf(...keys) {
const doc = `at least one of: ${keys.join(', ')}`;
return { doc, check: (p) => (keys.some((k) => present(p, k)) ? null : err('', doc)) };
},
requiredTogether(...keys) {
const doc = `all or none of: ${keys.join(', ')}`;
return {
doc,
check: (p) => {
const n = keys.filter((k) => present(p, k)).length;
return n === 0 || n === keys.length ? null : err('', doc);
},
};
},
mutuallyExclusive(...keys) {
const doc = `at most one of: ${keys.join(', ')}`;
return {
doc,
check: (p) => (keys.filter((k) => present(p, k)).length <= 1 ? null : err('', doc)),
};
},
rule(doc, okFn) {
return { doc, check: (p) => (okFn(p as never) ? null : err('', doc)) };
},
};
// ─────────────────────────── defineAction ───────────────────────────
export interface ParamDoc extends FieldDoc {
name: string;
}
/** Documentation only: never execute examples while collecting the catalog. */
export interface ActionExample {
summary: string;
/** OneBot params, also passed as the MCP action tool's `params`. */
params: JsonObject;
/** Prerequisites, placeholder sources, or ordering within a workflow. */
notes?: string;
}
export interface ActionDoc {
name: string;
aliases: string[];
/** Domain category (set by the doc collector, e.g. 群管理/消息). */
category?: string;
summary?: string;
returns?: string;
/** JSON Schema for successful OneBot `data`, not the envelope. For streams,
* describes each successful frame, not an MCP aggregated tool result.
* Optional for third-party specs; the bundled catalog requires coverage. */
returnsSchema?: JsonSchema;
examples?: readonly ActionExample[];
/** True only for pure data-fetch actions (no side effects). Drives the MCP's
* read/write tool routing; defaults to false (treated as a write). */
readOnly: boolean;
/** True for Stream API actions (`defineStreamAction`) — they push multiple
* frames through a sink, so a caller must use the streaming transport rather
* than a single request/response. Absent ⇒ ordinary single-response action. */
stream?: boolean;
params: ParamDoc[];
invariants: string[];
/** Composed JSON Schema for the whole params object (properties + required). */
inputSchema: JsonSchema;
}
type Handler = (params: JsonObject, sink?: StreamSink) => Promise<ApiResponse>;
/**
* The narrow shape both `ActionSpec` and `StreamActionSpec` share — enough to
* bind a spec through ActionRegistry and to collect its docs. The action
* registry barrel (`actions/index.ts`) is typed against this so it can hold
* both kinds in one flat list without coupling to either concrete generic.
*/
export interface RegisteredActionSpec {
/** Runtime dispatch kind. Kept on the spec so the compiled registry can
* validate the executable namespace without inferring behavior from docs. */
readonly kind: 'normal' | 'stream';
/** Canonical name first, followed by every executable alias. */
readonly names: readonly string[];
describe(): ActionDoc;
toHandler(ctx: ApiActionContext): Handler;
}
export interface ActionSpec<S extends Spec> extends RegisteredActionSpec {
readonly kind: 'normal';
readonly names: readonly string[];
readonly params: S;
/** Pure: coerce + validate + cross-field, no ctx, no I/O. The test surface. */
parse(raw: JsonObject): CoerceResult<InferParams<S>>;
/** Bind ctx and produce the handler ActionRegistry.bind installs. */
toHandler(ctx: ApiActionContext): Handler;
}
interface ActionDef<S extends Spec> {
name: string | readonly [string, ...string[]];
summary?: string;
returns?: string;
/** JSON Schema for the `data` payload (NOT the envelope). See ActionDoc. */
returnsSchema?: JsonSchema;
examples?: readonly ActionExample[];
/** Mark true ONLY for pure data-fetch actions with no side effects. Default
* false = write. Surfaced via describe() into the catalog for the MCP's
* read/write routing. Classify by what `run` actually does, not the name. */
readOnly?: boolean;
params: S;
rules?: (r: RuleBuilders<InferParams<S>>) => readonly CrossFieldRule[];
run: (p: InferParams<S>, ctx: ApiActionContext) => Promise<ApiResponse> | ApiResponse;
}
const wording = (e: Err): string => (e.field ? `${e.field}: ${e.reason}` : e.reason);
export function defineAction<S extends Spec>(def: ActionDef<S>): ActionSpec<S> {
const names = Object.freeze(typeof def.name === 'string' ? [def.name] : [...def.name]);
const rules: readonly CrossFieldRule[] = def.rules
? def.rules(RULES as unknown as RuleBuilders<InferParams<S>>)
: [];
const parse = (raw: JsonObject): CoerceResult<InferParams<S>> => {
const out: Record<string, unknown> = {};
for (const key of Object.keys(def.params)) {
const field = def.params[key];
const value = Object.prototype.hasOwnProperty.call(raw, key)
? (raw[key] as JsonValue)
: MISSING;
const r = field.coerce(value, key);
if (!r.ok) {
return r;
}
out[key] = r.value;
}
for (const rule of rules) {
const e = rule.check(out);
if (e) {
return e;
}
}
return ok(out as InferParams<S>);
};
const toHandler =
(ctx: ApiActionContext): Handler =>
async (params: JsonObject) => {
const r = parse(params);
if (!r.ok) {
return failedResponse(RETCODE.BAD_REQUEST, wording(r));
}
return def.run(r.value, ctx);
};
return {
kind: 'normal',
names,
params: def.params,
parse,
toHandler,
describe: () => {
const entries = Object.entries(def.params);
const properties: Record<string, JsonSchema> = {};
const required: string[] = [];
for (const [name, field] of entries) {
properties[name] = field.toJsonSchema();
if (field.doc.required) {
required.push(name);
}
}
const inputSchema: JsonSchema = {
type: 'object',
properties,
...(required.length ? { required } : {}),
additionalProperties: true,
};
return {
name: names[0],
aliases: names.slice(1),
summary: def.summary,
returns: def.returns,
returnsSchema: def.returnsSchema,
...(def.examples ? { examples: def.examples } : {}),
readOnly: def.readOnly ?? false,
params: entries.map(([name, field]) => ({ name, ...field.doc })),
invariants: rules.map((rule) => rule.doc),
inputSchema,
};
},
};
}
// ─────────────────────────── defineStreamAction ───────────────────────────
// A Stream API action (#163): same param contract + doc machinery as
// `defineAction`, but `run` additionally receives a `StreamSink` to push
// intermediate frames. `toHandler` is sink-aware so ActionRegistry.bind
// installs the same shape as a normal Action. The handler's return value is
// still the terminal `ApiResponse` (the final frame). Reuses `defineAction`
// for parse/describe so the two stay in lock-step.
interface StreamActionDef<S extends Spec> {
name: string | readonly [string, ...string[]];
summary?: string;
returns?: string;
returnsSchema?: JsonSchema;
examples?: readonly ActionExample[];
readOnly?: boolean;
params: S;
rules?: (r: RuleBuilders<InferParams<S>>) => readonly CrossFieldRule[];
run: (
p: InferParams<S>,
ctx: ApiActionContext,
sink: StreamSink,
) => Promise<ApiResponse> | ApiResponse;
}
export interface StreamActionSpec<S extends Spec> extends RegisteredActionSpec {
readonly kind: 'stream';
readonly names: readonly string[];
readonly params: S;
parse(raw: JsonObject): CoerceResult<InferParams<S>>;
toHandler(ctx: ApiActionContext): Handler;
}
export function defineStreamAction<S extends Spec>(def: StreamActionDef<S>): StreamActionSpec<S> {
// Borrow defineAction's parse + describe (run is never invoked — register
// below installs the sink-aware handler instead).
const base = defineAction<S>({
name: def.name,
summary: def.summary,
returns: def.returns,
returnsSchema: def.returnsSchema,
examples: def.examples,
readOnly: def.readOnly,
params: def.params,
rules: def.rules,
run: () =>
failedResponse(RETCODE.INTERNAL_ERROR, 'stream action dispatched without a sink'),
});
const toHandler =
(ctx: ApiActionContext): Handler =>
async (params, sink) => {
const r = base.parse(params);
if (!r.ok) {
return failedResponse(RETCODE.BAD_REQUEST, wording(r));
}
return def.run(r.value, ctx, sink ?? NOOP_SINK);
};
return {
kind: 'stream',
names: base.names,
params: def.params,
parse: base.parse,
toHandler,
describe: () => ({ ...base.describe(), stream: true }),
};
}
// ─────────────────────────── presets ───────────────────────────
// `group_id` appears in 61/142 actions, `user_id` in 27 — these collapse the
// dominant shape. The injected fields are surfaced in `describe()` so docs
// list them even though the caller never typed them (ADR-0006 trade-off).
type WithGroup<S extends Spec> = S & { group_id: Field<number> };
type WithGroupUser<S extends Spec> = S & { group_id: Field<number>; user_id: Field<number> };
interface PresetDef<S extends Spec, Extra> {
name: string | readonly [string, ...string[]];
summary?: string;
returns?: string;
returnsSchema?: JsonSchema;
examples?: readonly ActionExample[];
readOnly?: boolean;
params?: S;
rules?: (r: RuleBuilders<InferParams<S> & Extra>) => readonly CrossFieldRule[];
run: (p: InferParams<S> & Extra, ctx: ApiActionContext) => Promise<ApiResponse> | ApiResponse;
}
/** Pre-seeds `group_id` (uint, required). */
export function groupAction<S extends Spec>(
def: PresetDef<S, { group_id: number }>,
): ActionSpec<WithGroup<S>> {
const params = {
group_id: f.groupId().describe('群号'),
...(def.params ?? {}),
} as WithGroup<S>;
return defineAction({ ...def, params } as unknown as ActionDef<WithGroup<S>>);
}
/** Pre-seeds `group_id` + `user_id` (both uint, required). */
export function groupUserAction<S extends Spec>(
def: PresetDef<S, { group_id: number; user_id: number }>,
): ActionSpec<WithGroupUser<S>> {
const params = {
group_id: f.groupId().describe('群号'),
// In a group action, the user_id is a member of *that* group — so its
// picker links to the sibling group_id (member_id role, not user_id).
user_id: f.memberId().describe('QQ 号'),
...(def.params ?? {}),
} as WithGroupUser<S>;
return defineAction({ ...def, params } as unknown as ActionDef<WithGroupUser<S>>);
}
@@ -0,0 +1,134 @@
import type { JsonSchema } from './action-kit';
// Documentation fragments only. They never validate or transform runtime data.
export const messageIdSchema: JsonSchema = {
type: 'integer',
minimum: -2147483648,
maximum: 2147483647,
not: { const: 0 },
description: '非零有符号 32 位消息 ID,负值合法;必须原样保留',
};
export const messageResultSchema: JsonSchema = {
type: 'object',
properties: { message_id: messageIdSchema },
required: ['message_id'],
};
export const messageSegmentSchema: JsonSchema = {
type: 'object',
properties: {
type: { type: 'string', description: '消息段类型,例如 text、image、reply' },
data: {
type: 'object',
description: '字段随消息段类型变化,例如 text 段包含 text,reply 段包含 id',
additionalProperties: true,
},
},
required: ['type', 'data'],
};
/** Stored events retain subtype-specific fields; legacy records can omit fields. */
export const messageDataSchema = {
type: 'object',
properties: {
time: { type: 'integer', description: '消息时间,Unix 秒' },
message_type: { type: 'string', enum: ['private', 'group'] },
sub_type: { type: 'string', description: '消息子类型' },
message_id: messageIdSchema,
real_id: { type: 'integer', description: '原始 message_id;旧历史记录缺失时可为 0' },
user_id: { type: 'integer', description: '发送者 QQ 号' },
group_id: { type: 'integer', description: '群聊消息的群号,私聊可省略' },
target_id: { type: 'integer', description: '私聊目标 QQ 号(若有)' },
message: { type: 'array', items: messageSegmentSchema },
raw_message: { type: 'string', description: '消息的 CQ 码字符串形式' },
font: { type: 'integer' },
sender: {
type: 'object',
properties: {
user_id: { type: 'integer' },
nickname: { type: 'string' },
card: { type: 'string' },
sex: { type: 'string', enum: ['male', 'female', 'unknown'] },
age: { type: 'integer' },
area: { type: 'string' },
level: { type: 'string' },
role: { type: 'string', enum: ['owner', 'admin', 'member'] },
title: { type: 'string' },
},
additionalProperties: true,
},
},
additionalProperties: true,
} satisfies JsonSchema;
export const messageWithAutoReplySchema = {
...messageDataSchema,
properties: {
...messageDataSchema.properties,
auto_reply: { const: true, description: '仅当 QQ 标记该私聊消息为自动回复时提供' },
},
} satisfies JsonSchema;
// Forward nodes carry their original identifiers, not locally hashed message IDs.
export const forwardNodeSchema: JsonSchema = {
...messageDataSchema,
properties: {
...messageDataSchema.properties,
self_id: { type: 'integer' },
post_type: { const: 'message' },
message_format: { const: 'array' },
message_id: { type: 'integer', description: '转发节点原始标识,缺失时为 0' },
message_seq: { type: 'integer', description: '节点消息序号,缺失时使用节点标识或 0' },
real_id: { type: 'integer', description: '与节点 message_id 相同' },
},
required: [
'self_id',
'user_id',
'time',
'message_id',
'message_seq',
'real_id',
'message_type',
'sender',
'raw_message',
'font',
'sub_type',
'message',
'message_format',
'post_type',
],
};
export const mediaInfoSchema: JsonSchema = {
type: 'object',
properties: {
file: { type: 'string', description: '媒体地址;图片无地址时可为原文件标识' },
url: { type: 'string', description: '媒体下载地址,图片地址可能为空' },
file_size: { type: 'string', description: '原媒体字节数的十进制字符串' },
file_name: { type: 'string', description: '文件名或缓存标识' },
},
required: ['file', 'url', 'file_size', 'file_name'],
};
export const flashFileSchema: JsonSchema = {
type: 'object',
properties: {
fileset_id: { type: 'string' },
file_name: { type: 'string' },
orig_name: { type: 'string' },
size: { type: 'number', description: '文件字节数;此接口字段名为 size' },
share_url: { type: 'string' },
file_id: { type: 'string' },
download_url: { type: 'string' },
},
required: [
'fileset_id',
'file_name',
'orig_name',
'size',
'share_url',
'file_id',
'download_url',
],
};
@@ -0,0 +1,54 @@
const privilegeSchema = {
type: 'object',
properties: {
type: { type: 'integer', description: '业务类型' },
level: { type: 'integer', description: '业务等级' },
flag: { type: 'integer', description: '服务端业务标志' },
is_big: { type: 'integer', description: '服务端大图标标志' },
jump_url: { type: 'string', description: '业务跳转地址' },
icon_url: { type: 'string', description: '普通图标地址' },
deluxe_icon_url: { type: 'string', description: '高级图标地址,可与普通图标同时存在' },
},
};
export const publicProfileProperties = {
long_nick: { type: 'string', description: '个性签名' },
sex: { type: 'string', enum: ['male', 'female', 'unknown'], description: '性别' },
age: { type: 'integer', description: '年龄' },
level: { type: 'integer', description: 'QQ 等级' },
birthday_year: { type: 'integer', description: '生日年份,未公开时省略' },
birthday_month: { type: 'integer', description: '生日月份(1-12)' },
birthday_day: { type: 'integer', description: '生日日期(1-31)' },
phone_num: { type: 'string', description: '资料卡公开的电话号码' },
email: { type: 'string', description: '资料卡公开的邮箱' },
country: { type: 'string', description: '国家' },
province: { type: 'string', description: '省份' },
city: { type: 'string', description: '城市' },
college: { type: 'string', description: '学校' },
is_qq_vip: { type: 'boolean', description: '普通会员状态' },
is_vip: { type: 'boolean', description: '超级会员状态,兼容已有接口' },
is_years_vip: { type: 'boolean', description: '年费会员状态' },
vip_level: { type: 'integer', description: '会员等级' },
privilege_icons: {
type: 'object',
description: '业务图标;未返回该资料时省略整个字段,空列表表示没有该类图标',
properties: {
jump_url: { type: 'string', description: '业务列表跳转地址' },
open: { type: 'array', description: '已开通业务', items: privilegeSchema },
closed: { type: 'array', description: '未开通业务', items: privilegeSchema },
},
required: ['open', 'closed'],
},
};
export const friendResultSchema = {
type: 'object',
properties: {
user_id: { type: 'integer', description: '好友 QQ 号' },
nickname: { type: 'string', description: '好友昵称' },
remark: { type: 'string', description: '好友备注' },
category_id: { type: 'integer', description: '好友分组编号,有来源时返回' },
...publicProfileProperties,
},
required: ['user_id', 'nickname', 'remark'],
};
File diff suppressed because it is too large. Load diff
+164
View File
@@ -0,0 +1,164 @@
import { defineAction, f } from '../action-kit';
import { failedResponse, okResponse, RETCODE } from '../types';
import { friendResultSchema, publicProfileProperties } from './contact-profile-schema';
export const actions = [
defineAction({
name: 'get_friend_list',
examples: [
{
summary: '获取好友列表',
params: {},
},
],
summary: '获取好友列表',
readOnly: true,
returns:
'好友列表,包含账号、昵称、备注及回包已有的公开资料;with_profile=true 时主动补查,失败会报错。未返回的资料省略,零、空字符串和 false 保留。',
returnsSchema: {
type: 'array',
items: friendResultSchema,
},
params: {
with_profile: f
.bool()
.default(false)
.describe('主动补查好友公开资料,按账号缓存五分钟;大列表可能耗时较长'),
},
run: async (p, ctx) => {
if (ctx.getFriendList) {
return okResponse(await ctx.getFriendList(p.with_profile));
}
if (p.with_profile) {
return failedResponse(RETCODE.ACTION_FAILED, 'friend profile provider unavailable');
}
return okResponse([]);
},
}),
defineAction({
name: 'get_stranger_info',
examples: [
{
summary: '获取陌生人信息',
params: {
user_id: 10001,
},
notes: '替换为需要查询的 QQ 号。',
},
],
summary: '获取陌生人信息',
readOnly: true,
returns:
'用户资料,含账号标识、等级别名和可用的注册时间、生日、会员信息。注册时间、生日或会员信息未返回时省略对应字段,出生年份未公开时只省略年份;登录天数暂无可靠来源,不返回占位值。',
returnsSchema: {
type: 'object',
properties: {
...publicProfileProperties,
user_id: { type: 'integer', description: 'QQ 号' },
uid: { type: 'string', description: '账号唯一标识(仅查到资料时返回)' },
qid: { type: 'string', description: '自定义账号标识(仅查到资料时返回)' },
nickname: { type: 'string', description: '昵称' },
remark: { type: 'string', description: '好友备注;非好友或未设置时为空字符串' },
sex: { type: 'string', description: '性别(male/female/unknown)' },
age: { type: 'integer', description: '年龄' },
long_nick: { type: 'string', description: '个性签名' },
qq_level: { type: 'integer', description: 'QQ 等级(仅查到资料时返回)' },
qqLevel: { type: 'integer', description: 'QQ 等级,同 qq_level' },
reg_time: { type: 'integer', description: '注册时间戳(秒),不可用时省略' },
birthday_year: {
type: 'integer',
description: '生日年份,兼容 NapCat;未公开或不可用时省略',
},
birthday_month: {
type: 'integer',
description: '生日月份(1-12),兼容 NapCat;不可用时省略',
},
birthday_day: {
type: 'integer',
description: '生日日期(1-31),兼容 NapCat;不可用时省略',
},
is_vip: { type: 'boolean', description: '超级会员状态,兼容 NapCat;不可用时省略' },
is_years_vip: { type: 'boolean', description: '年费会员状态,不可用时省略' },
vip_level: { type: 'integer', description: '会员等级,不可用时省略' },
level: { type: 'integer', description: 'QQ 等级,同 qq_level(仅查到资料时返回)' },
status: { type: 'integer', description: '在线状态码' },
extStatus: { type: 'integer', description: '扩展状态码' },
ext_status: { type: 'integer', description: '扩展状态码(同 extStatus)' },
batteryStatus: { type: 'integer', description: '电量状态' },
customStatus: { type: 'object', description: '自定义状态', nullable: true },
customStatusDescInfo: { type: 'string', description: '自定义状态说明' },
qidian_master_flag: {
type: 'integer',
description: '企点主号标志,0 或 1;普通账号为 0',
},
qidian_crew_flag: {
type: 'integer',
description: '企点员工标志,0 或 1;普通账号为 0',
},
qidian_crew_flag_2: {
type: 'integer',
description: '企点保留标志,0 或 1;普通账号为 0',
},
qidian_enterprise_name: {
type: 'string',
description: '企点企业名称;非企点账号或未获取到时为空字符串',
},
},
required: ['user_id', 'nickname', 'remark', 'sex', 'age', 'long_nick'],
},
params: { user_id: f.userId().describe('QQ 号') },
run: async (p, ctx) => {
const userId = p.user_id;
if (ctx.getStrangerInfo) {
const info = await ctx.getStrangerInfo(userId);
return okResponse(
info ?? {
user_id: userId,
nickname: '',
remark: '',
sex: 'unknown',
age: 0,
long_nick: '',
},
);
}
return okResponse({
user_id: userId,
nickname: '',
remark: '',
sex: 'unknown',
age: 0,
long_nick: '',
});
},
}),
defineAction({
name: 'delete_friend',
examples: [
{
summary: '删除好友',
params: {
user_id: 10001,
block: false,
},
notes: '会删除指定好友;仅在明确需要删除时调用。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '删除好友',
params: { user_id: f.userId().describe('QQ 号'), block: f.bool().default(false) },
run: async (p, ctx) => {
await ctx.bridge.apis.friend.delete(p.user_id, p.block);
return okResponse();
},
}),
];
+747
View File
@@ -0,0 +1,747 @@
import { defineAction, groupAction, groupUserAction, f } from '../action-kit';
import { okResponse } from '../types';
export const actions = [
groupUserAction({
name: 'set_group_kick',
examples: [
{
summary: '踢出群成员',
params: {
group_id: 20001,
user_id: 10001,
reject_add_request: false,
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '踢出群成员',
params: { reject_add_request: f.bool().default(false) },
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.kickMember(
p.group_id,
p.user_id,
p.reject_add_request,
);
return okResponse();
},
}),
groupAction({
name: 'set_group_kick_members',
examples: [
{
summary: '批量踢出群成员',
params: {
group_id: 20001,
user_id: [10001, 10002],
reject_add_request: false,
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '批量踢出群成员',
params: {
user_id: f.array(f.memberId()).nonEmpty(),
reject_add_request: f.bool().default(false),
},
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.kickMembers(
p.group_id,
p.user_id,
p.reject_add_request,
);
return okResponse();
},
}),
groupUserAction({
name: 'set_group_ban',
examples: [
{
summary: '禁言群成员(duration=0 解除)',
params: {
group_id: 20001,
user_id: 10001,
duration: 600,
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。 duration 为秒,0 表示解除禁言。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '禁言群成员(duration=0 解除)',
params: { duration: f.duration().default(1800) },
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.muteMember(p.group_id, p.user_id, p.duration);
return okResponse();
},
}),
groupAction({
name: 'set_group_whole_ban',
examples: [
{
summary: '全员禁言开关',
params: {
group_id: 20001,
enable: true,
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '全员禁言开关',
params: { enable: f.bool().default(true) },
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.muteAll(p.group_id, p.enable);
return okResponse();
},
}),
groupAction({
name: 'set_group_add_option',
examples: [
{
summary: '设置加群选项',
params: {
group_id: 20001,
add_type: 2,
group_question: '入群用途?',
group_answer: '学习交流',
},
notes: '修改加群设置前先用 get_group_admin_settings 查看当前状态,并确认目标设置值。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置加群选项',
params: {
add_type: f.int({ min: 0 }).default(0),
group_question: f.string().optional(),
group_answer: f.string().optional(),
},
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.setAddOption(
p.group_id,
p.add_type,
p.group_question,
p.group_answer,
);
return okResponse();
},
}),
groupAction({
name: 'set_group_search',
examples: [
{
summary: '设置群被搜索方式(群指纹 / 群号搜索开关)',
params: {
group_id: 20001,
no_finger_open: 0,
no_code_finger_open: 0,
},
notes: '替换群号;数值含义以当前群设置为准。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置群被搜索方式(群指纹 / 群号搜索开关)',
params: {
no_finger_open: f.int({ min: 0 }).optional(),
no_code_finger_open: f.int({ min: 0 }).optional(),
},
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.setSearch(
p.group_id,
p.no_finger_open,
p.no_code_finger_open,
);
return okResponse();
},
}),
groupAction({
name: 'set_group_member_invite_policy',
examples: [
{
summary: '设置群成员邀请策略',
params: {
group_id: 20001,
policy: 'require_approval',
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置群成员邀请策略',
params: {
policy: f.enum('disabled', 'require_approval', 'no_approval', 'no_approval_under_100'),
},
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.setMemberInvitePolicy(p.group_id, p.policy);
return okResponse();
},
}),
groupAction({
name: 'set_group_new_member_history_visibility',
examples: [
{
summary: '设置新成员是否可查看历史消息',
params: {
group_id: 20001,
visible: true,
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置新成员是否可查看历史消息',
params: { visible: f.bool() },
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.setNewMemberHistoryVisibility(p.group_id, p.visible);
return okResponse();
},
}),
groupAction({
name: 'set_group_member_permissions',
examples: [
{
summary: '设置群成员权限(仅群主可改)',
params: {
group_id: 20001,
allow_member_upload_album: false,
},
notes: '至少传一个权限字段;仅修改传入的权限,其余保持原样。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置群成员权限(仅群主可改)',
params: {
allow_member_upload_album: f.bool().optional(),
allow_member_temporary_session: f.bool().optional(),
allow_member_create_group: f.bool().optional(),
},
rules: (r) => [
r.atLeastOneOf(
'allow_member_upload_album',
'allow_member_temporary_session',
'allow_member_create_group',
),
],
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.setMemberPermissions(p.group_id, {
allowMemberUploadAlbum: p.allow_member_upload_album,
allowMemberTemporarySession: p.allow_member_temporary_session,
allowMemberCreateGroup: p.allow_member_create_group,
});
return okResponse();
},
}),
groupAction({
name: 'get_group_admin_settings',
examples: [
{
summary: '获取群管理设置的当前值',
params: {
group_id: 20001,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群管理设置的当前值',
readOnly: true,
returns: '与对应设置接口字段对齐的当前群管理设置。',
returnsSchema: {
type: 'object',
properties: {
add_type: {
type: 'integer',
description: '加群选项(同 set_group_add_option.add_type)',
},
group_question: {
type: 'string',
description: '加群问题(同 set_group_add_option.group_question)',
},
group_answer: {
type: 'string',
description: '加群答案(同 set_group_add_option.group_answer)',
},
robot_member_switch: {
type: 'integer',
description: '机器人加群开关(同 set_group_robot_add_option)',
},
robot_member_examine: {
type: 'integer',
description: '机器人加群审核(同 set_group_robot_add_option)',
},
member_invite_policy: {
type: 'string',
enum: ['disabled', 'require_approval', 'no_approval', 'no_approval_under_100'],
description: '成员邀请策略(同 set_group_member_invite_policy.policy)',
},
allow_member_upload_album: { type: 'boolean', description: '是否允许成员上传相册' },
allow_member_temporary_session: {
type: 'boolean',
description: '是否允许成员发起临时会话',
},
allow_member_create_group: { type: 'boolean', description: '是否允许成员创建群聊' },
new_member_history_visible: {
type: 'boolean',
description: '新成员是否可查看历史消息',
},
no_finger_open: {
type: 'integer',
description: '是否关闭群指纹/关键词搜索(0 开 1 关)',
},
no_code_finger_open: {
type: 'integer',
description: '是否关闭群号搜索(0 开 1 关)',
},
},
required: [
'add_type',
'group_question',
'group_answer',
'robot_member_switch',
'robot_member_examine',
'member_invite_policy',
'allow_member_upload_album',
'allow_member_temporary_session',
'allow_member_create_group',
'new_member_history_visible',
'no_finger_open',
'no_code_finger_open',
],
},
run: async (p, ctx) => {
return okResponse(await ctx.bridge.apis.groupAdmin.getAdminSettings(p.group_id));
},
}),
defineAction({
name: 'get_group_tags',
examples: [
{
summary: '读取某个群分类下的资料标签',
params: {
group_class: 1,
},
notes: 'group_class 使用实际群分类编号。',
},
],
summary: '读取某个群分类下的资料标签',
readOnly: true,
returns: '该分类下的资料标签列表。',
returnsSchema: {
type: 'object',
properties: {
tags: {
type: 'array',
description: '资料标签',
items: {
type: 'object',
properties: {
id: { type: 'integer', description: '标签编号' },
name: { type: 'string', description: '标签名' },
type: { type: 'integer', description: '标签类型' },
},
required: ['id', 'name', 'type'],
},
},
},
required: ['tags'],
},
params: { group_class: f.int({ min: 0, max: 0xffffffff }) },
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.groupAdmin.listCategoryTags(p.group_class)),
}),
defineAction({
name: 'add_group_tag',
examples: [
{
summary: '向某个群分类添加资料标签',
params: {
group_class: 1,
name: '学习',
},
notes: '替换实际群分类和标签;需要当前账号有管理权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '向某个群分类添加资料标签',
params: {
group_class: f.int({ min: 0, max: 0xffffffff }),
name: f.string({ allowEmpty: false }),
},
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.addCategoryTag(p.group_class, p.name);
return okResponse();
},
}),
defineAction({
name: 'delete_group_tag',
examples: [
{
summary: '从某个群分类移除资料标签',
params: {
group_class: 1,
name: '学习',
},
notes: '替换实际群分类和标签;需要当前账号有管理权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '从某个群分类移除资料标签',
params: {
group_class: f.int({ min: 0, max: 0xffffffff }),
name: f.string({ allowEmpty: false }),
},
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.removeCategoryTag(p.group_class, p.name);
return okResponse();
},
}),
groupUserAction({
name: 'set_group_admin',
examples: [
{
summary: '设置/取消管理员',
params: {
group_id: 20001,
user_id: 10001,
enable: true,
},
notes: '需要群主权限;会授予该成员管理员身份。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置/取消管理员',
params: { enable: f.bool().default(true) },
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.setAdmin(p.group_id, p.user_id, p.enable);
return okResponse();
},
}),
groupUserAction({
name: 'get_group_admin_permissions',
summary: '读取指定管理员的撤回、移除成员、每日移除上限及接收验证消息状态',
readOnly: true,
examples: [
{
summary: '读取管理员权限',
params: { group_id: 20001, user_id: 10001 },
notes: 'user_id 为目标管理员 QQ 号;包含接收加群验证消息状态。',
},
],
returns:
'服务器返回的管理员权限;未返回或未指定的开关为 null。每日上限仅在移除权限开启时返回,0 表示不限。',
returnsSchema: {
type: 'object',
properties: {
group_id: { type: 'integer' },
user_id: { type: 'integer' },
recall_member_message: {
type: ['boolean', 'null'],
description: '撤回成员消息;null 表示服务器未指定',
},
kick_member: {
type: ['boolean', 'null'],
description: '移除群成员;null 表示服务器未指定',
},
kick_member_per_day: {
type: ['integer', 'null'],
description: '每日可移除人数,0 不限;权限关闭或未指定时为 null',
},
receive_verify: {
type: ['boolean', 'null'],
description: '是否接收加群验证消息;null 表示服务器未返回状态',
},
},
required: [
'group_id',
'user_id',
'recall_member_message',
'kick_member',
'kick_member_per_day',
'receive_verify',
],
additionalProperties: false,
},
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.groupAdmin.getAdminPermissions(p.group_id, p.user_id)),
}),
groupUserAction({
name: 'set_group_admin_permissions',
summary: '设置指定管理员的撤回和移除成员权限',
examples: [
{
summary: '允许管理员每日移除至多 10 人',
params: {
group_id: 20001,
user_id: 10001,
kick_member: true,
kick_member_per_day: 10,
},
notes: '需要当前账号具有修改权限;不会授予管理员角色。仅修改显式字段。开启移除必须提供上限,0 表示不限;修改上限需同时传 kick_member=true。',
},
],
returnsSchema: {
type: 'null',
description:
'服务器确认请求后 data 为 null;不代表已经过客户端回读验证。可用 get_group_admin_permissions 查询。',
},
params: {
recall_member_message: f.bool().optional(),
kick_member: f.bool().optional(),
kick_member_per_day: f.int({ min: 0, max: 200 }).optional(),
},
rules: (r) => [
r.atLeastOneOf('recall_member_message', 'kick_member'),
r.rule(
'kick_member=true requires kick_member_per_day: 0, 5, 10, 20, 50, 100 or 200',
(p) =>
p.kick_member !== true ||
(p.kick_member_per_day !== undefined &&
[0, 5, 10, 20, 50, 100, 200].includes(p.kick_member_per_day)),
),
r.rule(
'kick_member_per_day requires kick_member=true',
(p) => p.kick_member_per_day === undefined || p.kick_member === true,
),
],
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.setAdminPermissions(p.group_id, p.user_id, {
recallMemberMessage: p.recall_member_message,
kickMember: p.kick_member,
kickMemberPerDay: p.kick_member_per_day,
});
return okResponse();
},
}),
groupUserAction({
name: 'set_group_admin_receive_verify',
summary: '设置指定管理员是否接收加群验证消息',
examples: [
{
summary: '开启接收验证消息',
params: { group_id: 20001, user_id: 10001, enable: true },
notes: 'user_id 为目标管理员 QQ 号;需要当前账号具有修改权限。独立于撤回和移除成员权限。',
},
],
returnsSchema: {
type: 'null',
description: '服务器响应确认目标群和管理员后 data 为 null;未确认目标时返回错误。',
},
params: { enable: f.bool() },
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.setAdminReceiveVerify(p.group_id, p.user_id, p.enable);
return okResponse();
},
}),
groupUserAction({
name: 'set_group_card',
examples: [
{
summary: '设置群名片(空字符串清除)',
params: {
group_id: 20001,
user_id: 10001,
card: '示例群名片',
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置群名片(空字符串清除)',
params: { card: f.string().default('') },
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.setCard(p.group_id, p.user_id, p.card);
return okResponse();
},
}),
groupAction({
name: 'set_group_name',
examples: [
{
summary: '设置群名',
params: {
group_id: 20001,
group_name: '学习交流群',
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置群名',
params: { group_name: f.string().default('') },
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.setName(p.group_id, p.group_name);
return okResponse();
},
}),
groupAction({
name: 'set_group_leave',
examples: [
{
summary: '退群',
params: {
group_id: 20001,
},
notes: '会退出该群,请确认目标群号后调用。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '退群',
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.leave(p.group_id);
return okResponse();
},
}),
groupUserAction({
name: 'set_group_special_title',
examples: [
{
summary: '设置群头衔',
params: {
group_id: 20001,
user_id: 10001,
special_title: '学习委员',
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置群头衔',
params: { special_title: f.string().default('') },
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.setSpecialTitle(
p.group_id,
p.user_id,
p.special_title,
);
return okResponse();
},
}),
// No-op stubs (accept and ignore any params, as before).
defineAction({
name: 'set_group_anonymous',
examples: [
{
summary: '匿名开关(未实现,返回 ok)',
params: {},
notes: '兼容占位接口:当前返回成功但不会修改匿名设置。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '匿名开关(未实现,返回 ok)',
params: {},
run: () => okResponse(),
}),
defineAction({
name: 'set_group_anonymous_ban',
examples: [
{
summary: '匿名禁言(未实现,返回 ok)',
params: {},
notes: '兼容占位接口:当前返回成功但不会修改匿名设置。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '匿名禁言(未实现,返回 ok)',
params: {},
run: () => okResponse(),
}),
groupAction({
name: 'set_group_portrait',
examples: [
{
summary: '设置群头像',
params: {
group_id: 20001,
file: 'file:///path/to/avatar.png',
},
notes: '示例路径位于 SnowLuma 服务端,须替换为该服务可读取的实际文件。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置群头像',
params: { file: f.image() },
run: async (p, ctx) => {
await ctx.bridge.apis.profile.setGroupAvatar(p.group_id, p.file);
return okResponse();
},
}),
];
+520
View File
@@ -0,0 +1,520 @@
import { groupAction, f } from '../action-kit';
import type { JsonValue } from '../types';
import { okResponse } from '../types';
const albumCoverUrlSchema = {
type: ['object', 'null'],
properties: {
url: { type: 'string', description: '封面地址' },
width: { type: 'integer', description: '封面宽度' },
height: { type: 'integer', description: '封面高度' },
},
required: ['url', 'width', 'height'],
};
const albumCoverSchema = {
type: ['object', 'null'],
description: '相册封面;没有封面时为 null',
properties: {
type: { type: 'integer', description: '封面媒体类型' },
image: {
type: ['object', 'null'],
description: '封面图片信息',
properties: {
name: { type: 'string', description: '图片名称' },
sloc: { type: 'string', description: '图片短定位标识' },
lloc: { type: 'string', description: '图片长定位标识' },
photoUrls: {
type: 'array',
description: '不同规格的封面地址',
items: {
type: 'object',
properties: {
spec: { type: 'integer', description: '图片规格' },
url: albumCoverUrlSchema,
},
required: ['spec', 'url'],
},
},
defaultUrl: albumCoverUrlSchema,
isGif: { type: 'boolean', description: '是否为动图' },
hasRaw: { type: 'boolean', description: '是否有原图' },
},
required: ['name', 'sloc', 'lloc', 'photoUrls', 'defaultUrl', 'isGif', 'hasRaw'],
},
},
required: ['type', 'image'],
};
// Media-list entries are passed through rather than normalized like covers.
// Missing fields can be absent; decoded undefined values become null.
const mediaUrlSchema = {
type: ['object', 'null'],
properties: {
url: { type: ['string', 'null'] },
width: { type: ['integer', 'null'] },
height: { type: ['integer', 'null'] },
},
};
const mediaPhotoUrlsSchema = {
type: ['array', 'null'],
items: {
type: 'object',
properties: { spec: { type: ['integer', 'null'] }, url: mediaUrlSchema },
},
};
const mediaImageSchema = {
type: ['object', 'null'],
properties: {
name: { type: ['string', 'null'] },
sloc: { type: ['string', 'null'] },
lloc: { type: ['string', 'null'], description: '图片长定位标识,供评论、删除等操作使用' },
photoUrls: mediaPhotoUrlsSchema,
defaultUrl: mediaUrlSchema,
isGif: { type: ['boolean', 'null'] },
hasRaw: { type: ['boolean', 'null'] },
},
};
const albumMediaSchema = {
type: 'object',
properties: {
type: { type: ['integer', 'null'] },
image: mediaImageSchema,
video: {
type: ['object', 'null'],
properties: {
id: { type: ['string', 'null'], description: '视频标识,供评论、删除等操作使用' },
url: { type: ['string', 'null'] },
cover: mediaImageSchema,
width: { type: ['integer', 'null'] },
height: { type: ['integer', 'null'] },
videoTime: { type: ['string', 'null'], description: '时长,十进制字符串' },
videoUrl: mediaPhotoUrlsSchema,
},
},
uploader: { type: ['string', 'null'] },
batchId: { type: ['string', 'null'], description: '上传批次,十进制字符串' },
uploadTime: { type: ['string', 'null'], description: '上传时间,十进制字符串' },
},
};
export const actions = [
groupAction({
name: 'get_group_album_list',
examples: [
{
summary: '基本调用',
params: {
group_id: 20001,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
readOnly: true,
returns: '群相册列表数组,每项为一个相册的基本信息。',
returnsSchema: {
type: 'array',
description: '群相册列表',
items: {
type: 'object',
properties: {
id: { type: 'string', description: '相册 id' },
name: { type: 'string', description: '相册名称' },
picNum: { type: 'integer', description: '相册内照片数量' },
createTime: { type: 'integer', description: '相册创建时间(unix 秒)' },
last_upload_time: { type: 'integer', description: '最后上传时间(unix 秒)' },
cover: albumCoverSchema,
createuin: { type: 'string', description: '相册创建者 QQ 号' },
createnickname: {
type: 'string',
description: '相册创建者昵称(原始 Unicode)',
},
},
required: [
'id',
'name',
'picNum',
'createTime',
'last_upload_time',
'cover',
'createuin',
'createnickname',
],
},
},
run: async (p, ctx) => {
const albumList = await ctx.bridge.apis.groupAlbum.list(p.group_id);
return okResponse(albumList);
},
}),
// get_qun_album_list — NapCat-compatible view of QQ NT's AlbumService.
groupAction({
name: 'get_qun_album_list',
examples: [
{
summary: '基本调用',
params: {
group_id: 20001,
attach_info: '',
},
notes: '后续页原样传回上一页的 attach_info;has_more=false 表示结束。',
},
],
readOnly: true,
returns: 'NapCat 风格的相册列表封套:{album_list, attach_info, has_more}。',
returnsSchema: {
type: 'object',
properties: {
album_list: {
type: 'array',
description: '相册列表',
items: {
type: 'object',
properties: {
album_id: { type: 'string', description: '相册 id' },
name: { type: 'string', description: '相册名称' },
create_time: { type: 'string', description: '相册创建时间(unix 秒)' },
last_upload_time: {
type: 'string',
description: '最后上传时间(unix 秒)',
},
upload_number: { type: 'string', description: '相册内媒体数量' },
cover: albumCoverSchema,
creator: {
type: 'object',
description: '相册创建者信息',
properties: {
uin: { type: 'string', description: '创建者 QQ 号' },
uid: { type: 'string', description: '创建者 UID' },
nick: {
type: 'string',
description: '创建者原始 Unicode 昵称',
},
},
},
},
required: [
'album_id',
'name',
'create_time',
'last_upload_time',
'upload_number',
'cover',
],
},
},
attach_info: { type: 'string', description: '下一页分页游标' },
has_more: { type: 'boolean', description: '是否还有更多' },
},
required: ['album_list', 'attach_info', 'has_more'],
},
params: {
attach_info: f.string().default(''),
},
run: async (p, ctx) => {
const result = await ctx.bridge.apis.groupAlbum.listQun(p.group_id, p.attach_info);
return okResponse({
album_list: result.albumList,
attach_info: result.attachInfo,
has_more: result.hasMore,
} as unknown as JsonValue);
},
}),
groupAction({
name: 'upload_image_to_qun_album',
examples: [
{
summary: '基本调用',
params: {
group_id: 20001,
album_id: '<album-id>',
album_name: '活动照片',
file: 'file:///path/to/photo.png',
},
notes: 'album_id 来自 get_group_album_list;lloc 或视频 id、batch_id 来自 get_group_album_media_list。替换群号及对应资源标识。 示例路径位于 SnowLuma 服务端,须替换为该服务可读取的实际文件。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
params: {
album_id: f.string({ allowEmpty: false }),
album_name: f.string({ allowEmpty: false }),
file: f.image(),
},
run: async (p, ctx) => {
await ctx.bridge.apis.groupAlbum.upload(p.group_id, p.album_id, p.album_name, p.file);
return okResponse(null);
},
}),
groupAction({
name: 'upload_video_to_qun_album',
examples: [
{
summary: '上传视频到群相册',
params: {
group_id: 20001,
album_id: '<album-id>',
album_name: '活动视频',
file: 'file:///path/to/video.mp4',
},
notes: 'album_id 来自 get_group_album_list;lloc 或视频 id、batch_id 来自 get_group_album_media_list。替换群号及对应资源标识。 示例路径位于 SnowLuma 服务端,须替换为该服务可读取的实际文件。',
},
],
summary: '上传视频到群相册',
returns: '{ id: string }',
returnsSchema: {
type: 'object',
properties: {
id: { type: 'string', description: '视频 id' },
},
required: ['id'],
},
params: {
album_id: f.string({ allowEmpty: false }).describe('相册 id'),
album_name: f.string({ allowEmpty: false }).describe('相册名称'),
file: f.video().describe('视频文件'),
},
run: async (p, ctx) => {
const result = await ctx.bridge.apis.groupAlbum.uploadVideo(
p.group_id,
p.album_id,
p.album_name,
p.file,
);
return okResponse(result);
},
}),
groupAction({
name: 'get_group_album_media_list',
examples: [
{
summary: '基本调用',
params: {
group_id: 20001,
album_id: '<album-id>',
attach_info: '',
},
notes: 'album_id 来自 get_group_album_list;lloc 或视频 id、batch_id 来自 get_group_album_media_list。替换群号及对应资源标识。 下一页传回 nextAttachInfo。',
},
],
readOnly: true,
returns:
'相册图片/视频列表及下一页分页游标;视频项包含 id、url、cover、尺寸、时长和多规格地址。',
returnsSchema: {
type: 'object',
properties: {
mediaList: {
type: 'array',
description: '相册图片或视频,未返回的字段可省略或为 null',
items: albumMediaSchema,
},
nextAttachInfo: { type: 'string', description: '下一页分页游标(空串表示无更多)' },
},
required: ['mediaList', 'nextAttachInfo'],
},
params: {
album_id: f.string({ allowEmpty: false }),
attach_info: f.string().default(''),
},
run: async (p, ctx) => {
const mediaList = await ctx.bridge.apis.groupAlbum.getMediaList(
p.group_id,
p.album_id,
p.attach_info,
);
return okResponse(mediaList as unknown as JsonValue);
},
}),
groupAction({
name: 'do_group_album_comment',
examples: [
{
summary: '基本调用',
params: {
group_id: 20001,
album_id: '<album-id>',
lloc: '<media-lloc-or-video-id>',
content: '拍得很好',
},
notes: 'album_id 来自 get_group_album_list;lloc 或视频 id、batch_id 来自 get_group_album_media_list。替换群号及对应资源标识。',
},
],
returnsSchema: {
type: 'object',
properties: {
id: {
type: 'string',
},
user: {
type: 'object',
properties: {
uin: {
type: 'string',
},
},
required: ['uin'],
},
content: {
type: 'array',
items: {
type: 'object',
properties: {
type: {
type: 'integer',
},
content: {
type: 'string',
},
},
required: ['type', 'content'],
},
},
time: {
type: 'string',
description: '十进制时间字符串',
},
clientKey: {
type: 'string',
},
},
required: ['id', 'user', 'content', 'time', 'clientKey'],
},
params: {
album_id: f.string({ allowEmpty: false }),
lloc: f.string({ allowEmpty: false }),
content: f.string({ allowEmpty: false }),
},
run: async (p, ctx) => {
const comment = await ctx.bridge.apis.groupAlbum.comment(
p.group_id,
p.album_id,
p.lloc,
p.content,
);
return okResponse(comment as unknown as JsonValue);
},
}),
groupAction({
name: 'set_group_album_media_like',
examples: [
{
summary: '基本调用',
params: {
group_id: 20001,
album_id: '<album-id>',
batch_id: '1700000000',
},
notes: 'album_id 来自 get_group_album_list;lloc 或视频 id、batch_id 来自 get_group_album_media_list。替换群号及对应资源标识。 batch_id 必须是实际批次的十进制字符串。',
},
],
returnsSchema: {
type: 'object',
properties: {
id: {
type: ['string', 'null'],
},
status: {
type: ['integer', 'null'],
},
},
},
params: {
album_id: f.string({ allowEmpty: false }),
batch_id: f.string({ allowEmpty: false }),
lloc: f.string().optional(), // 可选参数(空串按未传处理)
},
run: async (p, ctx) => {
const res = await ctx.bridge.apis.groupAlbum.like(
p.group_id,
p.album_id,
p.batch_id,
p.lloc || undefined,
true,
);
return okResponse(res);
},
}),
// 取消点赞群相册媒体
groupAction({
name: 'cancel_group_album_media_like',
examples: [
{
summary: '基本调用',
params: {
group_id: 20001,
album_id: '<album-id>',
batch_id: '1700000000',
},
notes: 'album_id 来自 get_group_album_list;lloc 或视频 id、batch_id 来自 get_group_album_media_list。替换群号及对应资源标识。 batch_id 必须是实际批次的十进制字符串。',
},
],
returnsSchema: {
type: 'object',
properties: {
id: {
type: ['string', 'null'],
},
status: {
type: ['integer', 'null'],
},
},
},
params: {
album_id: f.string({ allowEmpty: false }),
batch_id: f.string({ allowEmpty: false }),
lloc: f.string().optional(), // 可选参数(空串按未传处理)
},
run: async (p, ctx) => {
const res = await ctx.bridge.apis.groupAlbum.like(
p.group_id,
p.album_id,
p.batch_id,
p.lloc || undefined,
false,
);
return okResponse(res);
},
}),
groupAction({
name: 'del_group_album_media',
examples: [
{
summary: '删除群相册图片或视频',
params: {
group_id: 20001,
album_id: '<album-id>',
lloc: '<media-lloc-or-video-id>',
},
notes: 'album_id 来自 get_group_album_list;lloc 或视频 id、batch_id 来自 get_group_album_media_list。替换群号及对应资源标识。',
},
],
returnsSchema: {
type: 'object',
properties: {
success: {
const: true,
},
},
required: ['success'],
},
summary: '删除群相册图片或视频',
params: {
album_id: f.string({ allowEmpty: false }),
lloc: f.string({ allowEmpty: false }).describe('图片长定位标识,或视频 id'),
},
run: async (p, ctx) => {
const res = await ctx.bridge.apis.groupAlbum.delete(p.group_id, p.album_id, p.lloc);
return okResponse(res);
},
}),
];
+608
View File
@@ -0,0 +1,608 @@
import { defineAction, groupAction, f } from '../action-kit';
import { RETCODE, failedResponse, okResponse } from '../types';
// NapCat/LLOneBot omit `busid` from their `get_group_file_url` schema entirely,
// so whatever value a client tacks on is silently ignored. Mirror that here:
// accept any value, coerce when it's a usable non-negative integer, else fall
// back to the uploaded file's known storage type (otherwise 102). Retains the
// semantics that a strict `f.int({min:0}).default(102)` regressed (a *present*
// non-numeric busid was rejected with `expected an integer`). See issue #147.
function optionalBusId(raw: unknown): number | undefined {
const n =
typeof raw === 'number' && Number.isFinite(raw)
? Math.trunc(raw)
: typeof raw === 'string' && raw.trim() !== '' && Number.isFinite(Number(raw))
? Math.trunc(Number(raw))
: NaN;
return Number.isInteger(n) && n >= 0 ? n : undefined;
}
export const actions = [
groupAction({
name: 'upload_group_file',
examples: [
{
summary: '上传群文件',
params: {
group_id: 20001,
file: 'file:///path/to/report.pdf',
name: '报告.pdf',
},
notes: '示例路径位于 SnowLuma 服务端,须替换为该服务可读取的实际文件。',
},
],
returnsSchema: {
type: 'object',
properties: {
file_id: {
type: 'string',
},
},
required: ['file_id'],
},
summary: '上传群文件',
returns: '{ file_id: string }',
// `folder` / `folder_id` are two aliases for one target dir; the first
// non-empty wins, else '/'. Both default '' so the `||` chain matches the
// legacy `asString(folder) || asString(folder_id) || '/'`.
params: {
file: f.file(),
name: f.string().default(''),
folder: f.string().default(''),
folder_id: f.string().default(''),
upload_file: f.bool().default(true),
},
run: async (p, ctx) => {
const folderId = p.folder || p.folder_id || '/';
const result = await ctx.bridge.apis.groupFile.upload(
p.group_id,
p.file,
p.name,
folderId,
p.upload_file,
);
return okResponse({ file_id: result.fileId });
},
}),
defineAction({
name: 'upload_private_file',
examples: [
{
summary: '上传私聊文件',
params: {
user_id: 10001,
file: 'file:///path/to/report.pdf',
name: '报告.pdf',
},
notes: '示例路径位于 SnowLuma 服务端,须替换为该服务可读取的实际文件。 默认上传并发送给该好友。',
},
],
returnsSchema: {
type: 'object',
properties: {
file_id: {
type: 'string',
},
},
required: ['file_id'],
},
summary: '上传私聊文件',
returns: '{ file_id: string }',
params: {
user_id: f.userId(),
file: f.file(),
name: f.string().default(''),
upload_file: f.bool().default(true),
},
run: async (p, ctx) => {
const result = await ctx.bridge.apis.groupFile.uploadPrivate(
p.user_id,
p.file,
p.name,
p.upload_file,
false,
);
if (p.upload_file) {
if (!result.fileId) {
throw new Error('private file upload returned no file_id');
}
const uploaded = ctx.bridge.recallUploadedFile(result.fileId);
if (!uploaded || uploaded.scope !== 'private' || uploaded.userId !== p.user_id) {
throw new Error(
`private file upload metadata missing for file_id ${result.fileId}`,
);
}
await ctx.sendPrivateMessage(
p.user_id,
[
{
type: 'file',
data: {
file_id: result.fileId,
name: uploaded.fileName,
file_size: uploaded.fileSize,
md5: Buffer.from(uploaded.fileMd5).toString('hex'),
file_hash: result.fileHash ?? uploaded.fileHash ?? '',
},
},
],
false,
);
}
return okResponse({ file_id: result.fileId });
},
}),
groupAction({
name: 'get_group_file_url',
examples: [
{
summary: '获取群文件下载链接',
params: {
group_id: 20001,
file_id: '<file-id-from-get_group_root_files>',
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群文件下载链接',
readOnly: true,
returns: '群文件下载链接。',
returnsSchema: {
type: 'object',
properties: { url: { type: 'string', description: '文件下载直链' } },
required: ['url'],
},
// busid is accepted but tolerant (see optionalBusId): NapCat/LLBot ignore it,
// so a present null/empty/non-numeric value must not 400 the request (#147).
params: { file_id: f.fileId(), busid: f.raw() },
run: async (p, ctx) => {
return okResponse({
url: await ctx.bridge.apis.groupFile.getUrl(
p.group_id,
p.file_id,
optionalBusId(p.busid),
),
});
},
}),
groupAction({
name: 'get_group_root_files',
examples: [
{
summary: '获取群根目录文件列表',
params: {
group_id: 20001,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群根目录文件列表',
readOnly: true,
returns: '群文件系统信息(文件与文件夹列表)。',
returnsSchema: {
type: 'object',
properties: {
files: {
type: 'array',
description: '文件列表',
items: {
type: 'object',
properties: {
group_id: { type: 'integer', description: '群号' },
file_id: { type: 'string', description: '文件 ID' },
file_name: { type: 'string', description: '文件名' },
busid: { type: 'integer', description: '业务 ID' },
file_size: { type: 'integer', description: '文件大小(字节)' },
upload_time: { type: 'integer', description: '上传时间戳' },
dead_time: { type: 'integer', description: '过期时间戳' },
modify_time: { type: 'integer', description: '修改时间戳' },
download_times: { type: 'integer', description: '下载次数' },
uploader: { type: 'integer', description: '上传者 QQ 号' },
uploader_name: { type: 'string', description: '上传者昵称' },
},
},
},
folders: {
type: 'array',
description: '文件夹列表',
items: {
type: 'object',
properties: {
group_id: { type: 'integer', description: '群号' },
folder_id: { type: 'string', description: '文件夹 ID' },
folder_name: { type: 'string', description: '文件夹名' },
create_time: { type: 'integer', description: '创建时间戳' },
creator: { type: 'integer', description: '创建者 QQ 号' },
create_name: { type: 'string', description: '创建者昵称' },
total_file_count: { type: 'integer', description: '文件夹内文件总数' },
last_upload_time: { type: 'integer', description: '最后上传时间戳' },
last_uploader: { type: 'integer', description: '最后上传者 QQ 号' },
last_uploader_name: { type: 'string', description: '最后上传者昵称' },
},
},
},
},
required: ['files', 'folders'],
},
run: async (p, ctx) => {
return okResponse(await ctx.getGroupFiles(p.group_id, '/'));
},
}),
groupAction({
name: 'get_group_files_by_folder',
examples: [
{
summary: '获取群子目录文件列表',
params: {
group_id: 20001,
folder_id: '<folder-id-from-get_group_root_files>',
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群子目录文件列表',
readOnly: true,
returns: '群文件系统信息(文件与文件夹列表)。',
returnsSchema: {
type: 'object',
properties: {
files: {
type: 'array',
description: '文件列表',
items: {
type: 'object',
properties: {
group_id: { type: 'integer', description: '群号' },
file_id: { type: 'string', description: '文件 ID' },
file_name: { type: 'string', description: '文件名' },
busid: { type: 'integer', description: '业务 ID' },
file_size: { type: 'integer', description: '文件大小(字节)' },
upload_time: { type: 'integer', description: '上传时间戳' },
dead_time: { type: 'integer', description: '过期时间戳' },
modify_time: { type: 'integer', description: '修改时间戳' },
download_times: { type: 'integer', description: '下载次数' },
uploader: { type: 'integer', description: '上传者 QQ 号' },
uploader_name: { type: 'string', description: '上传者昵称' },
},
},
},
folders: {
type: 'array',
description: '文件夹列表',
items: {
type: 'object',
properties: {
group_id: { type: 'integer', description: '群号' },
folder_id: { type: 'string', description: '文件夹 ID' },
folder_name: { type: 'string', description: '文件夹名' },
create_time: { type: 'integer', description: '创建时间戳' },
creator: { type: 'integer', description: '创建者 QQ 号' },
create_name: { type: 'string', description: '创建者昵称' },
total_file_count: { type: 'integer', description: '文件夹内文件总数' },
last_upload_time: { type: 'integer', description: '最后上传时间戳' },
last_uploader: { type: 'integer', description: '最后上传者 QQ 号' },
last_uploader_name: { type: 'string', description: '最后上传者昵称' },
},
},
},
},
required: ['files', 'folders'],
},
// folder_id / folder are aliases; first non-empty wins, else '/'.
params: { folder_id: f.string().default(''), folder: f.string().default('') },
run: async (p, ctx) => {
const folderId = p.folder_id || p.folder || '/';
return okResponse(await ctx.getGroupFiles(p.group_id, folderId));
},
}),
groupAction({
name: 'delete_group_file',
examples: [
{
summary: '删除群文件',
params: {
group_id: 20001,
file_id: '<file-id-from-get_group_root_files>',
},
notes: '替换群文件列表中的实际 file_id;需要对应文件操作权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '删除群文件',
params: { file_id: f.fileId() },
run: async (p, ctx) => {
await ctx.bridge.apis.groupFile.delete(p.group_id, p.file_id);
return okResponse();
},
}),
groupAction({
name: 'move_group_file',
examples: [
{
summary: '移动群文件',
params: {
group_id: 20001,
file_id: '<file-id>',
parent_directory: '/',
target_directory: '<target-folder-id>',
},
notes: '目录和文件标识均来自该群文件列表。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '移动群文件',
params: {
file_id: f.fileId(),
parent_directory: f.string({ allowEmpty: false }),
target_directory: f.string({ allowEmpty: false }),
},
run: async (p, ctx) => {
await ctx.bridge.apis.groupFile.move(
p.group_id,
p.file_id,
p.parent_directory,
p.target_directory,
);
return okResponse();
},
}),
// rename_group_file — 0x6D6_4。NapCat 入参:file_id + current_parent_directory
// (文件当前所在目录)+ new_name。SnowLuma 的 file_id 即原始 fileId,无需 UUID 解码。
groupAction({
name: 'rename_group_file',
examples: [
{
summary: '重命名群文件',
params: {
group_id: 20001,
file_id: '<file-id>',
current_parent_directory: '/',
new_name: '新文件名.pdf',
},
notes: 'file_id 来自群文件列表,目录必须是文件当前所在目录。',
},
],
returnsSchema: {
type: 'object',
properties: {
ok: {
const: true,
},
},
required: ['ok'],
},
summary: '重命名群文件',
params: {
file_id: f.fileId(),
current_parent_directory: f.string().default('/'),
new_name: f.string({ allowEmpty: false }),
},
run: async (p, ctx) => {
await ctx.bridge.apis.groupFile.rename(
p.group_id,
p.file_id,
p.current_parent_directory || '/',
p.new_name,
);
// {ok:true} 刻意对齐 NapCat RenameGroupFile 的返回体,偏离 SnowLuma 同类
// 文件写操作(move/delete 返回空 data)——为 NapCat 客户端 drop-in 兼容。
return okResponse({ ok: true });
},
}),
groupAction({
name: 'create_group_file_folder',
examples: [
{
summary: '创建群文件夹',
params: {
group_id: 20001,
name: '共享资料',
parent_id: '/',
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
returnsSchema: {
type: 'object',
properties: {
result: {
type: 'object',
properties: {
retCode: {
const: 0,
},
retMsg: {
const: 'success',
},
},
required: ['retCode', 'retMsg'],
},
groupItem: {
type: 'object',
properties: {
folderInfo: {
type: 'object',
properties: {
folderId: {
type: 'string',
},
folderName: {
type: 'string',
},
folderPath: {
type: 'string',
},
createTime: {
type: 'integer',
},
modifyTime: {
type: 'integer',
},
createUin: {
type: 'integer',
},
modifyUin: {
type: 'integer',
},
},
required: [
'folderId',
'folderName',
'folderPath',
'createTime',
'modifyTime',
'createUin',
'modifyUin',
],
},
},
required: ['folderInfo'],
},
},
required: ['result', 'groupItem'],
},
summary: '创建群文件夹',
// NapCat-aligned data so callers can read back the new folderId (#195):
// { result, groupItem: { folderInfo: { folderId, folderName, … } } }.
returns:
'{ result: { retCode, retMsg }, groupItem: { folderInfo: { folderId, folderName, folderPath, createTime, modifyTime, createUin, modifyUin } } }',
params: { name: f.string({ allowEmpty: false }), parent_id: f.string().default('/') },
run: async (p, ctx) => {
const folderInfo = await ctx.bridge.apis.groupFile.createFolder(
p.group_id,
p.name,
p.parent_id || '/',
);
return okResponse({
result: { retCode: 0, retMsg: 'success' },
groupItem: { folderInfo },
});
},
}),
groupAction({
name: 'delete_group_file_folder',
examples: [
{
summary: '删除群文件夹',
params: {
group_id: 20001,
folder_id: '<folder-id-from-get_group_root_files>',
},
notes: '会删除指定群文件夹,确认资源后调用。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '删除群文件夹',
params: { folder_id: f.string({ allowEmpty: false }) },
run: async (p, ctx) => {
await ctx.bridge.apis.groupFile.deleteFolder(p.group_id, p.folder_id);
return okResponse();
},
}),
groupAction({
name: 'rename_group_file_folder',
examples: [
{
summary: '重命名群文件夹',
params: {
group_id: 20001,
folder_id: '<folder-id>',
new_folder_name: '课程资料',
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '重命名群文件夹',
// new_folder_name / name are aliases; first non-empty wins and must be
// non-empty (legacy `asString(new_folder_name) || asString(name)` + `!newName`).
params: {
folder_id: f.string({ allowEmpty: false }),
new_folder_name: f.string().default(''),
name: f.string().default(''),
},
run: async (p, ctx) => {
const newName = p.new_folder_name || p.name;
if (!newName) {
return failedResponse(
RETCODE.BAD_REQUEST,
'group_id, folder_id and new_folder_name are required',
);
}
await ctx.bridge.apis.groupFile.renameFolder(p.group_id, p.folder_id, newName);
return okResponse();
},
}),
defineAction({
name: 'get_private_file_url',
examples: [
{
summary: '获取私聊文件下载链接',
params: {
user_id: 10001,
file_id: '<file-id-from-private-message>',
},
notes: '使用私聊文件消息段中的 file_id,必要时原样传 file_hash。',
},
],
summary: '获取私聊文件下载链接',
readOnly: true,
returns: '私聊文件下载链接。',
returnsSchema: {
type: 'object',
properties: { url: { type: 'string', description: '文件下载直链' } },
required: ['url'],
},
// NapCat's `get_private_file_url` requires only file_id — it re-derives the
// file hash by decoding its composite file_id and reading file10MMd5 off the
// original message. SnowLuma's file_id is the bare fileUUID, so it relies on
// the file_hash that it already emits alongside file_id in the received file
// segment (event-converter/to-segment.ts). To stay drop-in for NapCat-style
// clients that only persist file_id, both user_id and file_hash are now
// optional; file_hash falls through empty when absent. See issue #147.
params: {
user_id: f.userId().optional(),
file_id: f.fileId(),
file_hash: f.string().default(''),
},
run: async (p, ctx) => {
return okResponse({
url: await ctx.bridge.apis.groupFile.getPrivateUrl(
p.user_id ?? 0,
p.file_id,
p.file_hash,
),
});
},
}),
];
+669
View File
@@ -0,0 +1,669 @@
import { defineAction, groupAction, groupUserAction, f } from '../action-kit';
import { RETCODE, failedResponse, okResponse } from '../types';
import { WebHonorType } from '@snowluma/protocol/web/group-honor';
export const groupInfoReturnsSchema = {
type: 'object',
properties: {
group_id: { type: 'integer', description: '群号' },
group_name: { type: 'string', description: '群名' },
group_remark: { type: 'string', description: '当前账号设置的群备注' },
member_count: { type: 'integer', description: '当前成员数' },
max_member_count: { type: 'integer', description: '成员上限' },
group_create_time: { type: 'integer', description: '建群时间戳(秒)' },
group_level: { type: 'integer', description: '群等级' },
group_memo: { type: 'string', description: '群简介 / 公告预览' },
group_description: { type: 'string', description: '群简介(公告不算;未加入的群为空)' },
group_all_shut: {
type: 'integer',
enum: [-1, 0],
description: '是否开启全员禁言(-1 开启,0 关闭)',
},
},
required: [
'group_id',
'group_name',
'group_remark',
'member_count',
'max_member_count',
'group_all_shut',
],
};
export const actions = [
groupAction({
name: 'get_group_share_link',
examples: [
{
summary: '获取群分享链接',
params: {
group_id: 20001,
need_short_url: true,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群分享链接',
readOnly: true,
params: {
need_short_url: f.bool().default(true).describe('是否获取短链接'),
src_id: f.int({ min: 0, max: 0xffffffff }).default(73).describe('来源标识(兼容参数)'),
additional_param: f.string().default('').describe('附加参数'),
},
returns: '用于分享或邀请加群的链接,可作为群二维码内容。',
returnsSchema: {
type: 'object',
properties: { url: { type: 'string', description: '加群分享链接' } },
required: ['url'],
},
run: async (p, ctx) =>
okResponse({
url: await ctx.bridge.apis.contacts.getGroupShareLink(
p.group_id,
p.need_short_url,
p.src_id,
p.additional_param,
),
}),
}),
defineAction({
name: 'get_group_list',
examples: [
{
summary: '获取群列表',
params: {
no_cache: false,
},
},
],
summary: '获取群列表',
readOnly: true,
returns: '群信息对象数组。',
returnsSchema: {
type: 'array',
items: {
type: 'object',
properties: {
group_id: { type: 'integer', description: '群号' },
group_name: { type: 'string', description: '群名' },
group_remark: { type: 'string', description: '当前账号设置的群备注' },
member_count: { type: 'integer', description: '当前成员数' },
max_member_count: { type: 'integer', description: '成员上限' },
group_create_time: { type: 'integer', description: '建群时间戳(秒)' },
group_level: {
type: 'integer',
description: '群等级(列表批量场景恒 0,详见 get_group_info)',
},
group_memo: { type: 'string', description: '群简介 / 公告预览' },
group_description: {
type: 'string',
description: '群简介(公告不算;未加入的群为空)',
},
group_all_shut: {
type: 'integer',
enum: [-1, 0],
description: '是否开启全员禁言(-1 开启,0 关闭)',
},
},
required: [
'group_id',
'group_name',
'group_remark',
'member_count',
'max_member_count',
'group_all_shut',
],
},
},
params: { no_cache: f.bool().default(false) },
run: async (p, ctx) => {
const noCache = p.no_cache;
if (ctx.getGroupList) {
return okResponse(await ctx.getGroupList(noCache));
}
return okResponse([]);
},
}),
groupAction({
name: 'get_group_info',
examples: [
{
summary: '获取群信息',
params: {
group_id: 20001,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群信息',
readOnly: true,
returns: '群信息对象。',
returnsSchema: groupInfoReturnsSchema,
params: { no_cache: f.bool().default(false) },
run: async (p, ctx) => {
const groupId = p.group_id;
const noCache = p.no_cache;
const fallback = {
group_id: groupId,
group_name: '',
group_remark: '',
member_count: 0,
max_member_count: 0,
group_create_time: 0,
group_level: 0,
group_memo: '',
group_description: '',
group_all_shut: 0,
};
if (ctx.getGroupInfo) {
const info = await ctx.getGroupInfo(groupId, noCache);
return okResponse(info ?? fallback);
}
return okResponse(fallback);
},
}),
groupAction({
name: 'get_group_member_list',
examples: [
{
summary: '获取群成员列表',
params: {
group_id: 20001,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群成员列表',
readOnly: true,
returns: '群成员信息对象数组。',
returnsSchema: {
type: 'array',
items: {
type: 'object',
properties: {
group_id: { type: 'integer', description: '群号' },
user_id: { type: 'integer', description: 'QQ 号' },
nickname: { type: 'string', description: '昵称' },
card: { type: 'string', description: '群名片' },
is_robot: { type: 'boolean', description: '是否为机器人' },
sex: {
type: 'string',
enum: ['male', 'female', 'unknown'],
description: '性别',
},
age: { type: 'integer', description: '年龄' },
join_time: { type: 'integer', description: '入群时间戳(秒)' },
last_sent_time: { type: 'integer', description: '最后发言时间戳(秒)' },
shut_up_timestamp: {
type: 'integer',
description: '禁言结束时间戳(秒,未禁言时为 0)',
},
level: { type: 'string', description: '群等级' },
qq_level: { type: 'integer', description: 'QQ 等级,仅在账号资料可用时返回' },
qage: {
type: 'integer',
description: '按注册时间计算的完整周年数(UTC),注册时间不可用时省略',
},
role: {
type: 'string',
enum: ['owner', 'admin', 'member'],
description: '角色',
},
title: { type: 'string', description: '专属头衔' },
area: { type: 'string', description: '地区(QQ NT 不提供,恒空)' },
unfriendly: {
type: 'boolean',
description: '是否不良记录(QQ NT 不提供,恒 false)',
},
title_expire_time: {
type: 'integer',
description: '头衔过期时间戳(QQ NT 不提供,恒 0)',
},
card_changeable: {
type: 'boolean',
description: '是否可改名片(占位,恒 true)',
},
qidian_master_flag: {
type: 'integer',
description: '企点主号标志,0 或 1;普通账号为 0',
},
qidian_crew_flag: {
type: 'integer',
description: '企点员工标志,0 或 1;普通账号为 0',
},
qidian_crew_flag_2: {
type: 'integer',
description: '企点保留标志,0 或 1;普通账号为 0',
},
},
required: ['group_id', 'user_id', 'nickname', 'role'],
},
},
params: { no_cache: f.bool().default(false) },
run: async (p, ctx) => {
const groupId = p.group_id;
const noCache = p.no_cache;
if (ctx.getGroupMemberList) {
return okResponse(await ctx.getGroupMemberList(groupId, noCache));
}
return okResponse([]);
},
}),
groupUserAction({
name: 'get_group_member_info',
examples: [
{
summary: '获取群成员信息',
params: {
group_id: 20001,
user_id: 10001,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群成员信息',
readOnly: true,
returns: '群成员信息对象。',
returnsSchema: {
type: 'object',
properties: {
group_id: { type: 'integer', description: '群号' },
user_id: { type: 'integer', description: 'QQ 号' },
nickname: { type: 'string', description: '昵称' },
card: { type: 'string', description: '群名片' },
is_robot: { type: 'boolean', description: '是否为机器人' },
sex: { type: 'string', enum: ['male', 'female', 'unknown'], description: '性别' },
age: { type: 'integer', description: '年龄' },
join_time: { type: 'integer', description: '入群时间戳(秒)' },
last_sent_time: { type: 'integer', description: '最后发言时间戳(秒)' },
shut_up_timestamp: {
type: 'integer',
description: '禁言结束时间戳(秒,未禁言时为 0)',
},
level: { type: 'string', description: '群等级' },
qq_level: { type: 'integer', description: 'QQ 等级,仅在账号资料可用时返回' },
qage: {
type: 'integer',
description: '按注册时间计算的完整周年数(UTC),注册时间不可用时省略',
},
role: { type: 'string', enum: ['owner', 'admin', 'member'], description: '角色' },
title: { type: 'string', description: '专属头衔' },
area: { type: 'string', description: '地区(QQ NT 不提供,恒空)' },
unfriendly: {
type: 'boolean',
description: '是否不良记录(QQ NT 不提供,恒 false)',
},
title_expire_time: {
type: 'integer',
description: '头衔过期时间戳(QQ NT 不提供,恒 0)',
},
card_changeable: { type: 'boolean', description: '是否可改名片(占位,恒 true)' },
qidian_master_flag: {
type: 'integer',
description: '企点主号标志,0 或 1;普通账号为 0',
},
qidian_crew_flag: {
type: 'integer',
description: '企点员工标志,0 或 1;普通账号为 0',
},
qidian_crew_flag_2: {
type: 'integer',
description: '企点保留标志,0 或 1;普通账号为 0',
},
},
required: ['group_id', 'user_id', 'nickname', 'role'],
},
params: { no_cache: f.bool().default(false) },
run: async (p, ctx) => {
const groupId = p.group_id;
const userId = p.user_id;
const noCache = p.no_cache;
if (ctx.getGroupMemberInfo) {
const info = await ctx.getGroupMemberInfo(groupId, userId, noCache);
return okResponse(
info ?? {
group_id: groupId,
user_id: userId,
nickname: '',
card: '',
is_robot: false,
sex: 'unknown',
age: 0,
join_time: 0,
last_sent_time: 0,
shut_up_timestamp: 0,
level: '0',
role: 'member',
title: '',
qidian_master_flag: 0,
qidian_crew_flag: 0,
qidian_crew_flag_2: 0,
},
);
}
return okResponse({
group_id: groupId,
user_id: userId,
nickname: '',
card: '',
is_robot: false,
sex: 'unknown',
age: 0,
join_time: 0,
last_sent_time: 0,
shut_up_timestamp: 0,
level: '0',
role: 'member',
title: '',
});
},
}),
groupAction({
name: 'get_group_honor_info',
examples: [
{
summary: '获取群荣誉信息',
params: {
group_id: 20001,
type: 'all',
},
notes: '未请求的分类仍返回空数组;当前龙王可能为 null。',
},
],
returnsSchema: {
type: 'object',
properties: {
group_id: {
type: 'integer',
},
current_talkative: {
anyOf: [
{
type: 'object',
properties: {
user_id: {
type: ['number', 'string', 'null'],
},
nickname: {
type: 'string',
},
avatar: {
type: 'string',
},
description: {
type: 'string',
},
},
required: ['user_id', 'nickname', 'avatar', 'description'],
},
{
type: 'null',
},
],
},
talkative_list: {
type: 'array',
items: {
type: 'object',
properties: {
user_id: {
type: ['number', 'string', 'null'],
},
nickname: {
type: 'string',
},
avatar: {
type: 'string',
},
description: {
type: 'string',
},
},
required: ['user_id', 'nickname', 'avatar', 'description'],
},
},
performer_list: {
type: 'array',
items: {
type: 'object',
properties: {
user_id: {
type: ['number', 'string', 'null'],
},
nickname: {
type: 'string',
},
avatar: {
type: 'string',
},
description: {
type: 'string',
},
},
required: ['user_id', 'nickname', 'avatar', 'description'],
},
},
legend_list: {
type: 'array',
items: {
type: 'object',
properties: {
user_id: {
type: ['number', 'string', 'null'],
},
nickname: {
type: 'string',
},
avatar: {
type: 'string',
},
description: {
type: 'string',
},
},
required: ['user_id', 'nickname', 'avatar', 'description'],
},
},
emotion_list: {
type: 'array',
items: {
type: 'object',
properties: {
user_id: {
type: ['number', 'string', 'null'],
},
nickname: {
type: 'string',
},
avatar: {
type: 'string',
},
description: {
type: 'string',
},
},
required: ['user_id', 'nickname', 'avatar', 'description'],
},
},
strong_newbie_list: {
type: 'array',
items: {
type: 'object',
properties: {
user_id: {
type: ['number', 'string', 'null'],
},
nickname: {
type: 'string',
},
avatar: {
type: 'string',
},
description: {
type: 'string',
},
},
required: ['user_id', 'nickname', 'avatar', 'description'],
},
description: '当前实现始终为空数组',
},
},
required: [
'group_id',
'current_talkative',
'talkative_list',
'performer_list',
'legend_list',
'emotion_list',
'strong_newbie_list',
],
},
summary: '获取群荣誉信息',
readOnly: true,
params: { type: f.string().optional() },
run: async (p, ctx) => {
const groupId = p.group_id;
const typeStr = p.type || 'all';
const typeValues = Object.values(WebHonorType) as string[];
if (!typeValues.includes(typeStr)) {
return failedResponse(
RETCODE.BAD_REQUEST,
`invalid type, must be one of ${typeValues.join(', ')}`,
);
}
try {
const honorInfo = await ctx.bridge.apis.web.getHonorInfo(
groupId,
typeStr as WebHonorType,
);
return okResponse(honorInfo);
} catch (e) {
return failedResponse(
RETCODE.ACTION_FAILED,
`failed to get group honor info: ${(e as Error).message}`,
);
}
},
}),
defineAction({
name: 'get_group_system_msg',
examples: [
{
summary: '获取群系统消息',
params: {
group_id: 20001,
only_pending: true,
count: 20,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群系统消息',
readOnly: true,
returns:
'群系统消息数组,分别提供入群者、邀请人和处理者,可按群号或未处理状态过滤。身份为 0 时须查看状态;未知不代表不存在邀请人。',
returnsSchema: {
type: 'array',
items: {
type: 'object',
properties: {
group_id: { type: 'integer', description: '群号' },
group_name: { type: 'string', description: '群名称' },
request_id: { type: 'integer', description: '请求序列号' },
requester_uin: {
type: 'integer',
description: '请求发起人 QQ 号;邀请登录账号入群时表示邀请人',
},
requester_nick: { type: 'string', description: '请求发起人昵称' },
target_uin: {
type: 'integer',
description: '申请或被邀请入群者 QQ 号,未知时为 0',
},
target_nick: { type: 'string', description: '入群者昵称' },
target_status: {
type: 'string',
enum: ['known', 'unknown'],
description: '入群者 QQ 号是否已知',
},
invitor_uin: {
type: 'integer',
description: '邀请人 QQ 号,未知时为 0;不能据此认定没有邀请人',
},
invitor_nick: { type: 'string', description: '邀请人昵称' },
invitor_status: {
type: 'string',
enum: ['known', 'unknown'],
description: '邀请人 QQ 号是否已知;缺失信息为 unknown',
},
operator_uin: {
type: 'integer',
description: '处理该申请的操作者 QQ 号,未知时为 0',
},
operator_nick: { type: 'string', description: '处理者昵称' },
operator_status: {
type: 'string',
enum: ['known', 'unknown'],
description: '处理者 QQ 号是否已知;已知不代表审批通过',
},
message: { type: 'string', description: '验证留言' },
checked: { type: 'boolean', description: '是否已处理' },
flag: { type: 'string', description: '处理请求使用的规范 flag' },
},
required: [
'group_id',
'group_name',
'request_id',
'requester_uin',
'requester_nick',
'target_uin',
'target_nick',
'target_status',
'invitor_uin',
'invitor_nick',
'invitor_status',
'operator_uin',
'operator_nick',
'operator_status',
'message',
'checked',
'flag',
],
},
},
params: {
group_id: f.groupId().optional(),
only_pending: f.bool().default(false),
count: f.int({ min: 1, max: 100 }).default(50).describe('每个收件箱最多读取的记录数'),
},
run: async (p, ctx) => {
if (ctx.handleGetGroupSystemMsg) {
return okResponse(
await ctx.handleGetGroupSystemMsg({
groupId: p.group_id,
onlyPending: p.only_pending,
count: p.count,
}),
);
}
return okResponse([]);
},
}),
];
+313
View File
@@ -0,0 +1,313 @@
// Action registry — the single source of the executable OneBot namespace.
//
// ACTION_GROUPS is the authored input. ACTION_REGISTRY compiles it exactly
// once at module initialization, validating canonical names, aliases, stream
// classification, and the reserved raw-action namespace before any
// ApiHandler can start. Runtime registration and generated docs are projections
// of that same compiled value; neither is allowed to rebuild its own list.
import type { ActionDoc, RegisteredActionSpec } from '../action-kit';
import type { ActionHandler, ApiActionContext, ApiHandler } from '../api-handler';
import { actions as infoActions } from './info';
import { actions as messageActions } from './message';
import { actions as friendActions } from './friend';
import { actions as groupInfoActions } from './group-info';
import { actions as groupAdminActions } from './group-admin';
import { actions as groupFileActions } from './group-file';
import { actions as requestActions } from './request';
import { actions as extendedActions } from './extended';
import { actions as groupAlbumActions } from './group-album';
import { actions as qzoneActions } from './qzone';
import { actions as qzonePublicActions } from './qzone-public';
import { actions as streamFileActions } from './stream-file';
import { actions as streamDownloadActions } from './stream-download';
import { actions as systemFaceActions } from './system-face';
export interface ActionGroup {
/** Domain category (= source file), surfaced to the MCP / UI for grouping. */
readonly category: string;
readonly actions: readonly RegisteredActionSpec[];
}
export interface RawActionReservation {
/** Executable wire name reserved for a handler that cannot be an ActionSpec. */
readonly name: string;
/** Diagnostic owner. Usually identical to name; explicit for conflict logs. */
readonly canonical: string;
}
export type CompiledActionKind = RegisteredActionSpec['kind'] | 'raw';
export type ActionNameRole = 'canonical' | 'alias' | 'raw';
export interface CompiledAction {
readonly canonical: string;
readonly names: readonly string[];
readonly kind: RegisteredActionSpec['kind'];
readonly category: string;
readonly doc: ActionDoc;
readonly spec: RegisteredActionSpec;
}
interface ActionNameClaimBase {
readonly name: string;
readonly canonical: string;
readonly kind: CompiledActionKind;
readonly role: ActionNameRole;
}
export interface CompiledDeclarativeName extends ActionNameClaimBase {
readonly kind: RegisteredActionSpec['kind'];
readonly role: 'canonical' | 'alias';
readonly action: CompiledAction;
}
export interface CompiledRawName extends ActionNameClaimBase {
readonly kind: 'raw';
readonly role: 'raw';
}
export type CompiledActionName = CompiledDeclarativeName | CompiledRawName;
export type RawActionFactory = (api: ApiHandler) => ActionHandler;
export interface BoundAction {
readonly handler: ActionHandler;
readonly canonical: string;
readonly kind: CompiledActionKind;
}
export interface CompiledActionRegistry {
/** Canonical declarative actions in authored group/action order. */
readonly actions: readonly CompiledAction[];
/** Every executable name, including aliases and reserved raw handlers. */
readonly executableNames: readonly CompiledActionName[];
/** Category order/counts used by the docs and UI. */
readonly categories: readonly { category: string; count: number }[];
readonly rawActions: readonly CompiledRawName[];
resolve(name: string): CompiledActionName | undefined;
/** Produce the sealed executable table. Extra or missing raw factories fail. */
bind(
ctx: ApiActionContext,
api: ApiHandler,
rawFactories: Readonly<Record<string, RawActionFactory>>,
): ReadonlyMap<string, BoundAction>;
}
function validateSpecProjection(spec: RegisteredActionSpec, doc: ActionDoc): void {
const canonical = spec.names[0];
if (!canonical || canonical.trim() === '') {
throw new Error(
`Action registry invalid ${spec.kind} action: canonical name must not be empty`,
);
}
for (const name of spec.names) {
if (name.trim() === '') {
throw new Error(
`Action registry invalid ${spec.kind} action canonical "${canonical}": executable name must not be empty`,
);
}
}
const aliases = spec.names.slice(1);
if (
doc.name !== canonical ||
doc.aliases.length !== aliases.length ||
doc.aliases.some((name, i) => name !== aliases[i])
) {
throw new Error(
`Action registry invalid ${spec.kind} action canonical "${canonical}": describe() names do not match executable names`,
);
}
const documentedKind = doc.stream === true ? 'stream' : 'normal';
if (documentedKind !== spec.kind) {
throw new Error(
`Action registry invalid ${spec.kind} action canonical "${canonical}": describe() reports kind ${documentedKind}`,
);
}
}
function conflictMessage(
name: string,
first: CompiledActionName,
second: CompiledActionName,
): string {
return [
`Action registry conflict for executable name "${name}"`,
`canonical "${first.canonical}" (name "${first.name}", kind ${first.kind}, role ${first.role})`,
`canonical "${second.canonical}" (name "${second.name}", kind ${second.kind}, role ${second.role})`,
].join(': ');
}
/** Compile and validate a complete executable namespace. Exported so the
* conflict matrix can be tested without constructing an ApiHandler. */
export function compileActionRegistry(
groups: readonly ActionGroup[],
rawReservations: readonly RawActionReservation[] = [],
): CompiledActionRegistry {
const actions: CompiledAction[] = [];
const executableNames: CompiledActionName[] = [];
const rawActions: CompiledRawName[] = [];
const byName = new Map<string, CompiledActionName>();
const claim = (next: CompiledActionName): void => {
const previous = byName.get(next.name);
if (previous) {
throw new Error(conflictMessage(next.name, previous, next));
}
byName.set(next.name, next);
executableNames.push(next);
};
for (const group of groups) {
for (const spec of group.actions) {
const described = spec.describe();
validateSpecProjection(spec, described);
const canonical = spec.names[0]!;
const action: CompiledAction = Object.freeze({
canonical,
names: Object.freeze([...spec.names]),
kind: spec.kind,
category: group.category,
doc: Object.freeze({ ...described, category: group.category }),
spec,
});
actions.push(action);
action.names.forEach((name, index) =>
claim(
Object.freeze({
name,
canonical,
kind: action.kind,
role: index === 0 ? 'canonical' : 'alias',
action,
}),
),
);
}
}
for (const reservation of rawReservations) {
if (reservation.name.trim() === '' || reservation.canonical.trim() === '') {
throw new Error(
'Action registry invalid raw action: canonical and executable name must not be empty',
);
}
const raw: CompiledRawName = Object.freeze({
name: reservation.name,
canonical: reservation.canonical,
kind: 'raw',
role: 'raw',
});
claim(raw);
rawActions.push(raw);
}
const categories = groups.map(({ category, actions: groupActions }) =>
Object.freeze({
category,
count: groupActions.length,
}),
);
return Object.freeze({
actions: Object.freeze(actions),
executableNames: Object.freeze(executableNames),
categories: Object.freeze(categories),
rawActions: Object.freeze(rawActions),
resolve: (name: string) => byName.get(name),
bind: (
ctx: ApiActionContext,
api: ApiHandler,
rawFactories: Readonly<Record<string, RawActionFactory>>,
) => bindActionRegistry(executableNames, ctx, api, rawFactories),
});
}
function bindActionRegistry(
executableNames: readonly CompiledActionName[],
ctx: ApiActionContext,
api: ApiHandler,
rawFactories: Readonly<Record<string, RawActionFactory>>,
): ReadonlyMap<string, BoundAction> {
const factoryNames = new Set(Object.keys(rawFactories));
const handlers = new Map<string, BoundAction>();
for (const claim of executableNames) {
if (claim.kind === 'raw') {
const factory = rawFactories[claim.canonical];
if (!factory) {
throw new Error(
`Action registry raw factory missing: canonical "${claim.canonical}" ` +
`(name "${claim.name}", kind raw)`,
);
}
factoryNames.delete(claim.canonical);
handlers.set(
claim.name,
Object.freeze({
handler: factory(api),
canonical: claim.canonical,
kind: 'raw',
}),
);
continue;
}
const handler = claim.action.spec.toHandler(ctx);
handlers.set(
claim.name,
Object.freeze({
handler,
canonical: claim.canonical,
kind: claim.kind,
}),
);
}
if (factoryNames.size > 0) {
const extra = [...factoryNames][0]!;
throw new Error(
`Action registry unexpected raw factory: canonical "${extra}" ` +
`(name "${extra}", kind raw)`,
);
}
return handlers;
}
/** Every declarative action, grouped by domain category. Authored input. */
export const ACTION_GROUPS: readonly ActionGroup[] = [
{ category: '信息', actions: infoActions },
{ category: '消息', actions: messageActions },
{ category: '好友', actions: friendActions },
{ category: '群信息', actions: groupInfoActions },
{ category: '群管理', actions: groupAdminActions },
{ category: '群文件', actions: groupFileActions },
{ category: '请求', actions: requestActions },
{ category: '扩展', actions: extendedActions },
{ category: '群相册', actions: groupAlbumActions },
{ category: '空间', actions: [...qzoneActions, ...qzonePublicActions] },
{ category: '系统表情', actions: systemFaceActions },
{ category: '流式接口', actions: [...streamFileActions, ...streamDownloadActions] },
];
/** Hidden OneBot v11 quick-operation handler; `_async` is the NoneBot 1 alias. */
export const HANDLE_QUICK_OPERATION_ACTION = '.handle_quick_operation';
export const HANDLE_QUICK_OPERATION_ASYNC_ACTION = '.handle_quick_operation_async';
export const RAW_ACTION_RESERVATIONS: readonly RawActionReservation[] = [
{ name: HANDLE_QUICK_OPERATION_ACTION, canonical: HANDLE_QUICK_OPERATION_ACTION },
{ name: HANDLE_QUICK_OPERATION_ASYNC_ACTION, canonical: HANDLE_QUICK_OPERATION_ACTION },
];
/** Complete, validated runtime/docs registry. Compilation happens on import. */
export const ACTION_REGISTRY = compileActionRegistry(ACTION_GROUPS, RAW_ACTION_RESERVATIONS);
+138
View File
@@ -0,0 +1,138 @@
import { APP_VERSION } from '@snowluma/common/version';
import { defineAction } from '../action-kit';
import { okResponse } from '../types';
export const actions = [
defineAction({
name: 'get_login_info',
examples: [
{
summary: '基本调用',
params: {},
},
],
readOnly: true,
returns: '当前登录账号的 QQ 号与昵称。',
returnsSchema: {
type: 'object',
properties: {
user_id: { type: 'integer', description: '登录 QQ 号' },
nickname: { type: 'string', description: '登录昵称' },
},
required: ['user_id', 'nickname'],
},
params: {},
run: (_p, ctx) => {
const login = ctx.getLoginInfo();
return okResponse({ user_id: login.userId, nickname: login.nickname });
},
}),
defineAction({
name: 'get_status',
examples: [
{
summary: '基本调用',
params: {},
},
],
readOnly: true,
returns:
'运行状态。`online` 表示账号在线;`good` 表示已确认的收发链路健康状态;`time` 为当前账号服务实例运行秒数,与 #sl 同源。',
returnsSchema: {
type: 'object',
properties: {
online: { type: 'boolean', description: '是否在线' },
good: {
type: 'boolean',
description: '收发链路健康状态;确认接收停滞或主动请求连接失效时为 false',
},
time: {
type: 'integer',
description: '当前账号服务实例运行秒数,与 #sl 同源;实例重建后重新计时',
},
},
required: ['online', 'good', 'time'],
},
params: {},
run: (_p, ctx) => {
const online = ctx.isOnline();
return okResponse({
online,
good: online && ctx.bridge.receiveHealthy,
time: Math.floor(ctx.getUptimeMs() / 1000),
});
},
}),
defineAction({
name: 'get_version_info',
examples: [
{
summary: '基本调用',
params: {},
},
],
readOnly: true,
returns: '实现与协议版本信息。',
returnsSchema: {
type: 'object',
properties: {
app_name: { type: 'string', description: '实现名称(SnowLuma)' },
app_version: { type: 'string', description: '实现版本' },
protocol_version: { type: 'string', description: 'OneBot 协议版本' },
},
required: ['app_name', 'app_version', 'protocol_version'],
},
params: {},
run: () => {
return okResponse({
app_name: 'SnowLuma',
app_version: `${APP_VERSION}-node`,
protocol_version: 'v11',
});
},
}),
defineAction({
name: 'can_send_image',
examples: [
{
summary: '基本调用',
params: {},
},
],
readOnly: true,
returns: '能力查询结果。',
returnsSchema: {
type: 'object',
properties: { yes: { type: 'boolean', description: '是否支持发送图片' } },
required: ['yes'],
},
params: {},
run: (_p, ctx) => {
return okResponse({ yes: ctx.canSendImage?.() ?? false });
},
}),
defineAction({
name: 'can_send_record',
examples: [
{
summary: '基本调用',
params: {},
},
],
readOnly: true,
returns: '能力查询结果。',
returnsSchema: {
type: 'object',
properties: { yes: { type: 'boolean', description: '是否支持发送语音' } },
required: ['yes'],
},
params: {},
run: (_p, ctx) => {
return okResponse({ yes: ctx.canSendRecord?.() ?? false });
},
}),
];
+281
View File
@@ -0,0 +1,281 @@
import { createLogger } from '@snowluma/common/logger';
import type { JsonObject, JsonValue } from '../types';
import { defineAction, groupAction, f, type Field } from '../action-kit';
import { RETCODE, failedResponse, okResponse } from '../types';
import {
messageResultSchema,
messageIdSchema,
messageWithAutoReplySchema,
} from '../action-result-schemas';
/** send_msg only (#426). Some clients send `group_id: ""` on private chats.
* Treat that exact empty string as absent. Do not change `f.groupId()`. */
function sendMsgOptionalGroupId(): Field<number | undefined> {
const inner = f.groupId().optional();
return Object.assign(Object.create(inner) as Field<number | undefined>, {
coerce(raw: Parameters<Field<number | undefined>['coerce']>[0], field: string) {
if (raw === '') {
return { ok: true as const, value: undefined };
}
return inner.coerce(raw, field);
},
});
}
const log = createLogger('OneBot');
export const actions = [
defineAction({
name: 'send_custom_face',
examples: [
{
summary: '发送账号收藏表情(emoji_id 或 MD5),保留表情显示样式',
params: {
group_id: 20001,
emoji_id: '<emoji-id-from-fetch_custom_face_detail>',
},
notes: '群聊和私聊目标只能提供一个;emoji_id 来自 fetch_custom_face_detail。',
},
],
returnsSchema: messageResultSchema,
summary: '发送账号收藏表情(emoji_id 或 MD5),保留表情显示样式',
returns: '{ message_id: number }',
params: {
emoji_id: f.string().describe('当前账号收藏表情的完整 ID 或 32 位 MD5'),
group_id: f.groupId().optional().describe('目标群号;与 user_id 二选一'),
user_id: f.userId().optional().describe('目标 QQ 号;与 group_id 二选一'),
reply_to: f.messageId().optional().describe('可选回复消息 ID'),
},
run: async (p, ctx) => {
if ((p.group_id === undefined) === (p.user_id === undefined)) {
return failedResponse(
RETCODE.BAD_REQUEST,
'exactly one of group_id and user_id is required',
);
}
if (!p.emoji_id.trim()) {
return failedResponse(RETCODE.BAD_REQUEST, 'emoji_id is required');
}
const face = await ctx.bridge.apis.profile.resolveCustomFace(p.emoji_id);
const message: JsonObject[] = [];
if (p.reply_to !== undefined) {
message.push({ type: 'reply', data: { id: String(p.reply_to) } });
}
message.push({
type: 'image',
data: { file: face.url, sub_type: 1, summary: '[动画表情]' },
});
const result =
p.group_id !== undefined
? await ctx.sendGroupMessage(p.group_id, message, false)
: await ctx.sendPrivateMessage(p.user_id!, message, false);
return okResponse({ message_id: result.messageId });
},
}),
// send_msg routes on message_type / group_id presence, so the *required*
// id is conditional — that branch stays in run(). The fields themselves
// (message required; group_id/user_id valid uints when present) are
// validated by the spec; message_type is left lenient for parity.
defineAction({
name: 'send_msg',
examples: [
{
summary: '发送消息(按 message_type/群号 自动路由群聊或私聊)',
params: {
message_type: 'group',
group_id: 20001,
message: '你好',
},
notes: '必须提供对应目标;私聊使用 message_type=private 和 user_id。',
},
],
returnsSchema: messageResultSchema,
summary: '发送消息(按 message_type/群号 自动路由群聊或私聊)',
returns: '{ message_id: number }',
params: {
message: f.message(),
message_type: f.string().optional(),
group_id: sendMsgOptionalGroupId(),
user_id: f.userId().optional(),
auto_escape: f.bool().default(false),
},
run: async (p, ctx) => {
// Group temp-session reply: message_type=private with BOTH a user_id and
// a group_id (the source group). Detect it first so a group_id here
// doesn't get misrouted to a group message.
const isTempReply =
p.message_type === 'private' && p.user_id !== undefined && p.group_id !== undefined;
if (!isTempReply && (p.message_type === 'group' || p.group_id !== undefined)) {
if (p.group_id === undefined) {
return failedResponse(RETCODE.BAD_REQUEST, 'group_id is required');
}
const result = await ctx.sendGroupMessage(p.group_id, p.message, p.auto_escape);
return okResponse({ message_id: result.messageId });
}
if (p.user_id === undefined) {
return failedResponse(RETCODE.BAD_REQUEST, 'user_id is required');
}
const result = await ctx.sendPrivateMessage(
p.user_id,
p.message,
p.auto_escape,
isTempReply ? p.group_id : undefined,
);
return okResponse({ message_id: result.messageId });
},
}),
defineAction({
name: 'send_private_msg',
examples: [
{
summary: '发送私聊消息',
params: {
user_id: 10001,
message: '你好',
},
notes: '替换目标好友 QQ 号;临时会话需另传来源 group_id。',
},
],
returnsSchema: messageResultSchema,
summary: '发送私聊消息',
returns: '{ message_id: number }',
params: {
user_id: f.userId(),
message: f.message(),
// Optional source group: reply into that group's temp session (临时会话)
// instead of a friend chat. int({min:0}) rather than groupId (which
// rejects 0): a client that fills every field with group_id:0 must keep
// working as a plain private send, so 0/absent means "no temp session".
group_id: f.int({ min: 0 }).optional(),
auto_escape: f.bool().default(false),
},
run: async (p, ctx) => {
const tempGroupId = p.group_id && p.group_id > 0 ? p.group_id : undefined;
const result = await ctx.sendPrivateMessage(
p.user_id,
p.message,
p.auto_escape,
tempGroupId,
);
return okResponse({ message_id: result.messageId });
},
}),
groupAction({
name: 'send_group_msg',
examples: [
{
summary: '发送群消息',
params: {
group_id: 20001,
message: [
{
type: 'text',
data: {
text: '你好',
},
},
],
},
notes: '替换目标群号;成功返回的 message_id 可为负值。',
},
],
returnsSchema: messageResultSchema,
summary: '发送群消息',
returns: '{ message_id: number }',
params: { message: f.message(), auto_escape: f.bool().default(false) },
run: async (p, ctx) => {
const result = await ctx.sendGroupMessage(p.group_id, p.message, p.auto_escape);
return okResponse({ message_id: result.messageId });
},
}),
defineAction({
name: 'get_msg',
examples: [
{
summary: '获取消息',
params: {
message_id: -123456,
},
notes: '使用消息事件或发送结果中的真实 message_id,负值合法;消息须在当前账号的消息存储中可查询。',
},
],
returnsSchema: {
allOf: [
messageWithAutoReplySchema,
{
type: 'object',
properties: {
real_id: messageIdSchema,
},
required: ['real_id'],
},
],
description: '消息事件去除 post_type/self_id 并增加 real_id;群聊和私聊字段有所不同',
},
summary: '获取消息',
readOnly: true,
returns:
'消息事件对象(去除 post_type/self_id,附带 real_id);逐个尝试刷新图片、语音和视频 URL,成功使用新地址,失败保留该段原地址并继续返回消息;地址更新时同步 raw_message。',
params: { message_id: f.messageId() },
run: async (p, ctx) => {
const data = await ctx.getMessage(p.message_id);
if (!data) {
log.warn('[get_msg] miss message_id=%d', p.message_id);
return failedResponse(RETCODE.ACTION_FAILED, 'message not found');
}
const result: JsonObject = { ...data };
delete result.post_type;
delete result.self_id;
result.real_id = (result.message_id ?? p.message_id) as JsonValue;
await ctx.refreshMessageMedia(p.message_id, result);
return okResponse(result);
},
}),
defineAction({
name: 'delete_msg',
examples: [
{
summary: '撤回消息',
params: {
message_id: -123456,
},
notes: '使用消息事件或发送结果中的真实 message_id,负值合法;消息须在当前账号的消息存储中可查询。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '撤回消息',
params: { message_id: f.messageId() },
run: async (p, ctx) => {
const meta = await ctx.getMessageMeta(p.message_id);
if (!meta) {
return failedResponse(
RETCODE.ACTION_FAILED,
'message not found or not retractable',
);
}
await ctx.deleteMessage(p.message_id, meta);
return okResponse();
},
}),
];
+463
View File
@@ -0,0 +1,463 @@
import { defineAction, f } from '../action-kit';
import { okResponse, type JsonObject } from '../types';
const feed = f
.object()
.describe('get_qzone_public_feeds 返回的完整 feeds 条目,保留所有原始 cell 字段');
const comment = f.object().describe('详情或评论页返回的完整评论对象,保留 user 等原始字段');
const reply = f.object().describe('评论或回复页返回的完整回复对象,保留 user 等原始字段');
const content = f
.textOrObjectArray()
.describe(
'非空文本,或非空上游 RichItem 对象数组;字符串始终原样发送,富文本请直接传对象数组,不是 OneBot 消息段',
);
const objectResult = { type: 'object', additionalProperties: true };
const feedSource =
'feed 必须原样取自 get_qzone_public_feeds 的 feeds 数组,不能用旧说说 tid 拼造;示例中的占位字段须整体替换。';
const commentSource =
'comment 可取 get_qzone_public_feed_detail 返回的 feed.feed.cell_comment.comments,或后续评论页;保留完整对象。';
const feedExample: JsonObject = {
cell_common: {
union_id: {
uf_key: '<uf_key-from-get_qzone_public_feeds>',
ugc_id: '<ugc_id>',
feed_id: '<feed_id>',
ext_ids: [],
},
cur_like_key: '<like-key>',
domain_type: 3,
},
cell_feed_transfer: {},
cell_user_info: { user: { uin: '10001', uid: '<uid>', nick: '<nick>' } },
};
const commentExample: JsonObject = {
id: '<comment-id-from-detail-or-comments>',
user: { uin: '<uin>', uid: '<uid>', nick: '<nick>' },
like: { id: '<like-id-from-comment>', count: 0, status: 0 },
reply_attach_info: '',
};
const replyExample: JsonObject = {
id: '<reply-id-from-comments-or-replies>',
user: { uin: '<uin>', uid: '<uid>', nick: '<nick>' },
like: { id: '<like-id-from-reply>', count: 0, status: 0 },
};
export const actions = [
defineAction({
name: 'get_qzone_home_feeds',
summary: '读取自己或有权访问的空间主页动态,保留完整分享信息',
readOnly: true,
examples: [
{ summary: '读取自己的主页动态', params: {} },
{
summary: '继续读取同一主页',
params: { target_uin: 10001, cursor: '<next_cursor-from-get_qzone_home_feeds>' },
},
],
returns: '完整动态及分页状态;访问受空间权限限制,旧说说接口保持不变。',
returnsSchema: {
type: 'object',
additionalProperties: true,
properties: {
feeds: {
type: 'array',
items: objectResult,
description: '完整动态,可原样传给 get_qzone_share_ark',
},
has_more: { type: 'boolean' },
next_cursor: { type: ['string', 'null'] },
},
required: ['feeds', 'has_more', 'next_cursor'],
},
params: {
target_uin: f.userId().describe('主页所属 QQ 号,省略则为当前账号').optional(),
cursor: f
.string({ allowEmpty: false, maxLen: 65536 })
.describe('上次返回的 next_cursor,仅限同一账号及主页')
.optional(),
},
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.qzone.getHomeFeeds(p.target_uin, p.cursor)),
}),
defineAction({
name: 'get_qzone_feed_detail',
summary: '按说说 tid 读取完整空间动态,可用于生成分享卡片',
readOnly: true,
examples: [
{
summary: '按说说编号获取完整动态',
params: { tid: '1127000000f1536501000000' },
notes: '将示例 tid 替换为 get_qzone_msg_list 或 send_qzone_msg 返回的 24 位十六进制说说编号。把结果的 feed 原样传给 get_qzone_share_ark;读取和分享均受空间权限限制。',
},
],
returns:
'完整动态及上游详情字段。feed 保留图片、视频、分享信息和未知字段,可直接用于 get_qzone_share_ark;attach_info 保留上游评论游标,不自动翻页。',
returnsSchema: {
type: 'object',
additionalProperties: true,
properties: {
feed: {
type: 'object',
additionalProperties: true,
description: '去除上游包装后的完整动态,可原样传给 get_qzone_share_ark',
},
attach_info: {
type: ['string', 'null'],
description: '上游评论续页游标,未提供时为 null;不保证评论及回复完整',
},
},
required: ['feed', 'attach_info'],
},
params: {
tid: f.string({ allowEmpty: false, maxLen: 24 }).describe('24 位十六进制说说编号'),
},
rules: (r) => [
r.rule('tid must be a 24-character hexadecimal post ID', (p) =>
/^[0-9a-f]{24}$/i.test(p.tid),
),
],
run: async (p, ctx) => okResponse(await ctx.bridge.apis.qzone.getFeedDetail(p.tid)),
}),
defineAction({
name: 'get_qzone_share_ark',
summary: '生成空间动态分享卡片,返回原文及分享提示',
readOnly: false,
examples: [
{
summary: '生成卡片后通过 JSON 消息发送',
params: {
feed: {
...feedExample,
cell_feed_transfer: '<original-cell_feed_transfer>',
cell_operation: {
share_outside: {
share_access_resource: {
busi_id: '<original-busi_id>',
resource_id: '<original-resource_id>',
},
},
},
},
},
notes: 'feed 原样取自 get_qzone_feed_detail 的 feed,或 get_qzone_home_feeds / get_qzone_public_feeds 的 feeds 条目。分享链接可能允许未登录者访问并包含作者 QQ 号;如 requires_confirmation=true,发送前展示 notice 并取得确认。将 ark 原文作为 JSON 消息发送,不要修改卡片内容。',
},
],
returns: '生成卡片但不发送消息;后续发送仍返回普通消息编号。',
returnsSchema: {
type: 'object',
properties: {
ark: { type: 'string', description: '卡片原文,直接作为 JSON 消息的 data' },
requires_confirmation: {
type: 'boolean',
description: '上游要求发送前确认分享提示',
},
notice: { type: ['string', 'null'], description: '上游分享提示;无提示时为 null' },
},
required: ['ark', 'requires_confirmation', 'notice'],
},
params: {
feed: f.object().describe('主页、详情或推荐列表返回的完整动态,保留所有原始字段'),
},
run: async (p, ctx) => okResponse(await ctx.bridge.apis.qzone.getShareArk(p.feed)),
}),
defineAction({
name: 'get_qzone_public_capabilities',
summary: '读取当前账号的空友爱看入口与推权限',
readOnly: true,
examples: [{ summary: '查询当前账号是否有空友爱看入口及推能力', params: {} }],
returns: '当前账号的上游入口能力对象,保留未知字段。',
returnsSchema: objectResult,
params: {},
run: async (_p, ctx) => okResponse(await ctx.bridge.apis.qzone.getPublicCapabilities()),
}),
defineAction({
name: 'get_qzone_public_feeds',
summary: '读取空友爱看推荐动态,支持独立游标分页',
readOnly: true,
examples: [
{ summary: '读取空友爱看推荐首页', params: {} },
{
summary: '继续读取推荐动态',
params: { cursor: '<next_cursor-from-get_qzone_public_feeds>' },
notes: 'cursor 原样使用上次返回的 next_cursor,仅适用于同一账号;has_more=false 时停止。',
},
],
returns: '推荐动态原始 cell 对象及分页状态,保留其余上游页面字段。',
returnsSchema: {
type: 'object',
additionalProperties: true,
properties: {
feeds: {
type: 'array',
items: objectResult,
description: '原始动态 cell 对象,后续详情和互动需原样传入',
},
has_more: { type: 'boolean', description: '服务端是否报告还有下一页' },
next_cursor: {
type: ['string', 'null'],
description: '下一页游标;无下一页时为 null',
},
},
required: ['feeds', 'has_more', 'next_cursor'],
},
params: {
cursor: f
.string({ allowEmpty: false, maxLen: 65536 })
.describe('上次返回的 next_cursor,省略则刷新首页')
.optional(),
},
run: async (p, ctx) => okResponse(await ctx.bridge.apis.qzone.getPublicFeeds(p.cursor)),
}),
defineAction({
name: 'get_qzone_public_feed_detail',
summary: '读取推荐动态详情及初始评论',
readOnly: true,
examples: [
{ summary: '读取推荐动态详情', params: { feed: feedExample }, notes: feedSource },
],
returns:
'上游详情 data;初始评论位于 feed.feed.cell_comment.comments,续页使用 attach_info。',
returnsSchema: objectResult,
params: { feed },
run: async (p, ctx) => okResponse(await ctx.bridge.apis.qzone.getPublicFeedDetail(p.feed)),
}),
defineAction({
name: 'get_qzone_public_comments',
summary: '继续读取推荐动态评论',
readOnly: true,
examples: [
{
summary: '读取详情之后的下一页评论',
params: { feed: feedExample, cursor: '<attach_info-from-detail-or-comments>' },
notes: `${feedSource} 先读详情获取初始评论及 attach_info,再原样传入 cursor。`,
},
],
returns: '上游评论页对象,保留评论、分页和未知字段。',
returnsSchema: objectResult,
params: {
feed,
cursor: f.string({ allowEmpty: false }).describe('详情或上一评论页返回的 attach_info'),
},
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.qzone.getPublicComments(p.feed, p.cursor)),
}),
defineAction({
name: 'get_qzone_public_replies',
summary: '继续读取指定评论的回复',
readOnly: true,
examples: [
{
summary: '读取某条评论的后续回复',
params: { feed: feedExample, comment: commentExample },
notes: `${feedSource} ${commentSource} 首次续读使用评论自带的 reply_attach_info;之后传上一回复页的游标。`,
},
],
returns: '上游回复页对象,保留回复、分页和未知字段。',
returnsSchema: objectResult,
params: {
feed,
comment,
cursor: f
.string()
.describe('上一回复页的 reply_attach_info;省略则使用评论自带游标')
.default(''),
page_size: f.int({ min: 1 }).describe('请求的回复数量').default(20),
},
run: async (p, ctx) =>
okResponse(
await ctx.bridge.apis.qzone.getPublicReplies(
p.feed,
p.comment,
p.cursor,
p.page_size,
),
),
}),
defineAction({
name: 'set_qzone_public_upvote',
summary: '推或取消推空友爱看动态(独立于普通赞)',
readOnly: false,
examples: [
{
summary: '推一条推荐动态',
params: { feed: feedExample, enable: true },
notes: feedSource,
},
],
returns: '上游推操作结果,保留未知字段。',
returnsSchema: objectResult,
params: { feed, enable: f.bool().describe('true 推,false 取消推').default(true) },
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.qzone.setPublicUpvote(p.feed, p.enable)),
}),
defineAction({
name: 'set_qzone_public_like',
summary: '点赞或取消赞推荐动态',
readOnly: false,
examples: [
{
summary: '点赞推荐动态',
params: { feed: feedExample, enable: true },
notes: feedSource,
},
],
returns: '上游普通赞操作结果,保留未知字段。',
returnsSchema: objectResult,
params: { feed, enable: f.bool().describe('true 点赞,false 取消赞').default(true) },
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.qzone.setPublicLike(p.feed, p.enable)),
}),
defineAction({
name: 'set_qzone_public_comment_like',
summary: '点赞或取消赞推荐动态中的评论或回复',
readOnly: false,
examples: [
{
summary: '点赞一条评论',
params: { feed: feedExample, comment: commentExample },
notes: `${feedSource} ${commentSource} 必须保留评论原始 like.id。`,
},
{
summary: '取消赞某条回复',
params: {
feed: feedExample,
comment: commentExample,
reply: replyExample,
enable: false,
},
notes: `${feedSource} reply 使用该评论下的完整回复对象,保留其原始 like.id。`,
},
],
returns: '上游普通赞操作结果,保留未知字段。',
returnsSchema: objectResult,
params: {
feed,
comment,
enable: f.bool().describe('true 点赞,false 取消赞').default(true),
reply: reply.optional(),
},
run: async (p, ctx) =>
okResponse(
await ctx.bridge.apis.qzone.setPublicCommentLike(
p.feed,
p.comment,
p.enable,
p.reply,
),
),
}),
defineAction({
name: 'comment_qzone_public_feed',
summary: '评论推荐动态,支持文本或上游富文本条目',
readOnly: false,
examples: [
{
summary: '评论推荐动态',
params: { feed: feedExample, content: '感谢分享' },
notes: feedSource,
},
],
returns: '上游评论结果,包含新评论,保留未知字段。',
returnsSchema: objectResult,
params: { feed, content },
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.qzone.commentPublicFeed(p.feed, p.content)),
}),
defineAction({
name: 'reply_qzone_public_feed',
summary: '回复推荐动态的评论或其中某条回复',
readOnly: false,
examples: [
{
summary: '回复一条评论',
params: { feed: feedExample, comment: commentExample, content: '感谢回复' },
notes: `${feedSource} ${commentSource}`,
},
{
summary: '回复评论中的某条回复',
params: {
feed: feedExample,
comment: commentExample,
content: [{ type: 0, content: '我也这么认为' }],
reply: replyExample,
},
notes: `${feedSource} ${commentSource} reply 使用该评论下的完整回复对象。`,
},
],
returns: '上游回复结果,包含新回复,保留未知字段。',
returnsSchema: objectResult,
params: { feed, comment, content, reply: reply.optional() },
run: async (p, ctx) =>
okResponse(
await ctx.bridge.apis.qzone.replyPublicFeed(p.feed, p.comment, p.content, p.reply),
),
}),
defineAction({
name: 'delete_qzone_public_comment',
summary: '删除推荐动态的一条评论(需有权限)',
readOnly: false,
examples: [
{
summary: '删除一条评论',
params: { feed: feedExample, comment: commentExample },
notes: `${feedSource} ${commentSource}`,
},
],
returns: '上游删除结果,保留未知字段。',
returnsSchema: objectResult,
params: { feed, comment },
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.qzone.deletePublicComment(p.feed, p.comment)),
}),
defineAction({
name: 'delete_qzone_public_reply',
summary: '删除推荐动态评论下的一条回复(需有权限)',
readOnly: false,
examples: [
{
summary: '删除一条回复',
params: { feed: feedExample, comment: commentExample, reply: replyExample },
notes: `${feedSource} ${commentSource} reply 使用该评论下的完整回复对象。`,
},
],
returns: '上游删除结果,保留未知字段。',
returnsSchema: objectResult,
params: { feed, comment, reply },
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.qzone.deletePublicReply(p.feed, p.comment, p.reply)),
}),
defineAction({
name: 'set_qzone_public_follow',
summary: '蹲或取消蹲推荐动态作者',
readOnly: false,
examples: [
{
summary: '蹲这条推荐动态的作者',
params: { feed: feedExample, enable: true },
notes: `${feedSource} feed 需保留作者 cell_user_info。`,
},
],
returns: '上游关系操作结果,保留未知字段。',
returnsSchema: objectResult,
params: { feed, enable: f.bool().describe('true 蹲,false 取消蹲').default(true) },
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.qzone.setPublicFollow(p.feed, p.enable)),
}),
defineAction({
name: 'forward_qzone_public_feed',
summary: '将推荐动态转发到当前账号空间',
readOnly: false,
examples: [
{
summary: '转发推荐动态到我的空间',
params: { feed: feedExample, reason: '分享这条动态' },
notes: `${feedSource} 只转发,不同时评论或发送给 QQ 联系人。`,
},
],
returns: '上游转发结果,保留未知字段。',
returnsSchema: objectResult,
params: { feed, reason: f.string().describe('转发附言,可为空').default('') },
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.qzone.forwardPublicFeed(p.feed, p.reason)),
}),
];
+764
View File
@@ -0,0 +1,764 @@
import { defineAction, f } from '../action-kit';
import type { ApiActionContext } from '../api-handler';
import type { JsonValue } from '../types';
import { okResponse } from '../types';
async function uploadQzoneImages(
ctx: ApiActionContext,
images: string[],
select: (upload: { richval: string; url: string }) => string,
): Promise<string> {
const parts: string[] = [];
for (let i = 0; i < images.length; i++) {
try {
const upload = await ctx.bridge.apis.qzone.uploadImageFromSource(images[i]);
parts.push(select(upload));
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown error';
throw new Error(`第 ${i + 1} 张图片上传失败: ${message}`);
}
}
return parts.join('\t');
}
const QZONE_UGC_RIGHTS = new Set<number>([1, 4, 16, 64, 128]);
const qzoneCommentProperties = {
uin: { type: 'integer', description: '评论者 QQ 号' },
name: { type: 'string', description: '评论者昵称' },
time: { type: 'integer', description: '评论时间(unix 秒)' },
content: { type: 'string', description: '评论正文,保留空间表情和 @ 标记' },
comment_id: { type: 'string', description: '评论 ID,在所属说说或父评论内唯一' },
};
const qzoneCommentRequired = ['uin', 'name', 'time', 'content', 'comment_id'];
export const actions = [
defineAction({
name: 'get_qzone_profile',
examples: [
{
summary: '获取当前账号的空间名称和说明',
params: {},
},
{
summary: '获取指定账号的空间名称和说明',
params: { target_uin: 10001 },
notes: '需要有该空间的访问权限。',
},
],
readOnly: true,
summary: '获取 QQ 空间名称和说明(默认当前账号)',
returns: '空间名称和说明。',
returnsSchema: {
type: 'object',
properties: {
name: { type: 'string', description: '空间名称' },
description: { type: 'string', description: '空间说明' },
},
required: ['name', 'description'],
},
params: {
target_uin: f.userId().describe('目标 QQ 号,省略则取机器人自己').optional(),
},
run: async (p, ctx) => okResponse(await ctx.bridge.apis.qzone.getProfile(p.target_uin)),
}),
defineAction({
name: 'set_qzone_profile',
examples: [
{
summary: '修改当前账号的空间名称和说明',
params: { name: '我的空间', description: '记录日常' },
notes: '仅修改提供的字段;空字符串表示清空对应字段。',
},
{
summary: '清空当前账号的空间说明',
params: { description: '' },
},
],
readOnly: false,
summary: '修改当前账号的 QQ 空间名称或说明',
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
params: {
name: f.string().describe('新的空间名称,空字符串表示清空,省略则保留').optional(),
description: f
.string()
.describe('新的空间说明,空字符串表示清空,省略则保留')
.optional(),
},
rules: (r) => [r.atLeastOneOf('name', 'description')],
run: async (p, ctx) => {
await ctx.bridge.apis.qzone.setProfile(p);
return okResponse(null);
},
}),
// get_qzone_msg_list — 获取 QQ 空间说说列表。无 go-cqhttp 标准,自定义命名。
// 默认取机器人自己的空间;传 target_uin 可查看指定账号(需有权限)。
// 复用 qzone.qq.com 的 cookie/g_tk 基建(与群相册同源),纯 web,无 trpc。
defineAction({
name: 'get_qzone_msg_list',
examples: [
{
summary: '获取 QQ 空间说说列表(默认机器人自己的空间)',
params: {
pos: 0,
num: 20,
},
notes: '默认读取当前账号;指定 target_uin 时需有访问权限。',
},
],
readOnly: true,
returns: '说说列表对象,含说说总数与本页说说数组。',
returnsSchema: {
type: 'object',
properties: {
total: { type: 'integer', description: '账号说说总数(非本页数量)' },
msglist: {
type: 'array',
description: '本页说说数组',
items: {
type: 'object',
properties: {
tid: {
type: 'string',
description: '说说 id(delete/comment/like 的句柄)',
},
content: { type: 'string', description: '说说正文' },
time: { type: 'integer', description: '发表时间(unix 秒)' },
comment_num: { type: 'integer', description: '评论数' },
is_private: { type: 'boolean', description: '是否仅自己可见' },
images: {
type: 'array',
items: { type: 'string' },
description: '图片 URL 列表(每图取最大可用变体)',
},
},
required: ['tid', 'content', 'time', 'comment_num', 'is_private', 'images'],
},
},
},
required: ['total', 'msglist'],
},
summary: '获取 QQ 空间说说列表(默认机器人自己的空间)',
params: {
target_uin: f.userId().describe('目标 QQ 号,省略则取机器人自己').optional(),
pos: f.int({ min: 0 }).describe('起始偏移').default(0),
num: f.int({ min: 1, max: 100 }).describe('本页数量').default(20),
},
run: async (p, ctx) => {
const res = await ctx.bridge.apis.qzone.getMsgList(p.target_uin, p.pos, p.num);
return okResponse(res as unknown as JsonValue);
},
}),
defineAction({
name: 'get_qzone_msg_detail',
examples: [
{
summary: '获取 QQ 空间说说详情与一页评论(含服务端内联回复,需有访问权限)',
params: {
tid: '<tid-from-get_qzone_msg_list>',
pos: 0,
num: 20,
},
notes: 'tid 来自 get_qzone_msg_list;操作需有该说说的访问或管理权限。 replies 仅为服务端内联回复,不保证完整。',
},
],
readOnly: true,
summary: '获取 QQ 空间说说详情与一页评论(含服务端内联回复,需有访问权限)',
returns:
'说说详情、服务端报告的评论总数与本页评论;replies 仅含服务端内联返回的二级回复,不保证完整。',
returnsSchema: {
type: 'object',
properties: {
tid: { type: 'string', description: '说说 ID' },
content: { type: 'string', description: '说说正文' },
comment_num: { type: 'integer', description: '服务端报告的评论总数(非本页数量)' },
comments: {
type: 'array',
description: '本页一级评论,数量可能少于 num',
items: {
type: 'object',
properties: {
...qzoneCommentProperties,
replies: {
type: 'array',
description: '服务端内联二级回复,可能不完整',
items: {
type: 'object',
properties: {
...qzoneCommentProperties,
parent_comment_id: {
type: 'string',
description: '所属一级评论 ID;回复时作为 comment_id',
},
parent_comment_uin: {
type: 'integer',
description: '所属一级评论作者;回复时作为 comment_uin',
},
},
required: [
...qzoneCommentRequired,
'parent_comment_id',
'parent_comment_uin',
],
},
},
},
required: [...qzoneCommentRequired, 'replies'],
},
},
},
required: ['tid', 'content', 'comment_num', 'comments'],
},
params: {
tid: f
.string({ allowEmpty: false })
.describe('说说 tid(来自 get_qzone_msg_list / send_qzone_msg)'),
target_uin: f.userId().describe('说说所属 QQ 号,省略则取机器人自己').optional(),
pos: f.int({ min: 0 }).describe('一级评论起始偏移').default(0),
num: f
.int({ min: 1, max: 100 })
.describe('请求的一级评论数量,实际数量受服务端限制')
.default(20),
},
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.qzone.getMsgDetail(p.tid, p.target_uin, p.pos, p.num)),
}),
// get_qzone_feeds — 获取好友动态(feed)。只读,始终以机器人自己身份拉取。
// 保留旧 HTML,同时提供正文摘要与服务端分页接续。
defineAction({
name: 'get_qzone_feeds',
examples: [
{
summary: '获取 QQ 空间好友动态首页及结构化内容',
params: {
page_num: 1,
count: 10,
},
notes: '保留 html;content/images 为可提取的正文和图片,未知值为 null。续页使用 next_cursor。',
},
{
summary: '继续读取上一页之后的好友动态',
params: {
cursor: '<next_cursor-from-get_qzone_feeds>',
count: 10,
},
notes: 'cursor 原样使用上次返回的 next_cursor,仅适用于同一账号;has_more=false 时停止。',
},
],
readOnly: true,
returns:
'好友动态、可提取的正文与图片、统计值及下一页游标;未知内容或统计为 null,HTML 保留。',
returnsSchema: {
type: 'object',
properties: {
feeds: {
type: 'array',
description: '本页好友动态数组',
items: {
type: 'object',
properties: {
uin: { type: 'integer', description: '动态作者 QQ 号' },
nickname: { type: 'string', description: '作者昵称' },
time: { type: 'integer', description: '发表时间(unix 秒)' },
appid: {
type: 'integer',
description: 'Qzone 应用 id(311=说说,4=相册,…)',
},
key: {
type: 'string',
description: 'feed 句柄(Qzone 用于定位该条动态)',
},
html: { type: 'string', description: '预渲染 HTML 原样透传' },
tid: {
type: ['string', 'null'],
description: '可确认的说说编号;其他类型或无法确认时为 null',
},
content: {
type: ['string', 'null'],
description: '当前动态正文,不含评论;无法识别时为 null',
},
content_complete: {
type: ['boolean', 'null'],
description: '正文是否完整;false 表示折叠摘要,null 表示未知',
},
images: {
type: ['array', 'null'],
items: { type: 'string' },
description:
'当前正文中可提取的图片地址,不含头像、评论图片或视频封面;不保证完整相册',
},
like_count: {
type: ['integer', 'null'],
description: '服务端提供的点赞数;未知时为 null',
},
comment_count: {
type: ['integer', 'null'],
description:
'服务端提供的评论数;未知时为 null,不以展示的评论条数估算',
},
},
required: [
'uin',
'nickname',
'time',
'appid',
'key',
'html',
'tid',
'content',
'content_complete',
'images',
'like_count',
'comment_count',
],
},
},
has_more: { type: 'boolean', description: '服务端是否报告本页之后还有更多页' },
next_cursor: {
type: ['string', 'null'],
description: '下一页游标,原样作为 cursor 传回;没有更多页时为 null',
},
},
required: ['feeds', 'has_more', 'next_cursor'],
},
summary: '获取 QQ 空间好友动态,支持结构化正文与游标分页',
params: {
page_num: f
.int({ min: 1 })
.describe('页码(1 起);不传 cursor 时从首页顺序定位,较深页可能耗时较长')
.default(1),
count: f.int({ min: 1, max: 50 }).describe('本页数量').default(10),
cursor: f
.string({ allowEmpty: false, maxLen: 65536 })
.describe('上一次返回的 next_cursor;原样传递,省略 page_num 或传入对应页码')
.optional(),
},
run: async (p, ctx) => {
const res = await ctx.bridge.apis.qzone.getFeeds(p.page_num, p.count, p.cursor);
return okResponse(res as unknown as JsonValue);
},
}),
// send_qzone_msg — 发表说说(纯文字或带图)。写操作,发到机器人自己的空间。
// 传入 images 数组,自动上传并发布。
// 返回新说说的 tid(供后续 delete/comment/like 使用)。
// 注意:发说说为主动行为,高频会被 Qzone 风控,调用方需自行限流。
defineAction({
name: 'send_qzone_msg',
examples: [
{
summary:
'发表说说(QQ 空间,支持纯文字或带图;传 images 自动上传;可设置查看权限)',
params: {
content: '今天的学习记录',
ugc_right: 64,
},
notes: '会发布说说;ugc_right=64 仅自己可见。16/128 还必须提供 target_uins。',
},
],
returnsSchema: {
type: 'object',
properties: {
tid: {
type: 'string',
},
time: {
type: 'integer',
},
},
required: ['tid', 'time'],
},
summary: '发表说说(QQ 空间,支持纯文字或带图;传 images 自动上传;可设置查看权限)',
params: {
content: f.string({ allowEmpty: false }).describe('说说正文'),
images: f
.array(f.string({ allowEmpty: false }))
.describe('图片数组(可选),支持 file:// http:// base64://;自动上传')
.optional(),
ugc_right: f
.int({ min: 1 })
.describe(
'查看权限:1=所有人可见,4=好友可见,16=部分好友可见,64=仅自己可见,128=部分好友不可见',
)
.default(1),
target_uins: f
.array(f.uint())
.describe('权限作用 QQ 号数组;ugc_right=16 时表示可见名单,128 时表示不可见名单')
.optional(),
},
rules: (r) => [
r.rule('ugc_right must be one of 1, 4, 16, 64, 128', (p) =>
QZONE_UGC_RIGHTS.has(p.ugc_right),
),
r.rule(
'target_uins is required when ugc_right is 16 or 128',
(p) => (p.ugc_right !== 16 && p.ugc_right !== 128) || !!p.target_uins?.length,
),
],
run: async (p, ctx) => {
if (p.images && p.images.length > 0) {
const richval = await uploadQzoneImages(ctx, p.images, (upload) => upload.richval);
const res = await ctx.bridge.apis.qzone.publish(
p.content,
1,
richval,
p.ugc_right,
p.target_uins?.join('|'),
);
return okResponse(res as unknown as JsonValue);
}
const res = await ctx.bridge.apis.qzone.publish(
p.content,
undefined,
undefined,
p.ugc_right,
p.target_uins?.join('|'),
);
return okResponse(res as unknown as JsonValue);
},
}),
// delete_qzone_msg — 删除机器人自己空间的一条说说(按 tid)。写操作。
defineAction({
name: 'delete_qzone_msg',
examples: [
{
summary: '删除一条说说(QQ 空间,按 tid)',
params: {
tid: '<tid-from-get_qzone_msg_list>',
},
notes: 'tid 来自 get_qzone_msg_list;操作需有该说说的访问或管理权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '删除一条说说(QQ 空间,按 tid)',
params: {
tid: f
.string({ allowEmpty: false })
.describe('说说 tid(来自 get_qzone_msg_list / send_qzone_msg)'),
},
run: async (p, ctx) => {
await ctx.bridge.apis.qzone.delete(p.tid);
return okResponse(null);
},
}),
// set_qzone_msg_right — 修改机器人自己空间一条已发说说的查看权限(按 tid)。
// 写操作;ugc_right/target_uins 语义与 send_qzone_msg 一致。
defineAction({
name: 'set_qzone_msg_right',
examples: [
{
summary: '修改一条已发说说的查看权限(QQ 空间,按 tid)',
params: {
tid: '<tid-from-get_qzone_msg_list>',
ugc_right: 16,
target_uins: [10001],
},
notes: 'tid 来自 get_qzone_msg_list;操作需有该说说的访问或管理权限。 16 表示仅所选好友可见。',
},
],
returns: '更新后的权限对象。',
returnsSchema: {
type: 'object',
properties: {
ugc_right: { type: 'integer', description: '更新后的查看权限' },
},
required: ['ugc_right'],
},
summary: '修改一条已发说说的查看权限(QQ 空间,按 tid)',
params: {
tid: f
.string({ allowEmpty: false })
.describe('说说 tid(来自 get_qzone_msg_list / send_qzone_msg)'),
ugc_right: f
.int({ min: 1 })
.describe(
'查看权限:1=所有人可见,4=好友可见,16=部分好友可见,64=仅自己可见,128=部分好友不可见',
),
target_uins: f
.array(f.uint())
.describe('权限作用 QQ 号数组;ugc_right=16 时表示可见名单,128 时表示不可见名单')
.optional(),
},
rules: (r) => [
r.rule('ugc_right must be one of 1, 4, 16, 64, 128', (p) =>
QZONE_UGC_RIGHTS.has(p.ugc_right),
),
r.rule(
'target_uins is required when ugc_right is 16 or 128',
(p) => (p.ugc_right !== 16 && p.ugc_right !== 128) || !!p.target_uins?.length,
),
],
run: async (p, ctx) => {
const res = await ctx.bridge.apis.qzone.updateRight(
p.tid,
p.ugc_right,
p.target_uins?.join('|'),
);
return okResponse(res as unknown as JsonValue);
},
}),
// like_qzone — 给一条说说点赞。target_uin 省略=机器人自己空间;点赞好友说说传其 uin。
// abstime=该说说的发表时间(unix 秒,来自 get_qzone_feeds/get_qzone_msg_list),
// 传真实值更可靠;不传按 0。写操作;高频点赞会被 Qzone 风控,调用方需限流。
defineAction({
name: 'like_qzone',
examples: [
{
summary: '给一条说说点赞(QQ 空间)',
params: {
tid: '<tid-from-get_qzone_msg_list>',
target_uin: 10001,
},
notes: 'tid 来自 get_qzone_msg_list;操作需有该说说的访问或管理权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '给一条说说点赞(QQ 空间)',
params: {
tid: f.string({ allowEmpty: false }).describe('说说 tid'),
target_uin: f.userId().describe('说说所属 QQ 号,省略则为机器人自己').optional(),
abstime: f
.int({ min: 0 })
.describe('说说发表时间(unix 秒),传真实值更可靠')
.default(0)
.role('timestamp'),
},
run: async (p, ctx) => {
await ctx.bridge.apis.qzone.like(p.tid, p.target_uin, true, p.abstime);
return okResponse(null);
},
}),
// unlike_qzone — 取消对一条说说的点赞。参数同 like_qzone。写操作。
// 注意:取消赞端点(internal_unlike_app)暂未经真机核实,待抓包确认。
defineAction({
name: 'unlike_qzone',
examples: [
{
summary: '取消对一条说说的点赞(QQ 空间;取消赞端点待真机核实)',
params: {
tid: '<tid-from-get_qzone_msg_list>',
target_uin: 10001,
},
notes: 'tid 来自 get_qzone_msg_list;操作需有该说说的访问或管理权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '取消对一条说说的点赞(QQ 空间;取消赞端点待真机核实)',
params: {
tid: f.string({ allowEmpty: false }).describe('说说 tid'),
target_uin: f.userId().describe('说说所属 QQ 号,省略则为机器人自己').optional(),
abstime: f
.int({ min: 0 })
.describe('说说发表时间(unix 秒),传真实值更可靠')
.default(0)
.role('timestamp'),
},
run: async (p, ctx) => {
await ctx.bridge.apis.qzone.like(p.tid, p.target_uin, false, p.abstime);
return okResponse(null);
},
}),
// comment_qzone — 评论一条说说。target_uin=说说所属 QQ 号(省略=机器人自己空间)。
// 传入 images 数组,自动上传获取直链(多图用 tab 拼接)。
// 写操作;高频评论会被 Qzone 风控,调用方需限流。
defineAction({
name: 'comment_qzone',
examples: [
{
summary: '评论一条说说(QQ 空间,支持纯文字或带图;传 images 自动上传)',
params: {
tid: '<tid-from-get_qzone_msg_list>',
target_uin: 10001,
content: '感谢分享',
},
notes: 'tid 来自 get_qzone_msg_list;操作需有该说说的访问或管理权限。',
},
{
summary: '回复指定一级评论,保留说说所属账号',
params: {
tid: '<tid-from-get_qzone_msg_detail>',
target_uin: 10001,
content: '感谢回复',
comment_id: '0',
comment_uin: 10002,
},
notes: 'comment_id 和 comment_uin 分别取 get_qzone_msg_detail 的一级评论 comment_id 和 uin;编号 0 有效。楼中楼仅支持文字。',
},
{
summary: '回复楼中楼中的某位评论者',
params: {
tid: '<tid-from-get_qzone_msg_detail>',
target_uin: 10001,
content: '我也这么认为',
comment_id: '0',
comment_uin: 10002,
reply_uin: 10003,
reply_name: '评论者',
},
notes: 'comment_id/comment_uin 使用该回复的 parent_comment_id/parent_comment_uin;reply_uin/reply_name 使用该回复的 uin/name,不把二级回复自身的 comment_id 当成父评论编号。',
},
],
returnsSchema: {
type: 'object',
properties: {
comment_id: {
type: 'string',
description:
'新评论或新回复的 ID;普通评论未返回 ID 时为空串,楼中楼回复不会用父编号代替',
},
parent_comment_id: {
type: 'string',
description: '仅楼中楼回复返回,所属一级评论 ID',
},
parent_comment_uin: {
type: 'integer',
description: '仅楼中楼回复返回,所属一级评论作者 QQ 号',
},
},
required: ['comment_id'],
},
summary: '评论说说或回复指定评论(普通评论支持带图,楼中楼仅支持文字)',
params: {
tid: f.string({ allowEmpty: false }).describe('说说 tid'),
content: f.string({ allowEmpty: false }).describe('评论内容'),
target_uin: f.userId().describe('说说所属 QQ 号,省略则为机器人自己').optional(),
comment_id: f
.string({ allowEmpty: false })
.describe(
'一级父评论 ID,与 comment_uin 同时提供;编号 0 有效,不能传二级回复自身的 ID',
)
.optional(),
comment_uin: f
.userId()
.describe('一级父评论作者 QQ 号,与 comment_id 同时提供;不是说说所属账号')
.optional(),
reply_uin: f
.userId()
.describe('楼中楼中要回复的用户 QQ 号,与 reply_name 同时提供,需指定父评论')
.optional(),
reply_name: f
.string({ allowEmpty: false })
.describe('要回复的用户昵称,来自评论详情的 name;与 reply_uin 同时提供')
.optional(),
images: f
.array(f.string({ allowEmpty: false }))
.describe('图片数组(可选),支持 file:// http:// base64://;自动上传')
.optional(),
},
rules: (r) => [
r.requiredTogether('comment_id', 'comment_uin'),
r.requiredTogether('reply_uin', 'reply_name'),
r.rule(
'reply_uin/reply_name require comment_id/comment_uin',
(p) => p.reply_uin === undefined || p.comment_id !== undefined,
),
r.rule(
'images are not supported for comment replies',
(p) => p.comment_id === undefined || !p.images?.length,
),
r.rule(
'comment_id, reply_name and reply content must not be blank',
(p) =>
(p.comment_id === undefined ||
(p.comment_id.trim().length > 0 && p.content.trim().length > 0)) &&
(p.reply_name === undefined || p.reply_name.trim().length > 0),
),
r.rule(
'comment_uin and reply_uin must be safe integers',
(p) =>
(p.comment_uin === undefined || Number.isSafeInteger(p.comment_uin)) &&
(p.reply_uin === undefined || Number.isSafeInteger(p.reply_uin)),
),
],
run: async (p, ctx) => {
if (p.comment_id !== undefined && p.comment_uin !== undefined) {
return okResponse(
await ctx.bridge.apis.qzone.comment(
p.tid,
p.content,
p.target_uin,
undefined,
undefined,
{
commentId: p.comment_id,
commentUin: String(p.comment_uin),
...(p.reply_uin !== undefined && p.reply_name !== undefined
? { replyTo: { uin: String(p.reply_uin), name: p.reply_name } }
: {}),
},
),
);
}
if (p.images && p.images.length > 0) {
const richval = await uploadQzoneImages(ctx, p.images, (upload) => upload.url);
const res = await ctx.bridge.apis.qzone.comment(
p.tid,
p.content,
p.target_uin,
1,
richval,
);
return okResponse(res as unknown as JsonValue);
}
const res = await ctx.bridge.apis.qzone.comment(p.tid, p.content, p.target_uin);
return okResponse(res as unknown as JsonValue);
},
}),
// set_qzone_ban — 拉黑/解除拉黑某人(修改机器人自身 QQ 空间黑名单)。写操作。
// enable=true 拉黑,enable=false 解除拉黑。
defineAction({
name: 'set_qzone_ban',
examples: [
{
summary:
'拉黑或解除拉黑某人(修改机器人自身 QQ 空间黑名单;enable=true 拉黑,false 解除)',
params: {
user_id: 10001,
enable: true,
},
notes: '修改当前账号空间黑名单。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '拉黑或解除拉黑某人(修改机器人自身 QQ 空间黑名单;enable=true 拉黑,false 解除)',
params: {
user_id: f.userId().describe('目标 QQ 号'),
enable: f.bool().describe('true 拉黑,false 解除拉黑').default(true),
},
run: async (p, ctx) => {
await ctx.bridge.apis.qzone.setBlack(p.user_id, p.enable);
return okResponse(null);
},
}),
];
+66
View File
@@ -0,0 +1,66 @@
import { defineAction, f } from '../action-kit';
import { okResponse } from '../types';
export const actions = [
defineAction({
name: 'set_friend_add_request',
examples: [
{
summary: '处理好友添加请求',
params: {
flag: '<flag-from-friend-request-event>',
approve: true,
},
notes: 'flag 必须原样使用当前账号好友请求事件的 flag,不能根据 QQ 号构造。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '处理好友添加请求',
params: {
flag: f.string({ allowEmpty: false }),
approve: f.bool().default(true),
},
run: async (p, ctx) => {
await ctx.bridge.apis.friend.handleRequest(p.flag, p.approve);
return okResponse();
},
}),
defineAction({
name: 'set_group_add_request',
examples: [
{
summary: '处理加群请求',
params: {
flag: '<flag-from-group-request-event>',
sub_type: 'add',
approve: true,
},
notes: 'flag 和 sub_type 原样取自同一条群请求事件;邀请事件使用 invite。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '处理加群请求',
params: {
// Opaque handle from the original request event; callers must echo it.
flag: f.string({ allowEmpty: false }),
// sub_type → type → 'add'. Empty string is kept, not treated as missing.
sub_type: f.string().optional(),
type: f.string().optional(),
approve: f.bool().default(true),
reason: f.string().default(''),
},
run: async (p, ctx) => {
const subType =
p.sub_type !== undefined ? p.sub_type : p.type !== undefined ? p.type : 'add';
await ctx.handleGroupRequest(p.flag, subType, p.approve, p.reason);
return okResponse();
},
}),
];
@@ -0,0 +1,769 @@
// Stream API download actions (#163) — NapCat-compatible server-push streaming.
//
// download_file_stream / _image_stream / _record_stream answer one request with
// many frames: a `file_info` header, then N `file_chunk` frames (base64), then a
// terminal `file_complete`. The bytes come from one of three sources, resolved
// from the `file` arg:
// • a local file UNDER the stream temp root (e.g. something a client just
// pushed via upload_file_stream) — streamed straight off disk;
// • an http(s) URL — fetched and re-streamed;
// • a cached image/voice id from a received message — resolved to its QQ CDN
// URL, then fetched.
// test_download_stream pushes 10 canned frames so a client can verify the
// streaming transport without touching QQ.
//
// Hardening beyond the NapCat original (which streams ANY local path and
// fetches ANY URL): local reads are fenced inside the stream temp root (no
// arbitrary-file read via a leaked token), and URL fetches are SSRF-guarded
// (no private/loopback/link-local targets) + size-capped.
import fs from 'fs';
import path from 'path';
import net from 'net';
import dns from 'dns/promises';
import { resolveLocalFilePath } from '@snowluma/protocol/highway/utils';
import { defineStreamAction, f } from '../action-kit';
import type { ApiActionContext } from '../api-handler';
import { okResponse, type ApiResponse, type JsonObject, type JsonValue } from '../types';
import { type StreamSink, StreamStatus } from '../streaming';
import { STREAM_ROOT, ensureStreamDirectory, registerActiveStreamItem } from '../stream-storage';
const downloadFrameSchema = {
oneOf: [
{
type: 'object',
properties: {
type: {
const: 'stream',
},
data_type: {
const: 'file_info',
},
file_name: {
type: 'string',
},
file_size: {
type: 'integer',
description: '字节数,0 可表示未知',
},
chunk_size: {
type: 'integer',
},
},
required: ['type', 'data_type', 'file_name', 'file_size', 'chunk_size'],
},
{
type: 'object',
properties: {
type: {
const: 'stream',
},
data_type: {
const: 'file_chunk',
},
index: {
type: 'integer',
},
data: {
type: 'string',
description: '该分块的 Base64 数据',
},
size: {
type: 'integer',
},
progress: {
type: 'number',
minimum: 0,
maximum: 100,
},
base64_size: {
type: 'integer',
},
},
required: ['type', 'data_type', 'index', 'data', 'size', 'progress', 'base64_size'],
},
{
type: 'object',
properties: {
type: {
const: 'response',
},
data_type: {
const: 'file_complete',
},
file_name: {
type: 'string',
},
total_chunks: {
type: 'integer',
},
total_bytes: {
type: 'integer',
},
message: {
type: 'string',
},
},
required: ['type', 'data_type', 'file_name', 'total_chunks', 'total_bytes', 'message'],
},
],
description:
'每个成功流式帧的 data;失败帧另由 status/retcode/wording 与 type=error 表示,不是 MCP 保存文件后的工具结果',
};
const DEFAULT_CHUNK_BYTES = 64 * 1024;
const MAX_CHUNK_REQUEST_BYTES = 16 * 1024 * 1024; // cap the client-requested chunk size
const MAX_DOWNLOAD_BYTES = 4 * 1024 * 1024 * 1024; // 4 GiB total
const FETCH_CONNECT_TIMEOUT_MS = 30_000; // TTFB only — cleared once headers arrive (not the whole transfer)
const IDLE_TIMEOUT_MS = 60_000; // abort if no chunk arrives for this long (anti-stall)
const MAX_REDIRECTS = 5;
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';
class DownloadError extends Error {}
// ─────────────────────────── source resolution ───────────────────────────
function isWithinStreamRoot(p: string): boolean {
// Resolve the root through any symlinks too (e.g. macOS /tmp → /private/tmp),
// so a realpath'd target compares against a realpath'd root — otherwise a
// legitimate file reads as "outside" purely from the tmpdir symlink.
let root = path.resolve(STREAM_ROOT);
try {
root = fs.realpathSync(STREAM_ROOT);
} catch {
/* root may not exist yet */
}
const rel = path.relative(root, path.resolve(p));
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}
function isPrivateV4(o: number[]): boolean {
return (
o[0] === 0 ||
o[0] === 127 ||
o[0] === 10 ||
(o[0] === 192 && o[1] === 168) ||
(o[0] === 172 && o[1] >= 16 && o[1] <= 31) ||
(o[0] === 169 && o[1] === 254) || // link-local
(o[0] === 100 && o[1] >= 64 && o[1] <= 127) // CGNAT
);
}
/** Parse an IPv6 string (any form, incl. `::` compression and embedded IPv4)
* to its 16 bytes. `new URL().hostname` normalises v4-mapped addresses to hex
* (`::ffff:7f00:1`), so string matching is unreliable — we work on bytes. */
function ipv6ToBytes(input: string): Uint8Array | null {
let s = input.replace(/^\[|\]$/g, '');
if (net.isIP(s) !== 6) {
return null;
}
const v4m = /:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(s); // embedded IPv4 tail
if (v4m) {
const o = v4m[1].split('.').map(Number);
if (o.some((n) => n > 255)) {
return null;
}
s =
s.slice(0, v4m.index) +
':' +
`${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`;
}
const halves = s.split('::');
if (halves.length > 2) {
return null;
}
const head = halves[0] ? halves[0].split(':') : [];
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
let groups: string[];
if (halves.length === 2) {
const missing = 8 - head.length - tail.length;
if (missing < 0) {
return null;
}
groups = [...head, ...new Array(missing).fill('0'), ...tail];
} else {
groups = head;
}
if (groups.length !== 8) {
return null;
}
const bytes = new Uint8Array(16);
for (let i = 0; i < 8; i++) {
const g = parseInt(groups[i] || '0', 16);
if (Number.isNaN(g) || g < 0 || g > 0xffff) {
return null;
}
bytes[i * 2] = g >> 8;
bytes[i * 2 + 1] = g & 0xff;
}
return bytes;
}
function isPrivateIp(ip: string): boolean {
const host = ip.replace(/^\[|\]$/g, '');
const fam = net.isIP(host);
if (fam === 4) {
return isPrivateV4(host.split('.').map(Number));
}
if (fam === 6) {
const b = ipv6ToBytes(host);
if (!b) {
return true;
} // unparseable IPv6 → treat as unsafe
if (b.every((x) => x === 0)) {
return true;
} // ::
if (b.slice(0, 15).every((x) => x === 0) && b[15] === 1) {
return true;
} // ::1
if ((b[0] & 0xfe) === 0xfc) {
return true;
} // fc00::/7 unique-local
if (b[0] === 0xfe && (b[1] & 0xc0) === 0x80) {
return true;
} // fe80::/10 link-local
// v4-mapped ::ffff:a.b.c.d / v4-compatible ::a.b.c.d
if (
b.slice(0, 10).every((x) => x === 0) &&
((b[10] === 0xff && b[11] === 0xff) || (b[10] === 0 && b[11] === 0))
) {
return isPrivateV4([b[12], b[13], b[14], b[15]]);
}
// NAT64 64:ff9b::/96
if (
b[0] === 0x00 &&
b[1] === 0x64 &&
b[2] === 0xff &&
b[3] === 0x9b &&
b.slice(4, 12).every((x) => x === 0)
) {
return isPrivateV4([b[12], b[13], b[14], b[15]]);
}
return false;
}
return false;
}
/** Parse + SSRF-guard a URL: http(s) only, and neither the literal host nor any
* DNS-resolved address may be private/loopback/link-local. Not bulletproof
* against DNS rebinding, but blocks the obvious metadata-endpoint / internal
* service reach. */
async function assertSafeUrl(raw: string): Promise<URL> {
let u: URL;
try {
u = new URL(raw);
} catch {
throw new DownloadError('invalid URL');
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
throw new DownloadError('only http(s) URLs are allowed');
}
const host = u.hostname.replace(/^\[|\]$/g, '');
if (net.isIP(host)) {
if (isPrivateIp(host)) {
throw new DownloadError('refusing to fetch a private/loopback address');
}
return u;
}
let addrs: Array<{ address: string }>;
try {
addrs = await dns.lookup(host, { all: true });
} catch {
throw new DownloadError(`cannot resolve host: ${host}`);
}
for (const a of addrs) {
if (isPrivateIp(a.address)) {
throw new DownloadError('refusing to fetch a host that resolves to a private address');
}
}
return u;
}
interface DownloadSource {
name: string;
size: number; // 0 when unknown
iterable: AsyncIterable<Uint8Array>;
/** Local stream-storage path that must be protected from cleanup while read. */
managedPath?: string;
/** Force the underlying source closed (idle-timeout watchdog / abort). */
abort?: () => void;
}
const asStr = (v: JsonValue | undefined): string => (typeof v === 'string' ? v : '');
const asNum = (v: JsonValue | undefined): number => {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? n : 0;
};
/** Follow redirects MANUALLY, SSRF-guarding every hop. Default fetch follows
* redirects automatically and would NOT re-check the target, so a public URL
* could 302 to a private/metadata address — re-validating each Location closes
* that. Each hop gets a TTFB-only timeout (cleared once headers arrive, so a
* legitimate slow/large body isn't cut mid-transfer); the final hop's
* AbortController is returned so the caller's idle watchdog can kill a stalled
* body. (DNS rebinding between our check and undici's own connect remains a
* known residual — it needs connect-time peer-IP validation to fully close.) */
async function fetchGuarded(rawUrl: string): Promise<{ resp: Response; abort: () => void }> {
let current = rawUrl;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const u = await assertSafeUrl(current);
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), FETCH_CONNECT_TIMEOUT_MS);
let resp: Response;
try {
resp = await fetch(u, {
headers: { 'User-Agent': DOWNLOAD_USER_AGENT },
redirect: 'manual',
signal: ac.signal,
});
} finally {
clearTimeout(t);
}
if (resp.status >= 300 && resp.status < 400) {
const loc = resp.headers.get('location');
await resp.body?.cancel().catch(() => {
/* ignore */
});
if (!loc) {
throw new DownloadError(
`download failed: ${resp.status} redirect without Location`,
);
}
current = new URL(loc, u).toString();
continue;
}
return { resp, abort: () => ac.abort() };
}
throw new DownloadError('download failed: too many redirects');
}
async function openUrl(rawUrl: string, name: string, knownSize: number): Promise<DownloadSource> {
const { resp, abort } = await fetchGuarded(rawUrl);
if (!resp.ok) {
await resp.body?.cancel().catch(() => {
/* ignore */
});
throw new DownloadError(`download failed: HTTP ${resp.status}`);
}
const declared = Number(resp.headers.get('content-length') ?? '');
const size = (Number.isFinite(declared) && declared > 0 ? declared : 0) || knownSize;
if (!resp.body) {
throw new DownloadError('download failed: empty response body');
}
return {
name: name || urlBasename(rawUrl),
size,
iterable: resp.body as unknown as AsyncIterable<Uint8Array>,
abort,
};
}
function urlBasename(u: string): string {
try {
const name = path.basename(new URL(u).pathname);
return name && name !== '/' ? decodeURIComponent(name) : 'download.bin';
} catch {
return 'download.bin';
}
}
async function resolveDownload(
target: string,
ctx: ApiActionContext,
prefer: 'auto' | 'image' | 'record',
chunkSize: number,
): Promise<DownloadSource> {
// 1. local file — but ONLY a real existing file, fenced inside the stream
// temp root. `resolveLocalFilePath` returns the input verbatim for any
// non-URL string (including cached-media ids), so we must NOT treat a
// non-existent "path" as local — that would shadow the cached-media branch
// below (the whole point of the *_image/_record_stream actions).
const local = resolveLocalFilePath(target);
if (local) {
let st: fs.Stats | null = null;
try {
st = fs.statSync(local);
} catch {
st = null;
}
if (st?.isFile()) {
ensureStreamDirectory(STREAM_ROOT);
// Fence against the *real* path so a symlink inside the root can't point
// out (lexical resolve alone would follow it).
let real = local;
try {
real = fs.realpathSync(local);
} catch {
/* keep lexical */
}
if (!isWithinStreamRoot(real)) {
throw new DownloadError(
'local downloads are restricted to the stream temp directory',
);
}
const rs = fs.createReadStream(real, { highWaterMark: chunkSize });
return {
name: path.basename(real),
size: st.size,
iterable: rs,
managedPath: real,
abort: () => rs.destroy(),
};
}
}
// 2. explicit http(s) URL.
if (/^https?:\/\//i.test(target)) {
return openUrl(target, '', 0);
}
// 3. cached image / voice id → QQ CDN URL.
let info: JsonObject | null = null;
if (prefer !== 'record') {
info = await ctx.getImageInfo(target);
}
if (!info && prefer !== 'image') {
info = await ctx.getRecordInfo(target);
}
if (info) {
const url = asStr(info.url) || asStr(info.file);
if (/^https?:\/\//i.test(url)) {
return openUrl(url, asStr(info.file_name), asNum(info.file_size));
}
}
throw new DownloadError(
'file not found (expected a stream-dir path, an http(s) URL, or a cached image/voice id)',
);
}
// ─────────────────────────── push engine ───────────────────────────
async function streamChunks(
sink: StreamSink,
iterable: AsyncIterable<Uint8Array>,
totalSize: number,
dataType: string,
abort?: () => void,
): Promise<{ totalChunks: number; totalBytes: number }> {
let index = 0;
let bytesRead = 0;
// Anti-stall: if no chunk arrives for IDLE_TIMEOUT_MS, force the source closed
// so a server that sends headers then hangs can't pin a socket forever (the
// sink's own liveness check only fires when we actually send a frame).
let idle: NodeJS.Timeout | null = null;
const armIdle = (): void => {
if (idle) {
clearTimeout(idle);
}
idle = setTimeout(() => {
abort?.();
}, IDLE_TIMEOUT_MS);
if (idle.unref) {
idle.unref();
}
};
armIdle();
try {
for await (const part of iterable) {
armIdle();
const buf = Buffer.isBuffer(part) ? part : Buffer.from(part);
bytesRead += buf.length;
if (bytesRead > MAX_DOWNLOAD_BYTES) {
throw new DownloadError(
`download exceeds size limit (${MAX_DOWNLOAD_BYTES} bytes)`,
);
}
const b64 = buf.toString('base64');
await sink.send({
type: StreamStatus.Stream,
data_type: dataType,
index,
data: b64,
size: buf.length,
progress:
totalSize > 0 ? Math.min(100, Math.round((bytesRead / totalSize) * 100)) : 0,
base64_size: b64.length,
});
index++;
}
} finally {
if (idle) {
clearTimeout(idle);
}
}
return { totalChunks: index, totalBytes: bytesRead };
}
async function runDownload(
target: string,
chunkSizeReq: number | undefined,
ctx: ApiActionContext,
sink: StreamSink,
prefer: 'auto' | 'image' | 'record',
): Promise<ApiResponse> {
if (!target) {
throw new DownloadError('file is required');
}
let chunkSize = chunkSizeReq && chunkSizeReq > 0 ? chunkSizeReq : DEFAULT_CHUNK_BYTES;
if (chunkSize > MAX_CHUNK_REQUEST_BYTES) {
chunkSize = MAX_CHUNK_REQUEST_BYTES;
}
const src = await resolveDownload(target, ctx, prefer, chunkSize);
const releaseStorageActivity = src.managedPath
? registerActiveStreamItem([src.managedPath])
: undefined;
try {
await sink.send({
type: StreamStatus.Stream,
data_type: 'file_info',
file_name: src.name,
file_size: src.size,
chunk_size: chunkSize,
});
const { totalChunks, totalBytes } = await streamChunks(
sink,
src.iterable,
src.size,
'file_chunk',
src.abort,
);
return okResponse({
type: StreamStatus.Response,
data_type: 'file_complete',
file_name: src.name,
total_chunks: totalChunks,
total_bytes: totalBytes,
message: 'Download completed',
});
} finally {
releaseStorageActivity?.();
}
}
// ─────────────────────────── action definitions ───────────────────────────
const downloadParams = {
file: f.string().optional().describe('文件路径(限 stream 临时目录)/ http(s) URL').role('file'),
file_id: f.string().optional().describe('文件 ID(缓存的图片/语音 id)').role('file_id'),
chunk_size: f.int({ min: 1 }).optional().describe('分块大小(字节,默认 64KB)'),
};
const downloadFileStream = defineStreamAction({
name: 'download_file_stream',
examples: [
{
summary: '以流式方式下载文件(stream 目录本地文件 / URL / 缓存媒体)',
params: {
file: 'https://example.com/report.pdf',
chunk_size: 65536,
},
notes: '替换实际公开可访问地址;经 invoke_action 调用,文件保存到 MCP 下载目录。',
},
],
returnsSchema: downloadFrameSchema,
summary: '以流式方式下载文件(stream 目录本地文件 / URL / 缓存媒体)',
returns: '流式帧:file_info → file_chunk* → file_complete',
params: downloadParams,
run: (p, ctx, sink) => runDownload(p.file || p.file_id || '', p.chunk_size, ctx, sink, 'auto'),
});
const downloadFileImageStream = defineStreamAction({
name: 'download_file_image_stream',
examples: [
{
summary: '以流式方式下载图片(缓存图片 id / URL / stream 目录本地文件)',
params: {
file_id: '<image-file-from-message>',
chunk_size: 65536,
},
notes: 'file_id 来自当前账号图片消息;经 invoke_action 调用。',
},
],
returnsSchema: downloadFrameSchema,
summary: '以流式方式下载图片(缓存图片 id / URL / stream 目录本地文件)',
returns: '流式帧:file_info → file_chunk* → file_complete',
params: downloadParams,
run: (p, ctx, sink) => runDownload(p.file || p.file_id || '', p.chunk_size, ctx, sink, 'image'),
});
const downloadFileRecordStream = defineStreamAction({
name: 'download_file_record_stream',
examples: [
{
summary: '以流式方式下载语音(缓存语音 id / URL / stream 目录本地文件)',
params: {
file_id: '<record-file-from-message>',
chunk_size: 65536,
},
notes: 'file_id 来自当前账号语音消息;经 invoke_action 调用。',
},
],
returnsSchema: downloadFrameSchema,
summary: '以流式方式下载语音(缓存语音 id / URL / stream 目录本地文件)',
returns: '流式帧:file_info → file_chunk* → file_complete',
params: downloadParams,
run: (p, ctx, sink) =>
runDownload(p.file || p.file_id || '', p.chunk_size, ctx, sink, 'record'),
});
const testDownloadStream = defineStreamAction({
name: 'test_download_stream',
examples: [
{
summary: '测试下载流(推送 10 个数据帧,验证流式传输,不触达 QQ)',
params: {
error: false,
},
notes: '经 invoke_action 调用,仅测试传输,不访问 QQ。',
},
],
returnsSchema: {
oneOf: [
{
type: 'object',
properties: {
type: {
const: 'stream',
},
data_type: {
const: 'data_chunk',
},
data: {
type: 'string',
},
},
required: ['type', 'data_type', 'data'],
},
{
type: 'object',
properties: {
type: {
const: 'response',
},
data_type: {
const: 'data_complete',
},
data: {
type: 'string',
},
},
required: ['type', 'data_type', 'data'],
},
],
description: '成功时 10 个 data_chunk 后接 data_complete;error=true 时最终为失败帧',
},
summary: '测试下载流(推送 10 个数据帧,验证流式传输,不触达 QQ)',
returns: '流式帧:data_chunk*10 → data_complete(error=true 时以 error 帧结束)',
params: { error: f.bool().default(false).describe('是否触发测试错误') },
run: async (p, _ctx, sink) => {
for (let i = 0; i < 10; i++) {
await sink.send({
type: StreamStatus.Stream,
data: `Index-> ${i + 1}`,
data_type: 'data_chunk',
});
}
if (p.error) {
throw new DownloadError('This is a test error');
}
return okResponse({
type: StreamStatus.Response,
data_type: 'data_complete',
data: 'Stream transmission complete',
});
},
});
export const actions = [
downloadFileStream,
downloadFileImageStream,
downloadFileRecordStream,
testDownloadStream,
];
// Exposed for tests.
export const __test = {
resolveDownload,
isWithinStreamRoot,
isPrivateIp,
assertSafeUrl,
streamChunks,
runDownload,
};
+651
View File
@@ -0,0 +1,651 @@
import { errorMessage } from '@snowluma/common/errors';
// Stream API file actions (#163) — NapCat-compatible chunked file transfer.
//
// upload_file_stream: a client (possibly on another machine) pushes a file to
// the bot in base64 chunks across repeated calls keyed by `stream_id`; the bot
// reassembles them into a local temp file and returns its path, which the
// client then feeds to a normal send action. This sidesteps the HTTP 2 MB body
// cap and the "client and bot share a filesystem" assumption of plain file://
// sends. Reassembly streams chunk-by-chunk to disk (no whole-file
// Buffer.concat) so an arbitrarily large upload won't OOM the bot.
//
// clean_stream_temp_file: wipe the stream temp dirs (upload + download), scoped
// so it never touches unrelated temp files.
//
// Hardening over the NapCat original (which has all of these holes): stream_id
// is allowlist-validated + every derived path is fenced inside the upload dir
// (no path traversal); total_chunks/chunk/stream/byte counts are all bounded
// (no single-request OOM or disk/fd exhaustion); chunk indices are claimed
// synchronously (no double-count race); state is namespaced per account.
import path from 'path';
import fs from 'fs';
import { createHash } from 'crypto';
import { createLogger } from '@snowluma/common/logger';
import { defineAction, defineStreamAction, f } from '../action-kit';
import type { ApiActionContext } from '../api-handler';
import { okResponse, type ApiResponse } from '../types';
import { StreamStatus } from '../streaming';
import {
STREAM_DOWNLOAD_DIR,
STREAM_ROOT,
STREAM_UPLOAD_DIR,
clearInactiveStreamStorage,
ensureStreamDirectory,
registerActiveStreamItem,
} from '../stream-storage';
const log = createLogger('OneBot.Stream');
export { STREAM_DOWNLOAD_DIR, STREAM_ROOT, STREAM_UPLOAD_DIR };
const UPLOAD_TIMEOUT_MS = 10 * 60 * 1000; // idle stream reaped after 10 min
const DEFAULT_RETENTION_MS = 5 * 60 * 1000; // reassembled file auto-deleted after 5 min
// Resource caps (DoS guards — the whole point of the disk-streaming design is
// blown if a single request can still OOM, and an unbounded server can be
// flooded with streams/chunks/bytes).
const MAX_CONCURRENT_STREAMS = 64;
const MAX_CHUNKS = 200_000; // also bounds the completion scan
const MAX_CHUNK_BYTES = 32 * 1024 * 1024; // one decoded chunk
const MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024; // 4 GiB cumulative per stream
// stream_id / account key allowlist: filename-safe, no separators, no dots
// (so no `.`/`..` traversal token can ever reach a path segment).
const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
class StreamError extends Error {}
function validateStreamId(id: string): void {
if (!SAFE_ID_RE.test(id)) {
throw new StreamError('invalid stream_id (allowed: 1-128 chars of A-Z a-z 0-9 _ -)');
}
}
/** Account namespace for the in-memory map + on-disk dirs. Empty (tests / no
* login) → flat, un-namespaced. Sanitised so it can't widen the path either. */
function accountKey(ctx?: ApiActionContext): string {
let raw = '';
try {
raw = String(ctx?.getLoginInfo?.().userId ?? '');
} catch {
raw = '';
}
return raw.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64);
}
/** Belt-and-braces: assert a derived path stays inside the upload dir even if
* the allowlist above is ever weakened. */
function assertWithin(root: string, target: string): void {
const rel = path.relative(root, target);
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
throw new StreamError('resolved stream path escapes the upload directory');
}
}
// ─────────────────────────── upload state ───────────────────────────
interface UploadState {
/** Client-facing stream_id (echoed in frames). */
id: string;
/** Internal map key — `<account>:<id>` or just `<id>` when un-namespaced. */
key: string;
filename: string;
totalChunks: number;
/** Indices CLAIMED (sync, before the write await) — drives dedup + byte
* accounting so two concurrent calls for one index can't double-count. */
claimed: Set<number>;
/** Indices actually FLUSHED to disk (added after the write resolves) — drives
* completion + the reported count, so completion can't fire while a chunk
* file is still being written (which would read a truncated part). Neither
* set is pre-allocated to total_chunks (that would defeat the no-OOM goal). */
written: Set<number>;
bytesWritten: number;
fileSize?: number;
expectedSha256?: string;
tempDir: string;
finalPath: string;
fileRetention: number;
timeoutId: NodeJS.Timeout;
releaseStorageActivity: () => void;
}
/** Process-wide. Key is account-namespaced so two accounts (same process) can't
* collide on a shared/non-UUID stream_id. */
const uploads = new Map<string, UploadState>();
function reapTimer(key: string): NodeJS.Timeout {
const t = setTimeout(() => {
cleanupUpload(key, true);
}, UPLOAD_TIMEOUT_MS);
if (typeof t.unref === 'function') {
t.unref();
} // don't pin the event loop on a stalled upload
return t;
}
function cleanupUpload(key: string, deleteFinal: boolean): void {
const state = uploads.get(key);
if (!state) {
return;
}
clearTimeout(state.timeoutId);
try {
fs.rmSync(state.tempDir, { recursive: true, force: true });
} catch (error) {
log.warn(
'failed to delete stream upload chunks %s: %s',
state.tempDir,
errorMessage(error),
);
}
if (deleteFinal) {
try {
fs.rmSync(state.finalPath, { force: true });
} catch (error) {
log.warn(
'failed to delete stream upload file %s: %s',
state.finalPath,
errorMessage(error),
);
}
}
uploads.delete(key);
state.releaseStorageActivity();
}
function safeFilename(name: string | undefined, id: string): string {
const base = (name ?? '')
.replace(/[/\\]/g, '_') // no directory separators
.replace(/\.\.+/g, '_') // no `..` traversal tokens
.replace(/^\.+/, '') // no leading dots
.trim();
return base || `upload_${id}`;
}
function createUpload(p: UploadParams, account: string): UploadState {
if (!p.total_chunks || p.total_chunks <= 0) {
throw new StreamError('total_chunks required for new stream');
}
if (p.total_chunks > MAX_CHUNKS) {
throw new StreamError(`total_chunks exceeds limit (${MAX_CHUNKS})`);
}
if (p.file_size !== undefined && p.file_size > MAX_UPLOAD_BYTES) {
throw new StreamError(`file_size exceeds limit (${MAX_UPLOAD_BYTES} bytes)`);
}
if (uploads.size >= MAX_CONCURRENT_STREAMS) {
throw new StreamError(`too many concurrent upload streams (${MAX_CONCURRENT_STREAMS})`);
}
const filename = safeFilename(p.filename, p.stream_id);
const dirRoot = account ? path.join(STREAM_UPLOAD_DIR, account) : STREAM_UPLOAD_DIR;
const tempDir = path.join(dirRoot, p.stream_id);
const finalPath = path.join(dirRoot, `${p.stream_id}__${filename}`);
assertWithin(STREAM_UPLOAD_DIR, tempDir);
assertWithin(STREAM_UPLOAD_DIR, finalPath);
ensureStreamDirectory(tempDir);
const key = account ? `${account}:${p.stream_id}` : p.stream_id;
const releaseStorageActivity = registerActiveStreamItem([tempDir, finalPath]);
const state: UploadState = {
id: p.stream_id,
key,
filename,
totalChunks: p.total_chunks,
claimed: new Set(),
written: new Set(),
bytesWritten: 0,
fileSize: p.file_size,
expectedSha256: p.expected_sha256,
tempDir,
finalPath,
fileRetention: p.file_retention,
timeoutId: reapTimer(key),
releaseStorageActivity,
};
uploads.set(key, state);
return state;
}
function statusFrame(state: UploadState, status: string): ApiResponse {
return okResponse({
type: StreamStatus.Stream,
stream_id: state.id,
status,
received_chunks: state.written.size,
total_chunks: state.totalChunks,
});
}
function writeChunkToStream(stream: fs.WriteStream, buf: Buffer): Promise<void> {
return new Promise((resolve, reject) => {
stream.write(buf, (err) => (err ? reject(err) : resolve()));
});
}
async function completeUpload(state: UploadState): Promise<ApiResponse> {
if (state.written.size !== state.totalChunks) {
const missing: number[] = [];
for (let i = 0; i < state.totalChunks && missing.length < 16; i++) {
if (!state.written.has(i)) {
missing.push(i);
}
}
const more = state.totalChunks - state.written.size > missing.length ? '…' : '';
throw new StreamError(
`incomplete stream: missing ${state.totalChunks - state.written.size} chunk(s) [${missing.join(',')}${more}]`,
);
}
// Stream each chunk file into the final file in order, hashing as we go — one
// chunk in memory at a time, never the whole payload.
const hash = createHash('sha256');
const out = fs.createWriteStream(state.finalPath);
try {
for (let i = 0; i < state.totalChunks; i++) {
const buf = await fs.promises.readFile(path.join(state.tempDir, `${i}.chunk`));
hash.update(buf);
await writeChunkToStream(out, buf);
}
await new Promise<void>((resolve, reject) =>
out.end((err?: Error | null) => (err ? reject(err) : resolve())),
);
} catch (err) {
out.destroy();
try {
fs.rmSync(state.finalPath, { force: true });
} catch (error) {
log.warn(
'failed to delete incomplete stream file %s: %s',
state.finalPath,
errorMessage(error),
);
}
throw err;
}
const sha256 = hash.digest('hex');
if (state.expectedSha256 && sha256 !== state.expectedSha256) {
try {
fs.rmSync(state.finalPath, { force: true });
} catch (error) {
log.warn(
'failed to delete invalid stream file %s: %s',
state.finalPath,
errorMessage(error),
);
}
throw new StreamError(`sha256 mismatch (expected ${state.expectedSha256}, got ${sha256})`);
}
const finalPath = state.finalPath;
const fileSize = fs.statSync(finalPath).size;
const received = state.written.size;
const total = state.totalChunks;
const retention = state.fileRetention;
// Drop chunk parts + in-memory state but KEEP the reassembled file.
cleanupUpload(state.key, false);
if (retention > 0) {
const t = setTimeout(() => {
fs.rm(finalPath, { force: true }, (err) => {
if (err) {
log.warn(
'failed to delete retained stream file %s: %s',
finalPath,
err.message,
);
}
});
}, retention);
if (typeof t.unref === 'function') {
t.unref();
}
}
return okResponse({
type: StreamStatus.Response,
stream_id: state.id,
status: 'file_complete',
received_chunks: received,
total_chunks: total,
file_path: finalPath,
file_name: state.filename,
file_size: fileSize,
sha256,
});
}
async function handleUpload(p: UploadParams, account = ''): Promise<ApiResponse> {
validateStreamId(p.stream_id);
const key = account ? `${account}:${p.stream_id}` : p.stream_id;
if (p.reset) {
cleanupUpload(key, true);
// NapCat surfaces reset as an error frame; mirror it for client parity.
throw new StreamError('Stream reset completed');
}
if (p.verify_only) {
const state = uploads.get(key);
if (!state) {
throw new StreamError('stream not found');
}
return statusFrame(state, 'file_created');
}
const state = uploads.get(key) ?? createUpload(p, account);
// Metadata may arrive on the creating call or any later one (e.g. the client
// only knows the whole-file sha256 when it sends `is_complete`).
if (p.expected_sha256 !== undefined) {
state.expectedSha256 = p.expected_sha256;
}
if (p.file_size !== undefined) {
state.fileSize = p.file_size;
}
if (p.chunk_data !== undefined && p.chunk_index !== undefined) {
const index = p.chunk_index;
if (index < 0 || index >= state.totalChunks) {
throw new StreamError(`invalid chunk index: ${index}`);
}
if (state.claimed.has(index)) {
return statusFrame(state, 'chunk_received');
} // duplicate / in-flight — idempotent
const buf = Buffer.from(p.chunk_data, 'base64');
if (buf.length > MAX_CHUNK_BYTES) {
throw new StreamError(`chunk exceeds size limit (${MAX_CHUNK_BYTES} bytes)`);
}
if (state.bytesWritten + buf.length > MAX_UPLOAD_BYTES) {
throw new StreamError(`stream exceeds total size limit (${MAX_UPLOAD_BYTES} bytes)`);
}
// Claim the index synchronously (before the write await) so two concurrent
// calls for the same index can't both pass the dedup check or double-count
// bytes. `written` is only set AFTER the bytes hit disk, so completion can
// never fire while this chunk file is still being written.
state.claimed.add(index);
state.bytesWritten += buf.length;
try {
await fs.promises.writeFile(path.join(state.tempDir, `${index}.chunk`), buf);
} catch (err) {
state.claimed.delete(index);
state.bytesWritten -= buf.length;
throw err;
}
state.written.add(index);
clearTimeout(state.timeoutId);
state.timeoutId = reapTimer(state.key);
return statusFrame(state, 'chunk_received');
}
if (p.is_complete || state.written.size === state.totalChunks) {
return completeUpload(state);
}
return statusFrame(state, 'file_created');
}
// ─────────────────────────── action definitions ───────────────────────────
const uploadParams = {
stream_id: f
.string({ allowEmpty: false })
.describe('流 ID(客户端生成的 UUID,限 [A-Za-z0-9_-])'),
chunk_data: f.string().optional().describe('分块数据(Base64)'),
chunk_index: f.int({ min: 0 }).optional().describe('分块索引(从 0 开始)'),
total_chunks: f.int({ min: 1 }).optional().describe('总分块数(新流必填)'),
file_size: f.int({ min: 0 }).optional().describe('文件总大小(字节)'),
expected_sha256: f.string().optional().describe('期望的整文件 SHA256(校验)'),
is_complete: f.bool().optional().describe('是否为最后一个分块/触发合并'),
filename: f.string().optional().describe('文件名'),
reset: f.bool().optional().describe('重置并丢弃该流'),
verify_only: f.bool().optional().describe('仅查询当前流状态'),
file_retention: f
.int({ min: 0 })
.default(DEFAULT_RETENTION_MS)
.describe('合并文件保留毫秒(0=不回收)'),
};
type UploadParams = {
stream_id: string;
chunk_data: string | undefined;
chunk_index: number | undefined;
total_chunks: number | undefined;
file_size: number | undefined;
expected_sha256: string | undefined;
is_complete: boolean | undefined;
filename: string | undefined;
reset: boolean | undefined;
verify_only: boolean | undefined;
file_retention: number;
};
const uploadFileStream = defineStreamAction({
name: 'upload_file_stream',
examples: [
{
summary: '上传分块',
params: {
stream_id: 'example-upload-001',
filename: 'hello.txt',
total_chunks: 1,
file_size: 5,
chunk_index: 0,
chunk_data: 'aGVsbG8=',
},
notes: '第 1 步:每次上传生成新的 stream_id;这里上传 hello 的唯一分块。通过 invoke_action 调用,分块确认不等于整文件完成。',
},
{
summary: '完成同一上传',
params: {
stream_id: 'example-upload-001',
is_complete: true,
},
notes: '第 2 步:等待全部分块确认后,用同一个 stream_id 单独请求完成;成功时返回 file_path。MCP 的 invoke_action 也可通过 execution.input_file 自动完成上传。',
},
],
returnsSchema: {
oneOf: [
{
type: 'object',
properties: {
type: {
const: 'stream',
},
stream_id: {
type: 'string',
},
status: {
enum: ['file_created', 'chunk_received'],
},
received_chunks: {
type: 'integer',
},
total_chunks: {
type: 'integer',
},
},
required: ['type', 'stream_id', 'status', 'received_chunks', 'total_chunks'],
},
{
type: 'object',
properties: {
type: {
const: 'response',
},
stream_id: {
type: 'string',
},
status: {
const: 'file_complete',
},
received_chunks: {
type: 'integer',
},
total_chunks: {
type: 'integer',
},
file_path: {
type: 'string',
},
file_name: {
type: 'string',
},
file_size: {
type: 'integer',
},
sha256: {
type: 'string',
},
},
required: [
'type',
'stream_id',
'status',
'received_chunks',
'total_chunks',
'file_path',
'file_name',
'file_size',
'sha256',
],
},
],
description:
'分块确认与完成帧的 data;reset 和其他失败由错误帧返回。必须等到 file_complete 才能使用 file_path',
},
summary: '以流式分块方式上传文件到机器人本地(返回可用于发送的本地路径)',
returns:
'流式帧:分块确认 type=stream、完成 type=response(含 file_path/file_name/file_size/sha256)',
params: uploadParams,
run: (p, ctx) => handleUpload(p, accountKey(ctx)),
});
function cleanDir(dir: string): number {
let removed = 0;
let entries: string[];
try {
entries = fs.readdirSync(dir);
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
return 0;
}
throw error;
}
for (const name of entries) {
try {
fs.rmSync(path.join(dir, name), { recursive: true, force: true });
removed++;
} catch (err) {
log.warn(
'failed to remove stream temp %s: %s',
path.join(dir, name),
err instanceof Error ? err.message : String(err),
);
}
}
return removed;
}
const cleanStreamTempFile = defineAction({
name: 'clean_stream_temp_file',
examples: [
{
summary: '清理流式传输临时文件(仅清理 stream 上传/下载目录)',
params: {},
notes: '清理未使用的流式临时文件;不会清理正在传输的项目。',
},
],
returnsSchema: {
type: 'object',
properties: {
message: {
const: 'success',
},
removed: {
type: 'integer',
},
freed_bytes: {
type: 'integer',
},
skipped_active: {
type: 'integer',
},
},
required: ['message', 'removed', 'freed_bytes', 'skipped_active'],
},
summary: '清理流式传输临时文件(仅清理 stream 上传/下载目录)',
returns: '{ message, removed }',
params: {},
run: () => {
const result = clearInactiveStreamStorage();
for (const failure of result.failures) {
log.warn('failed to remove stream temp %s: %s', failure.item, failure.message);
}
if (result.failures.length > 0) {
throw new StreamError(`failed to remove ${result.failures.length} stream temp item(s)`);
}
return okResponse({
message: 'success',
removed: result.deletedFiles,
freed_bytes: result.freedBytes,
skipped_active: result.skippedActiveItems,
});
},
});
export const actions = [uploadFileStream, cleanStreamTempFile];
// Exposed for tests.
export const __test = {
uploads,
handleUpload,
cleanupUpload,
cleanDir,
validateStreamId,
STREAM_UPLOAD_DIR,
MAX_CONCURRENT_STREAMS,
MAX_CHUNKS,
};
+183
View File
@@ -0,0 +1,183 @@
import { isSuperFaceEntry } from '@snowluma/protocol/oidb-services/sys-faces/fetch-sys-faces';
import type { SysFaceEntry } from '@snowluma/protocol/sys-face-store';
import { defineAction, f } from '../action-kit';
import { okResponse, type JsonObject } from '../types';
function faceEntry(face: SysFaceEntry): JsonObject {
return {
q_sid: face.qSid,
q_des: face.qDes,
em_code: face.emCode,
q_cid: face.qCid,
ani_sticker_type: face.aniStickerType,
ani_sticker_pack_id: face.aniStickerPackId,
ani_sticker_id: face.aniStickerId,
url: face.url,
emoji_name_alias: face.emojiNameAlias,
ani_sticker_width: face.aniStickerWidth,
ani_sticker_height: face.aniStickerHeight,
is_super: isSuperFaceEntry(face),
};
}
const refreshField = () => f.bool().default(false).describe('是否强制从 QQ 刷新目录');
const faceIdField = () => f.faceId().describe('QQ 系统表情编号');
const nullableInteger = { type: ['integer', 'null'] };
const nullableString = { type: ['string', 'null'] };
const faceSchema = {
type: 'object',
properties: {
q_sid: {
type: 'string',
description: 'QQ 表情目录标识(部分 emoji 为 Unicode 表情字符串)',
},
q_des: { type: 'string', description: '表情描述' },
em_code: { type: 'string' },
q_cid: nullableInteger,
ani_sticker_type: nullableInteger,
ani_sticker_pack_id: nullableInteger,
ani_sticker_id: nullableInteger,
url: nullableString,
emoji_name_alias: { type: 'array', items: { type: 'string' } },
ani_sticker_width: nullableInteger,
ani_sticker_height: nullableInteger,
is_super: { type: 'boolean', description: '是否使用超级表情格式' },
},
required: [
'q_sid',
'q_des',
'em_code',
'q_cid',
'ani_sticker_type',
'ani_sticker_pack_id',
'ani_sticker_id',
'url',
'emoji_name_alias',
'ani_sticker_width',
'ani_sticker_height',
'is_super',
],
};
export const actions = [
defineAction({
name: 'fetch_sys_faces',
examples: [
{
summary: '获取 QQ 系统表情目录',
params: {
refresh: false,
},
},
],
summary: '获取 QQ 系统表情目录',
readOnly: true,
returns: '按分组返回完整的 QQ 系统表情映射。',
returnsSchema: {
type: 'object',
properties: {
packs: {
type: 'array',
items: {
type: 'object',
properties: {
pack_name: { type: 'string' },
emojis: { type: 'array', items: faceSchema },
},
required: ['pack_name', 'emojis'],
},
},
},
required: ['packs'],
},
params: { refresh: refreshField() },
run: async (params, ctx) => {
const packs = await ctx.bridge.apis.systemFace.fetchCatalog(params.refresh);
return okResponse({
packs: packs.map((pack) => ({
pack_name: pack.packName,
emojis: pack.emojis.map(faceEntry),
})),
});
},
}),
defineAction({
name: 'fetch_face_entity',
examples: [
{
summary: '按编号查询 QQ 系统表情',
params: {
face_id: 14,
},
},
],
summary: '按编号查询 QQ 系统表情',
readOnly: true,
returns: '表情详情;编号不存在时返回 null。',
returnsSchema: { anyOf: [faceSchema, { type: 'null' }] },
params: {
face_id: faceIdField(),
refresh: refreshField(),
},
run: async (params, ctx) => {
const face = await ctx.bridge.apis.systemFace.fetchFace(params.face_id, params.refresh);
return okResponse(face ? faceEntry(face) : null);
},
}),
defineAction({
name: 'search_sys_faces',
examples: [
{
summary: '搜索 QQ 系统表情',
params: {
query: '微笑',
},
},
],
summary: '搜索 QQ 系统表情',
readOnly: true,
returns: '匹配编号、名称、别名或分组名的表情列表。',
returnsSchema: {
type: 'object',
properties: { faces: { type: 'array', items: faceSchema } },
required: ['faces'],
},
params: {
query: f.string({ allowEmpty: false }).describe('编号、名称、别名或分组名'),
},
run: async (params, ctx) =>
okResponse({
faces: (await ctx.bridge.apis.systemFace.search(params.query)).map(faceEntry),
}),
}),
defineAction({
name: 'fetch_super_face_id',
examples: [
{
summary: '判断 QQ 系统表情是否使用超级表情格式',
params: {
face_id: 14,
},
},
],
summary: '判断 QQ 系统表情是否使用超级表情格式',
readOnly: true,
returns: '是否使用超级表情格式。',
returnsSchema: {
type: 'object',
properties: { is_super: { type: 'boolean' } },
required: ['is_super'],
},
params: {
face_id: faceIdField(),
refresh: refreshField(),
},
run: async (params, ctx) =>
okResponse({
is_super: await ctx.bridge.apis.systemFace.isSuper(params.face_id, params.refresh),
}),
}),
];
+572
View File
@@ -0,0 +1,572 @@
import { renderParamsVerbose, summarizeParams } from '@snowluma/common/log-summary';
import { createLogger, runWithTraceRequest, type Logger } from '@snowluma/common/logger';
import type { BridgeInterface } from '@snowluma/core/bridge-interface';
import { MessageElementValidationError } from '@snowluma/protocol/element-manifest';
import {
ACTION_REGISTRY,
HANDLE_QUICK_OPERATION_ACTION,
type CompiledActionKind,
type CompiledActionRegistry,
} from './actions';
import type { GroupSystemMessageQuery } from './modules/contact-actions';
import type { ForwardPreviewMeta } from './modules/message-actions';
import type { ReadSessionTargets } from './message-store';
import type { JsonObject, JsonValue, MessageMeta } from './types';
import { RETCODE, failedResponse, okResponse } from './types';
import {
StreamTransportClosedError,
type StreamSink,
wrapStreamFrame,
wrapStreamTerminal,
} from './streaming';
const moduleLog = createLogger('Bridge.Action');
function summarizeActionParams(action: string, params: JsonObject): string {
if (action !== 'fetch_emoji_like') {
return summarizeParams(params);
}
const safeParams: JsonObject = { ...params };
const cursor = params.cookie;
if (typeof cursor !== 'string' || !/^\d+$/.test(cursor)) {
return summarizeParams(safeParams);
}
const offset = Number(cursor);
if (!Number.isSafeInteger(offset)) {
return summarizeParams(safeParams);
}
delete safeParams.cookie;
safeParams.emoji_like_offset = offset;
return summarizeParams(safeParams);
}
export interface MessageSendResult {
messageId: number;
meta?: MessageMeta;
echoEvent?: JsonObject;
}
export interface ApiActionContext {
bridge: BridgeInterface;
getLoginInfo: () => { userId: number; nickname: string };
isOnline: () => boolean;
getUptimeMs: () => number;
getMessage: (messageId: number) => JsonObject | null | Promise<JsonObject | null>;
refreshMessageMedia: (messageId: number, event: JsonObject) => Promise<void>;
getMessageMeta: (messageId: number) => MessageMeta | null | Promise<MessageMeta | null>;
cacheMessageMetas: (
entries: ReadonlyArray<{ messageId: number; meta: MessageMeta }>,
) => void | Promise<void>;
listReadSessions: () => ReadSessionTargets | Promise<ReadSessionTargets>;
sendPrivateMessage: (
userId: number,
message: JsonValue,
autoEscape: boolean,
tempGroupId?: number,
) => Promise<MessageSendResult>;
sendGroupMessage: (
groupId: number,
message: JsonValue,
autoEscape: boolean,
) => Promise<MessageSendResult>;
deleteMessage: (messageId: number, meta: MessageMeta) => Promise<void>;
canSendImage: () => boolean;
canSendRecord: () => boolean;
getFriendList: (withProfile?: boolean) => Promise<JsonObject[]>;
getGroupList: (noCache?: boolean) => Promise<JsonObject[]>;
getGroupInfo: (groupId: number, noCache?: boolean) => Promise<JsonObject | null>;
getGroupMemberList: (groupId: number, noCache?: boolean) => Promise<JsonObject[]>;
getGroupMemberInfo: (
groupId: number,
userId: number,
noCache?: boolean,
) => Promise<JsonObject | null>;
getStrangerInfo: (userId: number) => Promise<JsonObject | null>;
getGroupFiles: (groupId: number, folderId?: string) => Promise<JsonObject>;
handleGroupRequest: (
flag: string,
subType: string,
approve: boolean,
reason: string,
) => Promise<void>;
setEssenceMsg: (messageId: number) => Promise<void>;
deleteEssenceMsg: (messageId: number) => Promise<void>;
getGroupMsgHistory: (
groupId: number,
messageId?: number,
count?: number,
reverseOrder?: boolean,
) => Promise<JsonObject[]>;
getFriendMsgHistory: (
userId: number,
messageId?: number,
count?: number,
reverseOrder?: boolean,
) => Promise<JsonObject[]>;
handleGetGroupSystemMsg: (query: GroupSystemMessageQuery) => Promise<JsonObject[]>;
getDownloadRKeys: () => Promise<JsonObject[]>;
sendGroupForwardMsg: (
groupId: number,
messages: JsonValue,
meta?: ForwardPreviewMeta,
) => Promise<{ messageId: number; forwardId: string }>;
sendPrivateForwardMsg: (
userId: number,
messages: JsonValue,
meta?: ForwardPreviewMeta,
) => Promise<{ messageId: number; forwardId: string }>;
sendForwardMsg: (messages: JsonValue, groupId?: number) => Promise<{ forwardId: string }>;
getForwardMsg: (resId: string) => Promise<JsonObject[]>;
forwardSingleMsg: (
messageId: number,
target: { groupId?: number; userId?: number },
) => Promise<{ messageId: number }>;
setMsgEmojiLike: (messageId: number, emojiId: string, set: boolean) => Promise<void>;
fetchEmojiLikeUsers: (
messageId: number,
emojiId: string,
count: number,
offset?: number,
) => Promise<{
users: Array<{ uin: number; uid: string; setAt: number }>;
cachedCount: number;
serverCount: number;
complete: boolean;
}>;
fetchEmojiLikeSummary: (messageId: number) => Promise<
Array<{
emoji_id: string;
emoji_type: number;
count: number;
last_reaction_time: number;
users: Array<{ user_id: number }>;
}>
>;
getImageInfo: (file: string) => Promise<JsonObject | null>;
getRecordInfo: (file: string) => Promise<JsonObject | null>;
fetchPttText: (messageId: number) => Promise<{ text: string }>;
}
export type ActionHandler = (
params: JsonObject,
sink?: StreamSink,
) => Promise<import('./types').ApiResponse>;
interface RegisteredHandler {
readonly handler: ActionHandler;
readonly canonical: string;
readonly kind: CompiledActionKind;
}
/** A handled-action record handed to debug observers. */
export interface ActionRecord {
action: string;
params: JsonObject;
response: import('./types').ApiResponse;
ms: number;
}
export type ActionObserver = (rec: ActionRecord) => void;
export class ApiHandler {
/** Handler + dispatch kind live in one record so stream classification can
* never outlive or drift from the handler it describes. */
private readonly handlers: ReadonlyMap<string, RegisteredHandler>;
/** Sticky instance-lifecycle gate. A failed transport close may restore its
* own listener for retry, but it must never reopen execution against a
* retiring Bridge/store generation. */
private acceptingActions = true;
private readonly log: Logger;
/** Debug-stream taps — notified after every handled action. Attached
* on-demand (ref-counted) by the WebUI debug stream. */
private readonly observers = new Set<ActionObserver>();
/** Observe handled actions (debug). Returns an unsubscribe. */
setObserver(cb: ActionObserver): () => void {
this.observers.add(cb);
return () => {
this.observers.delete(cb);
};
}
constructor(
context: ApiActionContext,
uin?: number,
registry: CompiledActionRegistry = ACTION_REGISTRY,
) {
this.log = typeof uin === 'number' && uin > 0 ? moduleLog.child({ uin }) : moduleLog;
const rawFactories: Record<string, (api: ApiHandler) => ActionHandler> = {};
for (const raw of registry.rawActions) {
if (raw.canonical !== HANDLE_QUICK_OPERATION_ACTION) {
throw new Error(
`Action registry has no factory for raw action canonical "${raw.canonical}" ` +
`(name "${raw.name}", kind raw)`,
);
}
rawFactories[raw.canonical] = (api) => async (params) => {
const opContext = params.context as JsonObject | undefined;
const operation = params.operation as Record<string, unknown> | undefined;
if (!opContext || !operation) {
return failedResponse(
RETCODE.BAD_REQUEST,
'context and operation are required',
);
}
const { executeQuickOperation } = await import('./network/quick-operation');
await executeQuickOperation(opContext, operation, api);
return okResponse();
};
}
this.handlers = registry.bind(context, this, rawFactories);
}
/** Whether `action` answers with a multi-frame Stream API response. */
isStreamAction(action: string): boolean {
return this.handlers.get(action)?.kind === 'stream';
}
/** Whether the owning OneBot instance still accepts new Action execution. */
get isAcceptingActions(): boolean {
return this.acceptingActions;
}
/** Permanently reject new Actions for this handler generation.
*
* Existing calls have already been admitted and are drained by their owning
* transport/instance. There is intentionally no resume operation: hot reload
* keeps the same live generation open, while teardown creates a new handler
* only after the previous generation has fully retired. */
quiesce(): void {
if (!this.acceptingActions) {
return;
}
this.acceptingActions = false;
this.log.info('Action ingress quiesced');
}
async handle(
action: string,
params: JsonObject,
sink?: StreamSink,
): Promise<import('./types').ApiResponse> {
return runWithTraceRequest(() => this.handleInContext(action, params, sink));
}
traceQuiescedAction(
action: string,
params: JsonObject,
response: import('./types').ApiResponse,
): void {
if (this.acceptingActions) {
return;
}
runWithTraceRequest(() => {
const startedAt = Date.now();
this.traceActionInput(action, params);
this.traceActionTerminal(action, response, startedAt, 'failed', 'quiesced');
});
}
traceQuiescedStreamRequest(rawRequest: string): void {
const request = parseStreamRequest(rawRequest);
if (!request) {
return;
}
this.traceQuiescedAction(
request.action,
request.params,
failedResponse(RETCODE.ACTION_FAILED, 'OneBot instance is shutting down'),
);
}
private async handleInContext(
action: string,
params: JsonObject,
sink?: StreamSink,
): Promise<import('./types').ApiResponse> {
const startedAt = Date.now();
this.traceActionInput(action, params);
if (!this.acceptingActions) {
const response = failedResponse(
RETCODE.ACTION_FAILED,
'OneBot instance is shutting down',
);
this.log.warn('rejected Action %s after instance quiesce', action);
this.traceActionTerminal(action, response, startedAt, 'failed', 'quiesced');
this.notifyObservers(action, params, response, 0);
return response;
}
const registered = this.handlers.get(action);
if (!registered) {
const response = failedResponse(RETCODE.UNKNOWN_ACTION, 'unknown action');
this.log.debug('unknown action %s', action);
this.traceActionTerminal(action, response, startedAt, 'failed', 'unknown_action');
return response;
}
return this.runAction(action, registered.handler, params, sink, startedAt);
}
private async runAction(
action: string,
handler: ActionHandler,
params: JsonObject,
sink: StreamSink | undefined,
startedAt: number,
): Promise<import('./types').ApiResponse> {
// Terse breadcrumb to the log file (debug, which is written to disk under the default
// file level): lets the operator grep "what did the bot get asked to do" in post-mortems.
this.log.debug('%s params=%s', action, summarizeActionParams(action, params));
let response: import('./types').ApiResponse;
let outcome: 'ok' | 'failed' | 'cancelled';
let reason: 'response_returned' | 'handler_threw' | 'transport_closed';
try {
response = await handler(
params,
sink
? {
send: async (frame) => {
this.log.trace(() => [
'action_stream_frame action=%s frame=%s',
action,
renderParamsVerbose(frame),
]);
await sink.send(frame);
},
}
: undefined,
);
outcome = response.status === 'ok' ? 'ok' : 'failed';
reason = 'response_returned';
} catch (error) {
// Single error seam: typed message-contract failures are caller errors
// and therefore map to BAD_REQUEST. Bridge/OIDB business failures and
// unexpected internal faults retain the shared ACTION_FAILED policy.
// Action `run` bodies should not hand-roll their own catch/response
// mapping: central classification keeps every transport consistent.
// `OidbError.message` already carries the QQ server code, so it needs no
// special casing. warn (not error) keeps the log file useful without
// drowning it in expected client-side failures.
this.log.warn(
'%s failed: %s\n%s',
action,
error instanceof Error ? error.message : String(error),
error instanceof Error ? (error.stack ?? '') : '',
);
const message = error instanceof Error ? error.message : String(error);
const retcode =
error instanceof MessageElementValidationError
? RETCODE.BAD_REQUEST
: RETCODE.ACTION_FAILED;
response = failedResponse(retcode, message);
outcome = error instanceof StreamTransportClosedError ? 'cancelled' : 'failed';
reason =
error instanceof StreamTransportClosedError ? 'transport_closed' : 'handler_threw';
}
this.traceActionTerminal(action, response, startedAt, outcome, reason);
this.notifyObservers(action, params, response, Date.now() - startedAt);
return response;
}
private traceActionInput(action: string, params: JsonObject): void {
this.log.trace(() => [
'action_input action=%s params=%s',
action,
renderParamsVerbose(params),
]);
}
private traceActionTerminal(
action: string,
response: import('./types').ApiResponse,
startedAt: number,
outcome: 'ok' | 'failed' | 'cancelled',
reason:
| 'response_returned'
| 'handler_threw'
| 'transport_closed'
| 'unknown_action'
| 'quiesced',
): void {
this.log.trace(() => [
'action_terminal action=%s outcome=%s reason=%s ms=%d response=%s',
action,
outcome,
reason,
Date.now() - startedAt,
renderParamsVerbose(response),
]);
}
private notifyObservers(
action: string,
params: JsonObject,
response: import('./types').ApiResponse,
ms: number,
): void {
if (!this.observers.size) {
return;
}
for (const cb of this.observers) {
try {
cb({ action, params, response, ms });
} catch (err) {
this.log.warn(
'action observer error: %s',
err instanceof Error ? err.message : String(err),
);
}
}
}
/** WS dispatch supporting Stream API multi-frame responses. A normal action
* emits exactly one frame; a stream action emits each intermediate frame
* then the terminal frame — every frame carries the request's echo. `emit`
* writes one JSON string per frame; awaiting it lets the transport apply
* backpressure. `isAlive`, when supplied, is checked before each stream
* frame — returning false aborts the action (e.g. the client disconnected),
* so a dead client can't make a download keep pumping frames into the void. */
async processStreamRequest(
rawRequest: string,
emit: (json: string) => void | Promise<void>,
isAlive?: () => boolean,
): Promise<void> {
const bad = (): Promise<void> =>
Promise.resolve(
emit(JSON.stringify(failedResponse(RETCODE.BAD_REQUEST, 'bad request'))),
);
if (!rawRequest.trim()) {
await bad();
return;
}
const request = parseStreamRequest(rawRequest);
if (!request) {
await bad();
return;
}
const { action, params, echo } = request;
if (!this.isStreamAction(action)) {
const response = await this.handle(action, params);
if (echo !== undefined) {
response.echo = echo;
}
await emit(JSON.stringify(response));
return;
}
const sink: StreamSink = {
send: async (frame) => {
if (isAlive && !isAlive()) {
throw new StreamTransportClosedError();
}
await emit(JSON.stringify(wrapStreamFrame(frame, echo)));
},
};
const response = await this.handle(action, params, sink);
await emit(JSON.stringify(wrapStreamTerminal(response, echo)));
}
}
interface StreamRequest {
action: string;
params: JsonObject;
echo: JsonValue | undefined;
}
function parseStreamRequest(rawRequest: string): StreamRequest | null {
if (!rawRequest.trim()) {
return null;
}
try {
const parsed = JSON.parse(rawRequest) as unknown;
if (!isJsonObject(parsed)) {
return null;
}
const action = asString(parsed.action);
if (!action) {
return null;
}
return {
action,
params: isJsonObject(parsed.params) ? parsed.params : {},
echo: parsed.echo !== undefined ? toJsonValue(parsed.echo) : undefined,
};
} catch {
return null;
}
}
function isJsonObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function asString(value: unknown, fallback = ''): string {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return fallback;
}
export function toJsonValue(value: unknown): JsonValue {
if (value === null) {
return null;
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return value;
}
if (Array.isArray(value)) {
return value.map(toJsonValue);
}
if (isJsonObject(value)) {
const obj: JsonObject = {};
for (const [key, item] of Object.entries(value)) {
obj[key] = toJsonValue(item);
}
return obj;
}
return String(value);
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
declare const __BUILD_WEBUI__: boolean;
@@ -0,0 +1,761 @@
import { createLogger } from '@snowluma/common/logger';
import type {
MessageElement,
MessageElementOf,
MessageElementType,
} from '@snowluma/protocol/events';
import { MessageElementValidationError } from '@snowluma/protocol/element-manifest';
import type { JsonObject } from '../types';
import type { ParseMessageOptions } from '../message-parser';
import type {
ImageUrlResolver,
MediaUrlResolver,
MessageIdResolver,
MediaSegmentSink,
} from './index';
import { GROUP_MESSAGE_EVENT, privateMessageEventName } from '../message-id';
import { resolveReplyId } from './utils';
import type { PrivateQuoteSource } from '../private-quote';
// ─────────────────────────────────────────────────────────────────────────
// 消息元素 codec 表(onebot 侧)—— 把「同一种元素」的两个 onebot 方向并到一处:
// S 收·转 toSegment MessageElement → OneBot 段
// P 发·解 fromSegment OneBot 段 → MessageElement
// 历史上这两向分散在 to-segment.ts 与 message-parser.ts、各写各的,容易一起漂。
// 现在每种元素类型一条 { toSegment?, fromSegment? } 条目,谁少写一向一眼可见,
// 并由 element-manifest 对账测试(onebot 侧)拿本表的键去核对清单。
//
// 第 1 步(收·解,proto 字段分派、异构)与第 4 步(发·打包,protocol 包)不在本表,
// 见 element-manifest.ts 的说明。纯 OneBot 输入糖(骰子/分享/…)也不进表,留在
// message-parser 当前置 normalize。
//
// 每条目由判别类型精确约束:S 读错字段或 P 产出错 discriminant 会在 typecheck 报错。
// ─────────────────────────────────────────────────────────────────────────
const log = createLogger('MsgParser');
/** toSegment(S 收·转)所需的上下文:会话信息 + 各类 URL/媒体解析器。 */
export interface ToSegmentContext {
isGroup: boolean;
sessionId: number;
selfId: number;
imageUrlResolver?: ImageUrlResolver | null;
mediaUrlResolver?: MediaUrlResolver | null;
messageIdResolver?: MessageIdResolver | null;
mediaSegmentSink?: MediaSegmentSink | null;
currentMessage?: PrivateQuoteSource;
}
// The image segment is the one intentional triangular mapping: an image with
// emoji_id normalizes to a market-face element so it can round-trip as the
// original wire type. Every other codec emits its own discriminant.
type CodecOutput<T extends MessageElementType> = T extends 'image'
? MessageElementOf<'image'> | MessageElementOf<'mface'>
: MessageElementOf<T>;
export interface ElementCodec<T extends MessageElementType = MessageElementType> {
/** S 收·转:MessageElement → OneBot 段。 */
toSegment?: (element: MessageElementOf<T>, ctx: ToSegmentContext) => Promise<JsonObject>;
/** P 发·解:OneBot 段 data → MessageElement(null = 无法构造;reply 可作为可选修饰段被跳过)。 */
fromSegment?: (
data: Record<string, unknown>,
options?: ParseMessageOptions,
) => Promise<CodecOutput<T> | null>;
}
type ElementCodecMap = Partial<{
readonly [T in MessageElementType]: ElementCodec<T>;
}>;
// ── 共享低层工具(原在 message-parser,移来供本表与 message-parser 的输入糖共用)──
export function intOr(value: unknown, fallback = 0): number {
if (value === undefined || value === null) {
return fallback;
}
if (typeof value === 'number') {
if (Number.isSafeInteger(value)) {
return value;
}
throw new MessageElementValidationError(
'INVALID_FIELD',
`numeric message segment field must be a safe integer, received ${String(value)}`,
);
}
if (typeof value === 'string') {
const text = value.trim();
if (!text) {
return fallback;
}
if (!/^[+-]?\d+$/.test(text)) {
throw new MessageElementValidationError(
'INVALID_FIELD',
`numeric message segment field must contain only an integer, received ${JSON.stringify(value)}`,
);
}
const parsed = Number(text);
if (Number.isSafeInteger(parsed)) {
return parsed;
}
}
throw new MessageElementValidationError(
'INVALID_FIELD',
`numeric message segment field must be a safe integer, received ${String(value)}`,
);
}
/** Build a market-face (`mface`) element from an OneBot segment's data.
* Shared by the dedicated `mface` segment and the `image`-with-`emoji_id`
* round-trip path. `emojiId` is the hex GUID the wire builder converts back
* to `MarketFace.faceId`. */
export function marketFaceElement(
emojiId: string,
data: Record<string, unknown>,
): MessageElementOf<'mface'> {
return {
type: 'mface',
text: String(data.summary ?? data.name ?? ''),
emojiId,
emojiPackageId: intOr(data.emoji_package_id ?? data.tab_id, 0),
emojiKey: String(data.key ?? ''),
};
}
/**
* Pick the best loadable source from a media segment's `file` / `url` / `path`
* / `media` fields.
*
* `file` is normally the canonical OneBot field and wins, but it can also be a
* QQ-internal media id (e.g. `<md5>.png`) that this process cannot resolve to a
* local path. When a bot framework echoes a received image back (Yunzai et al.
* resend the original `file=<md5>.ext` together with the download `url`), using
* `file` makes the send path `statSync` the id as a bogus local path and throw
* `ENOENT`. So: keep `file` when it is a directly loadable source (inline
* bytes, a remote url, or a filesystem path with a separator); otherwise, if a
* real http(s) `url` accompanies it, prefer that. (issue #155)
*/
export function isInlineMediaSource(value: string): boolean {
return /^(base64:\/\/|data:)/i.test(value);
}
export function pickMediaSource(data: Record<string, unknown>): string {
const file = String(data.file ?? '').trim();
const url = String(data.url ?? '').trim();
const fallback =
file || url || String(data.path ?? '').trim() || String(data.media ?? '').trim();
if (!file) {
return fallback;
}
// `file` is itself loadable: inline bytes, a remote url, or a path (anything
// carrying a `/` or `\` separator, incl. file:// and absolute/relative paths).
if (isInlineMediaSource(file) || /^(https?:\/\/|file:\/\/)/i.test(file) || /[\\/]/.test(file)) {
return file;
}
// `file` is a bare token (QQ-internal id) — fall back to a real url if present.
if (/^https?:\/\//i.test(url)) {
return url;
}
return fallback;
}
// ── codec 表:一种元素类型一条,键即 element.type(收·转)/ 段 type(发·解,二者同名)──
export const ELEMENT_CODECS = {
text: {
async toSegment(element) {
return { type: 'text', data: { text: element.text ?? '' } };
},
async fromSegment(data) {
const text = String(data.text ?? '');
return text ? { type: 'text', text } : null;
},
},
face: {
async toSegment(element) {
return {
type: 'face',
data: {
id: String(element.faceId ?? 0),
...(element.resultId !== undefined ? { resultId: element.resultId } : {}),
},
};
},
async fromSegment(data) {
const id = intOr(data.id, -1);
if (id < 0) {
return null;
}
const element: MessageElementOf<'face'> = { type: 'face', faceId: id };
if (data.resultId !== undefined) {
if (typeof data.resultId === 'string') {
element.resultId = data.resultId;
} else if (
typeof data.resultId === 'number' &&
Number.isSafeInteger(data.resultId) &&
data.resultId >= 0
) {
element.resultId = String(data.resultId);
} else {
throw new MessageElementValidationError(
'INVALID_FIELD',
'face.resultId must be a string or non-negative safe integer',
'face',
'resultId',
);
}
}
if (data.large !== undefined) {
if (
data.large === true ||
data.large === 'true' ||
data.large === 1 ||
data.large === '1'
) {
element.large = true;
} else if (
data.large === false ||
data.large === 'false' ||
data.large === 0 ||
data.large === '0'
) {
element.large = false;
} else {
throw new MessageElementValidationError(
'INVALID_FIELD',
'face.large must be a boolean',
);
}
}
return element;
},
},
at: {
async toSegment(element) {
const qq =
element.uid === 'all' || element.targetUin === 0
? 'all'
: String(element.targetUin ?? 0);
return { type: 'at', data: { qq } };
},
async fromSegment(data, options) {
const qq = String(data.qq ?? '').trim();
if (qq === 'all') {
return { type: 'at', targetUin: 0, uid: 'all', text: '@全体成员 ' };
}
const uin = intOr(qq, 0);
if (uin <= 0) {
return null;
}
const name = String(data.name ?? data.nickname ?? data.card ?? '').trim();
let uid = String(data.uid ?? '').trim();
if (!uid && options?.resolveMentionUid) {
uid = (await options.resolveMentionUid(uin))?.trim() ?? '';
}
const element: MessageElement = { type: 'at', targetUin: uin };
if (uid) {
element.uid = uid;
}
if (name) {
element.text = `@${name} `;
}
return element;
},
},
reply: {
async toSegment(element, ctx) {
const sentBySelf =
!ctx.isGroup && ctx.selfId > 0 && element.replySenderUin === ctx.selfId;
const eventName = ctx.isGroup
? GROUP_MESSAGE_EVENT
: privateMessageEventName(sentBySelf, false);
const id =
element.replyMessageId ??
(await resolveReplyId(
ctx.isGroup,
ctx.sessionId,
element.replySeq ?? 0,
ctx.messageIdResolver,
eventName,
element.replyTime && element.replyTime > 0
? element.replyTime
: Number.MAX_SAFE_INTEGER,
element.replyElements,
ctx.currentMessage,
));
return { type: 'reply', data: { id: String(id) } };
},
async fromSegment(data, options) {
let id: number;
try {
id = intOr(data.id, 0);
} catch (error) {
if (!(error instanceof MessageElementValidationError)) {
throw error;
}
log.warn('could not quote the target message');
return null;
}
// message_id is a signed int32 hash; only 0 is invalid (#371).
if (id === 0) {
log.warn('could not quote the target message');
return null;
}
const meta = (await options?.resolveReplyMeta?.(id)) ?? null;
if (options?.resolveReplySequence) {
const resolved = await options.resolveReplySequence(id);
if (typeof resolved === 'number' && resolved > 0) {
const element: MessageElement = {
type: 'reply',
replySeq: resolved,
replyMessageId: id, // Keep the original messageId for logging
};
// Try to get additional meta info for better reply display
if (meta) {
element.replySenderUin = meta.senderUin;
element.replyTime = meta.time;
element.replyRandom = meta.random;
element.replyElements = meta.elements;
}
return element;
}
}
// The id is known locally, but it came from an OIDB-only/synthetic
// message and therefore has no QQ server sequence. Treating the opaque
// OneBot id as a direct sequence would publish a malformed reply.
if (meta?.sequenceAuthoritative === false) {
log.warn('could not quote the target message');
return null;
}
// Backward-compatible path: allow direct seq reply IDs.
// A negative OneBot id is never a QQ sequence.
if (id > 0) {
return { type: 'reply', replySeq: id };
}
log.warn('could not quote the target message');
return null;
},
},
image: {
async toSegment(element, ctx) {
// Bot-built forwards cache the upload source on `url`. Incoming
// pictures use `imageUrl`. get_forward_msg must accept either —
// otherwise a just-sent merge comes back with empty file/url (#441).
const source = (element.imageUrl || element.url || '').trim();
const inline = isInlineMediaSource(source);
const resolved =
!inline && source && !element.imageUrl ? { ...element, imageUrl: source } : element;
const url = inline
? ''
: ctx.imageUrlResolver
? await ctx.imageUrlResolver(resolved, ctx.isGroup)
: source;
const data: JsonObject = {
url,
file:
element.fileId ||
(element.md5Hex ? `${element.md5Hex.toLowerCase()}.png` : '') ||
(inline ? '' : source),
sub_type: element.subType ?? 0,
summary: element.summary ?? '',
};
if (element.flash) {
data.type = 'flash';
}
if (ctx.mediaSegmentSink) {
await ctx.mediaSegmentSink('image', element, data, ctx.isGroup, ctx.sessionId);
}
return { type: 'image', data };
},
async fromSegment(data) {
// A market face that was surfaced as an `image` (see toSegment) can be
// echoed straight back: when `emoji_id` is present we rebuild the market
// face instead of re-uploading the gif as a plain picture.
const imgEmojiId = String(data.emoji_id ?? '').trim();
if (imgEmojiId) {
return marketFaceElement(imgEmojiId, data);
}
return {
type: 'image',
url: pickMediaSource(data),
flash: data.type === 'flash',
subType: intOr(data.sub_type ?? data.subType, 0),
summary: data.summary ? String(data.summary) : undefined,
};
},
},
record: {
async toSegment(element, ctx) {
const rawUrl = ctx.mediaUrlResolver
? await ctx.mediaUrlResolver(element, ctx.isGroup, ctx.sessionId)
: (element.url ?? '');
const url = isInlineMediaSource(rawUrl) ? '' : rawUrl;
const data: JsonObject = {
file: element.fileName ?? element.fileId ?? '',
url,
};
if (ctx.mediaSegmentSink) {
await ctx.mediaSegmentSink('record', element, data, ctx.isGroup, ctx.sessionId);
}
return { type: 'record', data };
},
async fromSegment(data) {
const source = pickMediaSource(data);
if (!source) {
return null;
}
return {
type: 'record',
url: source,
};
},
},
video: {
async toSegment(element, ctx) {
const rawUrl = ctx.mediaUrlResolver
? await ctx.mediaUrlResolver(element, ctx.isGroup, ctx.sessionId)
: (element.url ?? '');
const url = isInlineMediaSource(rawUrl) ? '' : rawUrl;
const data: JsonObject = {
file: element.fileName ?? element.fileId ?? '',
url,
};
if (ctx.mediaSegmentSink) {
await ctx.mediaSegmentSink('video', element, data, ctx.isGroup, ctx.sessionId);
}
return { type: 'video', data };
},
async fromSegment(data) {
const source = pickMediaSource(data);
if (!source) {
return null;
}
return {
type: 'video',
url: source,
thumbUrl: data.thumb ? String(data.thumb) : undefined,
};
},
},
json: {
async toSegment(element) {
return { type: 'json', data: { data: element.text ?? '' } };
},
async fromSegment(data) {
const raw = data.data;
const text =
typeof raw === 'object' && raw !== null && !Array.isArray(raw)
? JSON.stringify(raw)
: raw;
if (typeof text !== 'string' || !text.trim()) {
throw new MessageElementValidationError(
'INVALID_FIELD',
'message segment "json" field "data" must be a JSON object or non-empty JSON string',
'json',
'data',
);
}
return {
type: 'json',
text,
};
},
},
xml: {
async toSegment(element) {
return {
type: 'xml',
data: {
data: element.text ?? '',
resid: element.subType ?? 35,
},
};
},
async fromSegment(data) {
return {
type: 'xml',
text: String(data.data ?? ''),
subType: intOr(data.resid ?? data.id, 0),
};
},
},
file: {
async toSegment(element, ctx) {
const url = ctx.mediaUrlResolver
? await ctx.mediaUrlResolver(element, ctx.isGroup, ctx.sessionId)
: (element.url ?? '');
const fileName = element.fileName ?? '';
const fileSize = element.fileSize ?? 0;
const fileId = element.fileId ?? '';
return {
type: 'file',
data: {
// NapCat/LLOneBot-style canonical fields — most downstream
// OneBot adapters read these (`file`/`file_id`/`file_size`).
file: fileName,
file_id: fileId,
file_size: fileSize,
// Legacy SnowLuma field names, kept for backward compat with
// any consumer that already reads name/size/id.
name: fileName,
size: fileSize,
id: fileId,
url,
file_hash: element.fileHash ?? '',
},
};
},
async fromSegment(data) {
const fileId = String(data.file_id ?? data.fileId ?? data.id ?? '').trim();
// Reuse the media-source rule: a bare `file` token is commonly only the
// display filename/internal id and must not mask a loadable HTTP URL.
const source = pickMediaSource(data);
if (!fileId && !source) {
log.warn('[MsgParser] file segment without file_id or file/url is unsupported');
return null;
}
const fileName = String(data.name ?? data.filename ?? data.fileName ?? '').trim();
const fileSize = intOr(data.file_size ?? data.size ?? data.fileSize, 0);
const md5Hex = String(data.md5 ?? data.md5Hex ?? '').trim();
const sha1Hex = String(data.sha1 ?? data.sha1Hex ?? '').trim();
const fileHash = String(data.file_hash ?? data.fileHash ?? '').trim();
const elem: MessageElement = fileId
? { type: 'file', fileId }
: { type: 'file', url: source };
if (fileName) {
elem.fileName = fileName;
}
if (fileSize > 0) {
elem.fileSize = fileSize;
}
if (md5Hex) {
elem.md5Hex = md5Hex;
}
if (sha1Hex) {
elem.sha1Hex = sha1Hex;
}
if (fileHash) {
elem.fileHash = fileHash;
}
return elem;
},
},
mface: {
async toSegment(element) {
// Unify market faces (商城表情) to an `image` segment so OneBot clients
// that don't special-case `mface` still render the sticker, while the
// `emoji_id`/`emoji_package_id`/`key` markers let aware clients (and our
// own send path) reproduce it as a real market face. Mirrors NapCat's
// marketFaceElement → image conversion. The gxh URL is a self-contained
// external link (no rkey), so we set it directly and skip mediaSegmentSink.
const emojiId = element.emojiId ?? '';
const dir = emojiId.slice(0, 2);
const url = emojiId
? `https://gxh.vip.qq.com/club/item/parcel/item/${dir}/${emojiId}/raw300.gif`
: '';
return {
type: 'image',
data: {
file: emojiId ? `${dir}-${emojiId}.gif` : '',
url,
summary: element.text ?? '',
sub_type: 0,
emoji_id: emojiId,
emoji_package_id: element.emojiPackageId ?? 0,
key: element.emojiKey ?? '',
},
};
},
async fromSegment(data) {
// Market face (商城表情). emoji_id is the hex GUID; without it we can't
// construct the wire element, so drop the segment.
const emojiId = String(data.emoji_id ?? '').trim();
if (!emojiId) {
log.warn('[MsgParser] mface segment without emoji_id is unsupported');
return null;
}
return marketFaceElement(emojiId, data);
},
},
poke: {
async toSegment(element) {
return {
type: 'poke',
data: {
type: element.subType ?? 0,
},
};
},
async fromSegment(data) {
if (data.type === undefined && data.id === undefined) {
return null;
}
return {
type: 'poke',
subType: intOr(data.type ?? data.id, 0),
};
},
},
// 闪传文件 (flash transfer) — receive-only. Decoded from an older-client
// richui markdown card (#199/#200). Sending uses the send_flash_msg action,
// so there is no fromSegment.
flash_file: {
async toSegment(element) {
return {
type: 'flash_file',
data: {
title: element.fileName ?? '',
file_set_id: element.filesetId ?? '',
scene_type: element.sceneType ?? 0,
thumb: element.thumbUrl ?? '',
},
};
},
},
forward: {
async toSegment(element) {
return {
type: 'forward',
data: { id: element.resId ?? '' },
};
},
async fromSegment(data) {
return {
type: 'forward',
resId: String(data.id ?? data.res_id ?? data.forward_id ?? ''),
};
},
},
markdown: {
async toSegment(element) {
return {
type: 'markdown',
data: { content: element.text },
};
},
async fromSegment(data) {
return {
type: 'markdown',
text: String(data.content ?? ''),
};
},
},
inline_keyboard: {
async toSegment(element) {
return {
type: 'inline_keyboard',
data: {
bot_appid: element.botAppid,
rows: element.rows.map((row) => ({
buttons: row.buttons.map((button) => ({
id: button.id,
label: button.label,
visited_label: button.visitedLabel,
style: button.style,
type: button.type,
click_limit: button.clickLimit,
unsupport_tips: button.unsupportedTips,
data: button.data,
at_bot_show_channel_list: button.atBotShowChannelList,
permission_type: button.permissionType,
specify_role_ids: button.specifyRoleIds,
specify_user_ids: button.specifyUserIds,
is_reply: button.isReply,
enter: button.enter,
anchor: button.anchor,
})),
})),
},
};
},
},
} satisfies ElementCodecMap;
/**
* Dynamic lookup boundary. Each table entry is checked against its precise
* discriminant above; callers with a runtime type receive the safe union view.
*/
export function getElementCodec(type: string): ElementCodec | undefined {
if (!Object.hasOwn(ELEMENT_CODECS, type)) {
return undefined;
}
return (ELEMENT_CODECS as unknown as Record<string, ElementCodec>)[type];
}
@@ -0,0 +1,32 @@
import type { JsonObject } from '../types';
import type { ConverterContext } from './index';
// The OneBot post header every converted event shares: `time` + `self_id` +
// `post_type`. Previously each of the ~17 converters hand-wrote it, so the
// shared shape could drift. These builders concentrate it — a converter just
// supplies its `post_type`-specific fields.
type Timed = { time: number };
/** notice envelope — `{ time, self_id, post_type: 'notice', ...fields }`. */
export function notice(ctx: ConverterContext, event: Timed, fields: JsonObject): JsonObject {
return { time: event.time, self_id: ctx.selfId, post_type: 'notice', ...fields };
}
/** request envelope — `{ time, self_id, post_type: 'request', ...fields }`. */
export function request(ctx: ConverterContext, event: Timed, fields: JsonObject): JsonObject {
return { time: event.time, self_id: ctx.selfId, post_type: 'request', ...fields };
}
/**
* message envelope — `{ time, self_id, post_type, ...fields }`. `postType` is
* 'message' or 'message_sent' (self-echo), decided per-message by the caller.
*/
export function message(
ctx: ConverterContext,
event: Timed,
postType: string,
fields: JsonObject,
): JsonObject {
return { time: event.time, self_id: ctx.selfId, post_type: postType, ...fields };
}
@@ -0,0 +1,135 @@
import type { MessageElement, QQEventVariant } from '@snowluma/protocol/events';
import type { JsonObject } from '../types';
import type { PrivateQuoteSource } from '../private-quote';
import { convertFriendMessage, convertGroupMessage, convertTempMessage } from './to-message';
import {
convertBotOffline,
convertFriendAdd,
convertFriendInputStatus,
convertFriendProfileLike,
convertFriendPoke,
convertFriendRecall,
convertGroupAdmin,
convertGroupCardChange,
convertGroupEssence,
convertGroupFileUpload,
convertGroupMemberJoin,
convertGroupMemberLeave,
convertGroupMsgEmojiLike,
convertGroupMute,
convertGroupNameChange,
convertGroupTitleChange,
convertGroupPoke,
convertGroupRecall,
} from './to-notice';
import { convertFriendRequest, convertGroupInvite } from './to-request';
export { elementsToOneBotSegments } from './to-segment';
export type ImageUrlResolver = (
element: MessageElement,
isGroup: boolean,
) => string | Promise<string>;
export type MediaUrlResolver = (
element: MessageElement,
isGroup: boolean,
sessionId: number,
) => Promise<string>;
export type MessageIdResolver = (
isGroup: boolean,
sessionId: number,
sequence: number,
eventName: string,
timestamp?: number,
replyElements?: readonly MessageElement[],
excludedMessage?: PrivateQuoteSource,
) => number | Promise<number>;
export type MediaSegmentSink = (
mediaType: 'image' | 'record' | 'video',
element: MessageElement,
data: JsonObject,
isGroup: boolean,
sessionId: number,
) => void | Promise<void>;
// ─────────────── context ───────────────
export interface ConverterContext {
selfId: number;
imageUrlResolver: ImageUrlResolver | null;
mediaUrlResolver: MediaUrlResolver | null;
messageIdResolver: MessageIdResolver | null;
mediaSegmentSink: MediaSegmentSink | null;
}
// ─────────────── dispatcher ───────────────
/** Converter for one event kind — receives the narrowed event for that kind. */
type ConverterFor<K extends QQEventVariant['kind']> = (
ctx: ConverterContext,
event: Extract<QQEventVariant, { kind: K }>,
) => JsonObject | Promise<JsonObject>;
/**
* Every event kind → its converter (or `null` for kinds intentionally not
* surfaced to OneBot clients). The mapped type is TOTAL over the union, so a
* new `QQEventVariant` kind that forgets a converter is a compile error here —
* not a silent `null` at runtime.
*/
export type ConverterRegistry = { [K in QQEventVariant['kind']]: ConverterFor<K> | null };
export const CONVERTERS = {
// Messages.
friend_message: convertFriendMessage,
group_message: convertGroupMessage,
temp_message: convertTempMessage,
// Notices.
group_member_join: convertGroupMemberJoin,
group_member_leave: convertGroupMemberLeave,
group_mute: convertGroupMute,
group_admin: convertGroupAdmin,
friend_recall: convertFriendRecall,
group_recall: convertGroupRecall,
friend_poke: convertFriendPoke,
group_poke: convertGroupPoke,
group_essence: convertGroupEssence,
group_file_upload: convertGroupFileUpload,
friend_add: convertFriendAdd,
friend_input_status: convertFriendInputStatus,
friend_profile_like: convertFriendProfileLike,
bot_offline: convertBotOffline,
group_name_change: convertGroupNameChange,
group_title_change: convertGroupTitleChange,
group_card_change: convertGroupCardChange,
group_msg_emoji_like: convertGroupMsgEmojiLike,
// Requests.
friend_request: convertFriendRequest,
group_invite: convertGroupInvite,
// Internal-only: async voice-to-text result, correlated by a pending
// fetch_ptt_text; never emitted as an OneBot event.
ptt_trans_result: null,
// Internal-only cache snapshot consumed by Bridge.getOnlineClients().
online_devices_changed: null,
// Internal-only identity synchronization from another QQ client.
friend_remark_changed: null,
} satisfies ConverterRegistry;
export async function convertEvent(
ctx: ConverterContext,
event: QQEventVariant,
): Promise<JsonObject | null> {
const converter = CONVERTERS[event.kind];
if (!converter) {
return null;
}
// The registry ties each key to its own event kind, but indexing by a
// runtime `event.kind` loses that correlation — one localized cast restores it.
return (
converter as (
ctx: ConverterContext,
event: QQEventVariant,
) => JsonObject | Promise<JsonObject>
)(ctx, event);
}
@@ -0,0 +1,149 @@
import type { MessageElement, QQEventVariant } from '@snowluma/protocol/events';
import { segmentsToRawMessage } from '../helper/cq';
import { GROUP_MESSAGE_EVENT, PRIVATE_MESSAGE_EVENT, privateMessageEventName } from '../message-id';
import type { JsonObject } from '../types';
import type { ConverterContext } from './index';
import { elementsToOneBotSegments } from './to-segment';
import { applyMessageIdResolver } from './utils';
import { message } from './envelope';
type FriendMessage = Extract<QQEventVariant, { kind: 'friend_message' }>;
type GroupMessage = Extract<QQEventVariant, { kind: 'group_message' }>;
type TempMessage = Extract<QQEventVariant, { kind: 'temp_message' }>;
function toSegments(
ctx: ConverterContext,
elements: MessageElement[],
isGroup: boolean,
sessionId: number,
) {
return elementsToOneBotSegments(ctx, elements, isGroup, sessionId);
}
/**
* friend_message and temp_message differ only in `sub_type` ('friend' vs
* 'group'). Friend messages use their conversation peer as the session key,
* which differs from senderUin on a self-sent echo.
*/
async function convertPrivateMessage(
ctx: ConverterContext,
event: FriendMessage | TempMessage,
subType: 'friend' | 'group',
): Promise<JsonObject> {
const isSelf = event.senderUin === ctx.selfId;
const postType = isSelf ? 'message_sent' : 'message';
const sessionId =
event.kind === 'friend_message' && event.peerUin && event.peerUin > 0
? event.peerUin
: event.senderUin;
// ContentHead field 5 (`msgSeq`) is sender-local in C2C and can collide in
// both directions. Friend history/pushes expose field 11 (`ntMsgSeq`), which
// is conversation-wide and therefore the canonical OneBot id input.
const ntSequence = event.kind === 'friend_message' ? (event.ntMsgSeq ?? 0) : 0;
const hasCanonicalNtSequence = ntSequence > 0 && event.sequenceAuthoritative !== false;
const messageIdSequence = hasCanonicalNtSequence ? ntSequence : event.msgSeq;
const eventName =
event.kind === 'friend_message'
? privateMessageEventName(isSelf, hasCanonicalNtSequence)
: PRIVATE_MESSAGE_EVENT;
const messageId = await applyMessageIdResolver(
ctx.messageIdResolver,
false,
sessionId,
messageIdSequence,
eventName,
);
const segments = await elementsToOneBotSegments(ctx, event.elements, false, sessionId, {
currentMessage: {
id: messageId,
sequence: hasCanonicalNtSequence ? ntSequence : 0,
clientSequence:
event.kind === 'friend_message' ? (event.clientSeq ?? event.msgSeq) : event.msgSeq,
timestamp: event.time,
sentBySelf: isSelf,
},
});
const sender: JsonObject = {
user_id: event.senderUin,
nickname: event.senderNick,
sex: 'unknown',
age: 0,
};
// A temp (group-originated) message carries its source group so a client can
// reply via send_private_msg(user_id, group_id=sender.group_id). Mirrors
// go-cqhttp / NapCat, which expose it on sender.group_id.
if (subType === 'group' && 'groupId' in event && event.groupId > 0) {
sender.group_id = event.groupId;
}
const fields: JsonObject = {
message_type: 'private',
sub_type: subType,
message_id: messageId,
message_seq: event.msgSeq,
user_id: event.senderUin,
message: segments,
raw_message: segmentsToRawMessage(segments),
font: 0,
sender,
};
if (event.autoReply) {
fields.auto_reply = true;
}
if (isSelf && sessionId > 0 && sessionId !== ctx.selfId) {
fields.target_id = sessionId;
}
return message(ctx, event, postType, fields);
}
export function convertFriendMessage(
ctx: ConverterContext,
event: FriendMessage,
): Promise<JsonObject> {
return convertPrivateMessage(ctx, event, 'friend');
}
export function convertTempMessage(ctx: ConverterContext, event: TempMessage): Promise<JsonObject> {
return convertPrivateMessage(ctx, event, 'group');
}
export async function convertGroupMessage(
ctx: ConverterContext,
event: GroupMessage,
): Promise<JsonObject> {
const isSelf = event.senderUin === ctx.selfId;
const postType = isSelf ? 'message_sent' : 'message';
const messageId = await applyMessageIdResolver(
ctx.messageIdResolver,
true,
event.groupId,
event.msgSeq,
GROUP_MESSAGE_EVENT,
);
const segments = await toSegments(ctx, event.elements, true, event.groupId);
return message(ctx, event, postType, {
message_type: 'group',
sub_type: 'normal',
message_id: messageId,
message_seq: event.msgSeq,
group_id: event.groupId,
group_name: event.groupName,
user_id: event.senderUin,
message: segments,
raw_message: segmentsToRawMessage(segments),
font: 0,
sender: {
user_id: event.senderUin,
nickname: event.senderNick,
card: event.senderCard,
role: event.senderRole || 'member',
sex: 'unknown',
age: 0,
},
anonymous: null,
});
}
@@ -0,0 +1,300 @@
import type { QQEventVariant } from '@snowluma/protocol/events';
import { GROUP_MESSAGE_EVENT, privateMessageEventName } from '../message-id';
import type { JsonObject } from '../types';
import type { ConverterContext } from './index';
import { applyMessageIdResolver, isSameActor } from './utils';
import { notice } from './envelope';
type GroupMemberJoin = Extract<QQEventVariant, { kind: 'group_member_join' }>;
type GroupMemberLeave = Extract<QQEventVariant, { kind: 'group_member_leave' }>;
type GroupMute = Extract<QQEventVariant, { kind: 'group_mute' }>;
type GroupAdmin = Extract<QQEventVariant, { kind: 'group_admin' }>;
type FriendRecall = Extract<QQEventVariant, { kind: 'friend_recall' }>;
type GroupRecall = Extract<QQEventVariant, { kind: 'group_recall' }>;
type FriendPoke = Extract<QQEventVariant, { kind: 'friend_poke' }>;
type GroupPoke = Extract<QQEventVariant, { kind: 'group_poke' }>;
type GroupEssence = Extract<QQEventVariant, { kind: 'group_essence' }>;
type GroupFileUpload = Extract<QQEventVariant, { kind: 'group_file_upload' }>;
type FriendAdd = Extract<QQEventVariant, { kind: 'friend_add' }>;
type GroupMsgEmojiLike = Extract<QQEventVariant, { kind: 'group_msg_emoji_like' }>;
type FriendInputStatus = Extract<QQEventVariant, { kind: 'friend_input_status' }>;
type GroupNameChange = Extract<QQEventVariant, { kind: 'group_name_change' }>;
type GroupCardChange = Extract<QQEventVariant, { kind: 'group_card_change' }>;
type GroupTitleChange = Extract<QQEventVariant, { kind: 'group_title_change' }>;
type FriendProfileLike = Extract<QQEventVariant, { kind: 'friend_profile_like' }>;
type BotOffline = Extract<QQEventVariant, { kind: 'bot_offline' }>;
export function convertGroupMemberJoin(ctx: ConverterContext, event: GroupMemberJoin): JsonObject {
return notice(ctx, event, {
notice_type: 'group_increase',
sub_type:
event.joinType ??
(isSameActor(event.operatorUin, event.operatorUid, event.userUin, event.userUid)
? 'approve'
: 'invite'),
group_id: event.groupId,
operator_id: event.operatorUin,
user_id: event.userUin,
invitor_id: event.invitorUin ?? 0,
invitor_status: (event.invitorUin ?? 0) > 0 ? 'known' : 'unknown',
});
}
export function convertGroupMemberLeave(
ctx: ConverterContext,
event: GroupMemberLeave,
): JsonObject {
let subType: string;
switch (event.leaveType) {
case 'disband':
subType = 'disband';
break;
case 'kick':
subType = event.userUin === ctx.selfId ? 'kick_me' : 'kick';
break;
default:
subType = 'leave';
}
return notice(ctx, event, {
notice_type: 'group_decrease',
sub_type: subType,
group_id: event.groupId,
operator_id: event.operatorUin,
user_id: event.userUin,
});
}
export function convertGroupMute(ctx: ConverterContext, event: GroupMute): JsonObject {
return notice(ctx, event, {
notice_type: 'group_ban',
sub_type: event.duration > 0 ? 'ban' : 'lift_ban',
group_id: event.groupId,
operator_id: event.operatorUin,
user_id: event.userUin,
duration: event.duration,
});
}
export function convertGroupAdmin(ctx: ConverterContext, event: GroupAdmin): JsonObject {
return notice(ctx, event, {
notice_type: 'group_admin',
sub_type: event.set ? 'set' : 'unset',
group_id: event.groupId,
user_id: event.userUin,
});
}
export async function convertFriendRecall(
ctx: ConverterContext,
event: FriendRecall,
): Promise<JsonObject> {
const messageId = await applyMessageIdResolver(
ctx.messageIdResolver,
false,
event.userUin,
event.msgSeq,
privateMessageEventName(event.recalledBySelf === true, false),
event.time,
);
return notice(ctx, event, {
notice_type: 'friend_recall',
user_id: event.userUin,
message_id: messageId,
});
}
export async function convertGroupRecall(
ctx: ConverterContext,
event: GroupRecall,
): Promise<JsonObject> {
const messageId = await applyMessageIdResolver(
ctx.messageIdResolver,
true,
event.groupId,
event.msgSeq,
GROUP_MESSAGE_EVENT,
);
return notice(ctx, event, {
notice_type: 'group_recall',
group_id: event.groupId,
operator_id: event.operatorUin,
user_id: event.authorUin,
message_id: messageId,
});
}
export function convertFriendPoke(ctx: ConverterContext, event: FriendPoke): JsonObject {
return notice(ctx, event, {
notice_type: 'notify',
sub_type: 'poke',
user_id: event.peerUin,
sender_id: event.senderUin,
target_id: event.targetUin,
action: event.action,
suffix: event.suffix,
action_img_url: event.actionImgUrl,
});
}
export function convertGroupPoke(ctx: ConverterContext, event: GroupPoke): JsonObject {
return notice(ctx, event, {
notice_type: 'notify',
sub_type: 'poke',
group_id: event.groupId,
user_id: event.userUin,
target_id: event.targetUin,
action: event.action,
suffix: event.suffix,
action_img_url: event.actionImgUrl,
});
}
export async function convertGroupEssence(
ctx: ConverterContext,
event: GroupEssence,
): Promise<JsonObject> {
const messageId = await applyMessageIdResolver(
ctx.messageIdResolver,
true,
event.groupId,
event.msgSeq,
GROUP_MESSAGE_EVENT,
);
return notice(ctx, event, {
notice_type: 'essence',
sub_type: event.set ? 'add' : 'delete',
group_id: event.groupId,
user_id: event.senderUin,
sender_id: event.senderUin,
operator_id: event.operatorUin,
message_id: messageId,
message_seq: event.msgSeq,
random: event.random,
});
}
export function convertGroupFileUpload(ctx: ConverterContext, event: GroupFileUpload): JsonObject {
return notice(ctx, event, {
notice_type: 'group_upload',
group_id: event.groupId,
user_id: event.userUin,
file: {
id: event.fileId,
name: event.fileName,
size: event.fileSize,
busid: event.busId,
},
});
}
export function convertFriendAdd(ctx: ConverterContext, event: FriendAdd): JsonObject {
return notice(ctx, event, {
notice_type: 'friend_add',
user_id: event.userUin,
});
}
export function convertGroupCardChange(ctx: ConverterContext, event: GroupCardChange): JsonObject {
// Mirrors NapCat's OB11GroupCardEvent: notice_type group_card with the new and
// old group cards.
return notice(ctx, event, {
notice_type: 'group_card',
group_id: event.groupId,
user_id: event.userUin,
card_new: event.cardNew,
card_old: event.cardOld,
});
}
export function convertBotOffline(ctx: ConverterContext, event: BotOffline): JsonObject {
// Mirrors NapCat's OB11BotOfflineEvent: notice_type bot_offline with the bot's
// own uin plus the tag/message describing why it went offline.
return notice(ctx, event, {
notice_type: 'bot_offline',
user_id: event.selfUin,
tag: event.tag,
message: event.message,
});
}
export function convertFriendProfileLike(
ctx: ConverterContext,
event: FriendProfileLike,
): JsonObject {
// Mirrors NapCat's OB11ProfileLikeEvent: notify/profile_like with the liker's
// uin, nickname and like count.
return notice(ctx, event, {
notice_type: 'notify',
sub_type: 'profile_like',
operator_id: event.operatorUin,
operator_nick: event.operatorNick,
times: event.times,
});
}
export function convertGroupTitleChange(
ctx: ConverterContext,
event: GroupTitleChange,
): JsonObject {
// Mirrors NapCat's OB11GroupTitleEvent: notify/title with the recipient's uin
// and the granted title text.
return notice(ctx, event, {
notice_type: 'notify',
sub_type: 'title',
group_id: event.groupId,
user_id: event.userUin,
title: event.title,
});
}
export function convertGroupNameChange(ctx: ConverterContext, event: GroupNameChange): JsonObject {
// Mirrors NapCat's OB11GroupNameEvent: notify/group_name with the operator's
// uin as user_id and the new name in `name_new`.
return notice(ctx, event, {
notice_type: 'notify',
sub_type: 'group_name',
group_id: event.groupId,
user_id: event.operatorUin,
name_new: event.name,
});
}
export function convertFriendInputStatus(
ctx: ConverterContext,
event: FriendInputStatus,
): JsonObject {
// Mirrors NapCat's OB11InputStatusEvent: notify/input_status with the peer's
// uin, the raw event_type (1 = typing, 3 = recording voice) and status_text.
return notice(ctx, event, {
notice_type: 'notify',
sub_type: 'input_status',
user_id: event.userUin,
event_type: event.eventType,
status_text: event.statusText,
});
}
export async function convertGroupMsgEmojiLike(
ctx: ConverterContext,
event: GroupMsgEmojiLike,
): Promise<JsonObject> {
const messageId = await applyMessageIdResolver(
ctx.messageIdResolver,
true,
event.groupId,
event.msgSeq,
GROUP_MESSAGE_EVENT,
);
return notice(ctx, event, {
notice_type: 'group_msg_emoji_like',
sub_type: event.isAdd ? 'add' : 'remove',
group_id: event.groupId,
user_id: event.operatorUin,
operator_id: event.operatorUin,
message_id: messageId,
message_seq: event.msgSeq,
likes: [{ emoji_id: event.emojiId, count: event.count }],
});
}
@@ -0,0 +1,29 @@
import type { QQEventVariant } from '@snowluma/protocol/events';
import type { JsonObject } from '../types';
import type { ConverterContext } from './index';
import { request } from './envelope';
type FriendRequest = Extract<QQEventVariant, { kind: 'friend_request' }>;
type GroupInvite = Extract<QQEventVariant, { kind: 'group_invite' }>;
export function convertFriendRequest(ctx: ConverterContext, event: FriendRequest): JsonObject {
return request(ctx, event, {
request_type: 'friend',
user_id: event.fromUin,
comment: event.message,
flag: event.flag,
});
}
export function convertGroupInvite(ctx: ConverterContext, event: GroupInvite): JsonObject {
const invitedId = event.invitedUin ?? 0;
return request(ctx, event, {
request_type: 'group',
sub_type: event.subType || 'invite',
group_id: event.groupId,
user_id: event.fromUin,
...(invitedId > 0 ? { invited_id: invitedId } : {}),
comment: event.message,
flag: event.flag,
});
}
@@ -0,0 +1,127 @@
import type { MessageElement } from '@snowluma/protocol/events';
import type { JsonArray, JsonObject } from '../types';
import type { ToSegmentContext } from './element-codecs';
import { getElementCodec } from './element-codecs';
import { createLogger } from '@snowluma/common/logger';
import type { ConverterContext } from './index';
import { attachMessageMedia } from '../message-media';
import type { PrivateQuoteSource } from '../private-quote';
const log = createLogger('OneBot');
export async function elementsToOneBotSegments(
ctx: ConverterContext,
elements: MessageElement[],
isGroup: boolean,
sessionId: number,
options: { includeReplyContent?: boolean; currentMessage?: PrivateQuoteSource } = {},
): Promise<JsonArray> {
const segmentCtx: ToSegmentContext = {
...ctx,
isGroup,
sessionId,
mediaSegmentSink: null,
currentMessage: options.currentMessage,
};
const result: JsonArray = [];
for (const element of elements) {
let segment: JsonObject;
// One malformed element shouldn't drop the whole message — skip it (with a
// breadcrumb) and keep converting the rest.
try {
segment = await elementToSegment(element, segmentCtx);
} catch (err) {
log.warn(
'segment convert skipped type=%s (%s)',
element.type,
err instanceof Error ? err.message : String(err),
);
continue;
}
if (
element.type === 'reply' &&
(segment.data as JsonObject).id === '0' &&
!options.includeReplyContent
) {
log.warn(
'quote target unresolved: group=%s session=%d seq=%d time=%s; omitting reply id',
isGroup,
sessionId,
element.replySeq,
element.replyTime,
);
continue;
}
// Forward quotes often refer to messages outside the local account's
// history. Carry their inline content so consumers can resend the node.
if (
options.includeReplyContent &&
element.type === 'reply' &&
element.replyElements?.length
) {
const message = await elementsToOneBotSegments(
ctx,
element.replyElements.filter((quoted) => quoted.type !== 'reply'),
isGroup,
sessionId,
);
if (message.length > 0) {
const data = segment.data as JsonObject;
data.message = message;
if (element.replySenderUin) {
data.qq = String(element.replySenderUin);
}
if (element.replyTime !== undefined) {
data.time = String(element.replyTime);
}
if (element.replySeq) {
data.seq = String(element.replySeq);
}
}
}
// Persistence failures must propagate, not masquerade as bad wire elements.
attachMessageMedia(segment, element);
if (element.type === 'image' || element.type === 'record' || element.type === 'video') {
await ctx.mediaSegmentSink?.(
element.type,
element,
segment.data as JsonObject,
isGroup,
sessionId,
);
}
result.push(segment);
if (element.type === 'markdown' && element.text) {
result.push({ type: 'text', data: { text: element.text } });
}
}
return result;
}
async function elementToSegment(
element: MessageElement,
ctx: ToSegmentContext,
): Promise<JsonObject> {
// S 收·转:按 element.type 查 codec 表;缺条目则走 default 透传(保持原兜底)。
const codec = getElementCodec(element.type);
if (codec?.toSegment) {
return codec.toSegment(element, ctx);
}
log.warn('segment convert fallback type=%s reason=no receive-side codec', element.type);
return { type: element.type, data: {} };
}
@@ -0,0 +1,74 @@
import { GROUP_MESSAGE_EVENT, PRIVATE_MESSAGE_EVENT } from '../message-id';
import type { MessageIdResolver } from './index';
import type { MessageElement } from '@snowluma/protocol/events';
import type { PrivateQuoteSource } from '../private-quote';
export function isSameActor(
leftUin: number,
leftUid: string | undefined,
rightUin: number,
rightUid: string | undefined,
): boolean {
if (leftUin > 0 && rightUin > 0) {
return leftUin === rightUin;
}
return Boolean(leftUid) && leftUid === rightUid;
}
export async function applyMessageIdResolver(
resolver: MessageIdResolver | null,
isGroup: boolean,
sessionId: number,
sequence: number,
eventName: string,
timestamp?: number,
): Promise<number> {
if (resolver) {
const resolved = await resolver(isGroup, sessionId, sequence, eventName, timestamp);
if (Number.isInteger(resolved) && resolved !== 0) {
return resolved;
}
}
const seq = Math.trunc(sequence);
return seq === 0 ? 0 : seq;
}
export async function resolveReplyId(
isGroup: boolean,
sessionId: number,
sequence: number,
resolver?: MessageIdResolver | null,
eventName = isGroup ? GROUP_MESSAGE_EVENT : PRIVATE_MESSAGE_EVENT,
timestamp?: number,
replyElements?: readonly MessageElement[],
excludedMessage?: PrivateQuoteSource,
): Promise<number> {
const seq = Math.trunc(sequence);
if (seq === 0) {
return 0;
}
if (resolver) {
const resolved = await resolver(
isGroup,
sessionId,
seq,
eventName,
timestamp,
replyElements,
excludedMessage,
);
if (Number.isInteger(resolved)) {
return resolved;
}
throw new Error('reply resolver returned an invalid message id');
}
return seq;
}
+67
View File
@@ -0,0 +1,67 @@
import type { JsonObject, JsonValue, MessageFormat } from './types';
export interface EventReportOptions {
messageFormat: MessageFormat;
reportSelfMessage: boolean;
}
export function resolveReportOptions(network: {
messageFormat?: MessageFormat;
reportSelfMessage?: boolean;
}): EventReportOptions {
return {
messageFormat: network.messageFormat ?? 'array',
reportSelfMessage: network.reportSelfMessage ?? false,
};
}
export interface DispatchPayload {
isSelfMessage: boolean;
arrayJson: string;
stringJson: string;
}
export function buildDispatchPayload(event: JsonObject): DispatchPayload {
const isSelfMessage = event.post_type === 'message_sent';
const arrayJson = JSON.stringify(event);
let stringJson = arrayJson;
const hasMessage = event.post_type === 'message' || event.post_type === 'message_sent';
if (hasMessage && Array.isArray(event.message)) {
const raw = typeof event.raw_message === 'string' ? event.raw_message : '';
stringJson = JSON.stringify({ ...event, message: raw as JsonValue });
}
return { isSelfMessage, arrayJson, stringJson };
}
export function pickDispatchJson(
payload: DispatchPayload,
options: EventReportOptions,
): string | null {
if (payload.isSelfMessage && !options.reportSelfMessage) {
return null;
}
return options.messageFormat === 'string' ? payload.stringJson : payload.arrayJson;
}
export function shapeEventForAdapter(
event: JsonObject,
options: EventReportOptions,
): JsonObject | null {
if (event.post_type === 'message_sent' && !options.reportSelfMessage) {
return null;
}
if (
options.messageFormat === 'string' &&
(event.post_type === 'message' || event.post_type === 'message_sent') &&
Array.isArray(event.message)
) {
const raw = typeof event.raw_message === 'string' ? event.raw_message : '';
return { ...event, message: raw as JsonValue };
}
return event;
}
+524
View File
@@ -0,0 +1,524 @@
import { createLogger, runWithTraceRequest, type Logger } from '@snowluma/common/logger';
import { renderParamsVerbose } from '@snowluma/common/log-summary';
import type { QQEventVariant } from '@snowluma/protocol/events';
import { CONVERTERS, convertEvent } from './event-converter';
import type { OneBotInstanceContext } from './instance-context';
import {
GROUP_MESSAGE_EVENT,
PRIVATE_MESSAGE_EVENT,
hashMessageIdInt32,
privateMessageEventName,
} from './message-id';
import { backfillReplyTarget } from './modules/message-actions';
import { deliverPttTransText, pttTransKey } from './modules/ptt-trans-waiter';
import type { MessageMeta } from './types';
const moduleLog = createLogger('Event');
/** Lifecycle handle for the asynchronous bridge-event pipeline.
*
* `stop()` is synchronous and idempotent: it removes every subscription so no
* new conversion can start. `drain()` resolves only after conversions that had
* already started have settled. Instance teardown must call them in that order
* before closing stores used by conversion/backfill/dispatch. */
export interface EventPipelineHandle {
stop(): void;
drain(): Promise<void>;
}
export function registerEventPipeline(ctx: OneBotInstanceContext): EventPipelineHandle {
const uinNum = Number.parseInt(ctx.uin, 10);
const log =
Number.isFinite(uinNum) && uinNum > 0 ? moduleLog.child({ uin: uinNum }) : moduleLog;
const disposers: Array<() => void> = [];
const inFlight = new Set<Promise<void>>();
let accepting = true;
const track = (
event: QQEventVariant,
start: (state: EventTraceState) => void | Promise<void>,
): Promise<void> => {
if (!accepting) {
return Promise.resolve();
}
return runWithTraceRequest(() => {
const state: EventTraceState = {
startedAt: Date.now(),
handedOff: false,
terminalEmitted: false,
};
log.trace(() => [
'event_input kind=%s event=%s',
event.kind,
renderParamsVerbose(event),
]);
let operation: Promise<void>;
try {
operation = Promise.resolve(start(state));
} catch (error) {
operation = Promise.reject(error);
}
const tracked = operation.then(
() => undefined,
(error) => {
if (!state.handedOff && !state.terminalEmitted) {
traceEventTerminal(
log,
event.kind,
state,
'failed',
'pipeline_threw',
error,
);
}
log.error(
'event pipeline handler failed kind=%s: %s',
event.kind,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
},
);
inFlight.add(tracked);
void tracked.then(() => {
inFlight.delete(tracked);
});
return tracked;
});
};
for (const kind of Object.keys(EVENT_PIPELINE) as EventKind[]) {
const entry = EVENT_PIPELINE[kind];
if ('drop' in entry) {
continue;
}
const handle = entry.handle as PipelineHandler<EventKind>;
disposers.push(
ctx.bridge.events.on(kind, (event) =>
track(event, (state) => handle(ctx, log, event, state)),
),
);
}
const stop = (): void => {
if (!accepting) {
return;
}
accepting = false;
for (const dispose of disposers) {
try {
dispose();
} catch (error) {
log.error(
'event pipeline unsubscribe failed: %s',
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
}
}
};
return {
stop,
async drain(): Promise<void> {
// `stop()` makes the set monotonic: no later event can be admitted while
// this snapshot is settling.
if (accepting) {
throw new Error('event pipeline must be stopped before it can be drained');
}
await Promise.allSettled([...inFlight]);
},
};
}
type EventKind = QQEventVariant['kind'];
type PipelineHandler<K extends EventKind> = (
ctx: OneBotInstanceContext,
log: Logger,
event: Extract<QQEventVariant, { kind: K }>,
state: EventTraceState,
) => void | Promise<void>;
type PipelineDisposition<K extends EventKind> =
{ readonly handle: PipelineHandler<K> } | { readonly drop: true };
type PipelineRegistry = { [K in EventKind]: PipelineDisposition<K> };
interface EventTraceState {
startedAt: number;
handedOff: boolean;
terminalEmitted: boolean;
}
type PipelineEventOutcome = 'dropped' | 'failed' | 'internal';
type PipelineEventReason =
| 'converter_returned_null'
| 'converter_threw'
| 'pipeline_threw'
| 'recalled_before_backfill'
| 'recalled_after_backfill'
| 'waiter_notified'
| 'waiter_not_found';
async function convertAndDispatch(
ctx: OneBotInstanceContext,
log: Logger,
event: QQEventVariant,
state: EventTraceState,
messageIdOverride?: number,
): Promise<void> {
let preparedEvent = event;
if (event.kind === 'friend_message' && (await isFriendMessageRecalled(ctx, log, event))) {
traceEventTerminal(log, event.kind, state, 'dropped', 'recalled_before_backfill');
return;
}
// If this message quotes one we don't have, fetch + persist it first (gated +
// throttled) so a consumer's get_msg on the quote resolves. No-op for the
// common case (no reply, or the quoted message is already stored). Never let a
// back-fill failure block delivery of the live message.
try {
preparedEvent = await backfillReplyTarget(ctx, event);
} catch (error) {
// Best-effort — dispatch the live event regardless, but keep the failure
// attributable so a repeated store/server miss is diagnosable.
log.warn(
'reply backfill failed kind=%s: %s',
event.kind,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
log.trace(() => [
'event_branch kind=%s reason=reply_backfill_failed error=%s',
event.kind,
error instanceof Error ? (error.stack ?? error.message) : String(error),
]);
}
let converted;
try {
converted = await convertEvent(ctx.converterCtx, preparedEvent);
} catch (error) {
traceEventTerminal(log, event.kind, state, 'failed', 'converter_threw', error);
throw error;
}
if (!converted) {
traceEventTerminal(log, event.kind, state, 'dropped', 'converter_returned_null');
return;
}
if (messageIdOverride !== undefined && event.kind === 'friend_recall') {
converted.message_id = messageIdOverride;
}
log.trace(() => [
'event_converted kind=%s event=%s',
event.kind,
renderParamsVerbose(converted),
]);
// Backfill can await several network/media operations. Re-check immediately
// before dispatch so a recall that arrived during that gap wins the race.
if (event.kind === 'friend_message' && (await isFriendMessageRecalled(ctx, log, event))) {
traceEventTerminal(log, event.kind, state, 'dropped', 'recalled_after_backfill');
return;
}
await ctx.dispatchEvent(converted, 'bridge', state.startedAt);
state.handedOff = true;
}
function traceEventTerminal(
log: Logger,
kind: QQEventVariant['kind'],
state: EventTraceState,
outcome: PipelineEventOutcome,
reason: PipelineEventReason,
error?: unknown,
): void {
if (state.terminalEmitted) {
return;
}
state.terminalEmitted = true;
log.trace(() => [
'event_terminal kind=%s outcome=%s reason=%s ms=%d%s',
kind,
outcome,
reason,
Date.now() - state.startedAt,
error === undefined
? ''
: ` error=${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
]);
}
async function cacheGroupMessageMeta(
ctx: OneBotInstanceContext,
event: Extract<QQEventVariant, { kind: 'group_message' }>,
): Promise<void> {
const messageId = hashMessageIdInt32(event.msgSeq, event.groupId, GROUP_MESSAGE_EVENT);
await ctx.cacheMessageMeta(messageId, {
isGroup: true,
targetId: event.groupId,
sequence: event.msgSeq,
sequenceAuthoritative: true,
eventName: GROUP_MESSAGE_EVENT,
clientSequence: 0,
random: event.msgId,
timestamp: event.time,
});
}
async function cachePrivateMessageMeta(
ctx: OneBotInstanceContext,
sessionId: number,
messageSequence: number,
serverSequence: number,
clientSequence: number,
timestamp: number,
random: number,
sequenceAuthoritative: boolean,
privateDirection: MessageMeta['privateDirection'],
): Promise<void> {
const isFriendMessage = privateDirection !== undefined;
const hasNtSequence = isFriendMessage && sequenceAuthoritative && serverSequence > 0;
const eventName = isFriendMessage
? privateMessageEventName(privateDirection === 'outgoing', hasNtSequence)
: PRIVATE_MESSAGE_EVENT;
const messageId = hashMessageIdInt32(
hasNtSequence ? serverSequence : messageSequence,
sessionId,
eventName,
);
await ctx.cacheMessageMeta(messageId, {
isGroup: false,
targetId: sessionId,
sequence: serverSequence,
sequenceAuthoritative: sequenceAuthoritative && serverSequence > 0,
eventName,
clientSequence,
privateDirection,
random,
timestamp,
});
}
async function isFriendMessageRecalled(
ctx: OneBotInstanceContext,
log: Logger,
event: Extract<QQEventVariant, { kind: 'friend_message' }>,
): Promise<boolean> {
const peerUin = event.peerUin ?? event.senderUin;
const clientSequence = event.clientSeq ?? event.msgSeq;
const sentBySelf = event.senderUin === ctx.selfId;
const recalled = await ctx.messageStore.isPrivateMessageRecalled(
peerUin,
clientSequence,
sentBySelf,
event.time,
);
if (recalled) {
log.debug(
'friend message suppressed by recall tombstone peer=%d clientSeq=%d self=%s',
peerUin,
clientSequence,
String(sentBySelf),
);
}
return recalled;
}
async function cacheReaction(
ctx: OneBotInstanceContext,
event: Extract<QQEventVariant, { kind: 'group_msg_emoji_like' }>,
): Promise<void> {
if (!event.groupId || !event.msgSeq || !event.emojiId || !event.operatorUin) {
return;
}
if (event.isAdd) {
await ctx.reactionStore.recordAdd(
event.groupId,
event.msgSeq,
event.emojiId,
1,
event.operatorUin,
event.operatorUid,
event.time,
);
} else {
await ctx.reactionStore.recordRemove(
event.groupId,
event.msgSeq,
event.emojiId,
event.operatorUin,
);
}
}
async function handleGroupMessage(
ctx: OneBotInstanceContext,
log: Logger,
event: Extract<QQEventVariant, { kind: 'group_message' }>,
state: EventTraceState,
): Promise<void> {
await cacheGroupMessageMeta(ctx, event);
return convertAndDispatch(ctx, log, event, state);
}
async function handleFriendMessage(
ctx: OneBotInstanceContext,
log: Logger,
event: Extract<QQEventVariant, { kind: 'friend_message' }>,
state: EventTraceState,
): Promise<void> {
await cachePrivateMessageMeta(
ctx,
event.peerUin ?? event.senderUin,
event.msgSeq,
event.ntMsgSeq ?? 0,
event.clientSeq ?? event.msgSeq,
event.time,
event.msgId,
event.sequenceAuthoritative !== false,
event.senderUin === ctx.selfId ? 'outgoing' : 'incoming',
);
return convertAndDispatch(ctx, log, event, state);
}
async function handleTempMessage(
ctx: OneBotInstanceContext,
log: Logger,
event: Extract<QQEventVariant, { kind: 'temp_message' }>,
state: EventTraceState,
): Promise<void> {
await cachePrivateMessageMeta(
ctx,
event.senderUin,
event.msgSeq,
event.ntMsgSeq ?? 0,
event.clientSeq ?? 0,
event.time,
0,
event.sequenceAuthoritative !== false,
undefined,
);
ctx.tempSessions.record(event.senderUin, event.groupId);
return convertAndDispatch(ctx, log, event, state);
}
async function handleFriendRecall(
ctx: OneBotInstanceContext,
log: Logger,
event: Extract<QQEventVariant, { kind: 'friend_recall' }>,
state: EventTraceState,
): Promise<void> {
const clientSequence = event.clientSeq ?? event.msgSeq;
const recalled = await ctx.messageStore.recordPrivateRecall(
event.userUin,
clientSequence,
event.recalledBySelf === true,
event.time,
);
let messageIdOverride: number | undefined;
if (recalled !== null) {
messageIdOverride = recalled;
} else {
log.debug('friend recall cache miss peer=%d clientSeq=%d', event.userUin, clientSequence);
}
return convertAndDispatch(ctx, log, event, state, messageIdOverride);
}
async function handleGroupMsgEmojiLike(
ctx: OneBotInstanceContext,
log: Logger,
event: Extract<QQEventVariant, { kind: 'group_msg_emoji_like' }>,
state: EventTraceState,
): Promise<void> {
await cacheReaction(ctx, event);
return convertAndDispatch(ctx, log, event, state);
}
function handlePttTransResult(
_ctx: OneBotInstanceContext,
log: Logger,
event: Extract<QQEventVariant, { kind: 'ptt_trans_result' }>,
state: EventTraceState,
): void {
const notified = deliverPttTransText(pttTransKey(event.selfUin, event.msgId), event.text);
traceEventTerminal(
log,
event.kind,
state,
'internal',
notified ? 'waiter_notified' : 'waiter_not_found',
);
}
function convertOnly<K extends EventKind>(
ctx: OneBotInstanceContext,
log: Logger,
event: Extract<QQEventVariant, { kind: K }>,
state: EventTraceState,
): Promise<void> {
return convertAndDispatch(ctx, log, event, state);
}
const EVENT_PIPELINE = {
friend_message: { handle: handleFriendMessage },
group_message: { handle: handleGroupMessage },
temp_message: { handle: handleTempMessage },
group_member_join: { handle: convertOnly },
group_member_leave: { handle: convertOnly },
group_mute: { handle: convertOnly },
group_admin: { handle: convertOnly },
friend_recall: { handle: handleFriendRecall },
group_recall: { handle: convertOnly },
friend_request: { handle: convertOnly },
group_invite: { handle: convertOnly },
friend_poke: { handle: convertOnly },
group_poke: { handle: convertOnly },
group_essence: { handle: convertOnly },
group_file_upload: { handle: convertOnly },
friend_add: { handle: convertOnly },
friend_input_status: { handle: convertOnly },
friend_profile_like: { handle: convertOnly },
bot_offline: { handle: convertOnly },
group_name_change: { handle: convertOnly },
group_title_change: { handle: convertOnly },
group_card_change: { handle: convertOnly },
group_msg_emoji_like: { handle: handleGroupMsgEmojiLike },
ptt_trans_result: { handle: handlePttTransResult },
online_devices_changed: { drop: true },
friend_remark_changed: { drop: true },
} as const satisfies PipelineRegistry;
type ConvertedKind = {
[K in EventKind]: (typeof CONVERTERS)[K] extends null ? never : K;
}[EventKind];
type DroppedKind = {
[K in EventKind]: (typeof EVENT_PIPELINE)[K] extends { drop: true } ? K : never;
}[EventKind];
type ConvertedButDropped = ConvertedKind & DroppedKind;
const _convertedKindsAreHandled: [ConvertedButDropped] extends [never]
? true
: ConvertedButDropped = true;
void _convertedKindsAreHandled;
+267
View File
@@ -0,0 +1,267 @@
import { validateUploadConcurrency } from '@snowluma/protocol/highway/upload-settings';
import { createLogger } from '@snowluma/common/logger';
import fs from 'fs';
import path from 'path';
import type { JsonObject, RKeyConfig } from './types';
const log = createLogger('OneBot.GlobalConfig');
const CONFIG_DIR = 'config';
const GLOBAL_CONFIG_PATH = path.join(CONFIG_DIR, 'snowluma.json');
/**
* Global, all-accounts SnowLuma protocol settings — the home for small
* deployment-wide knobs that are NOT per-UIN (so they don't belong in
* `onebot_<uin>.json`). Persisted as `config/snowluma.json`. New small global
* settings should be added here rather than spawning a file/page per feature.
*/
export interface GlobalSettings {
/** Opt-in remote rkey fallback (see RKeyConfig). Default: servers empty = off. */
rkey: RKeyConfig;
/** Music-card signing service URL. Deployment-wide (same service for every
* account), so global rather than per-UIN. Empty = use the built-in default. */
musicSignUrl: string;
/** Video upload connections, 1–4. Omitted by older callers means one. */
videoUploadConcurrency?: number;
}
export function defaultGlobalSettings(): GlobalSettings {
return { rkey: { fallbackServers: [] }, musicSignUrl: '', videoUploadConcurrency: 1 };
}
/** Keep only well-formed, deduped http(s) URLs (must parse + have a host). */
export function normalizeRkeyServers(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set<string>();
const out: string[] = [];
for (const item of value) {
if (typeof item !== 'string') {
continue;
}
const v = item.trim();
if (!v || !isHttpUrl(v) || seen.has(v)) {
continue;
}
seen.add(v);
out.push(v);
}
return out;
}
function isHttpUrl(value: string): boolean {
try {
const u = new URL(value);
return (u.protocol === 'http:' || u.protocol === 'https:') && u.host.length > 0;
} catch {
return false;
}
}
// NOTE: when adding a new section, extend normalizeGlobalSettings, toJson AND
// saveGlobalSettings in lockstep. normalize/toJson only copy known sections, so
// an un-handled on-disk section is silently dropped on the next load+save — the
// section-merge in saveGlobalSettings only guards against a request OMITTING a
// section, not against this load/serialize pipeline discarding an unknown one.
export function normalizeGlobalSettings(value: unknown): GlobalSettings {
const out = defaultGlobalSettings();
if (!isObject(value)) {
return out;
}
const rkey = value.rkey;
if (isObject(rkey)) {
out.rkey.fallbackServers = normalizeRkeyServers(rkey.fallbackServers);
}
if (typeof value.musicSignUrl === 'string') {
const v = value.musicSignUrl.trim();
// Empty = "use built-in default"; otherwise must be a real http(s) endpoint.
out.musicSignUrl = v && isHttpUrl(v) ? v : '';
}
if ('videoUploadConcurrency' in value) {
out.videoUploadConcurrency = validateUploadConcurrency(value.videoUploadConcurrency);
}
return out;
}
export function loadGlobalSettings(): GlobalSettings {
if (!fs.existsSync(GLOBAL_CONFIG_PATH)) {
return defaultGlobalSettings();
}
try {
const raw = fs.readFileSync(GLOBAL_CONFIG_PATH, 'utf8');
return normalizeGlobalSettings(JSON.parse(raw) as unknown);
} catch (err) {
if (err instanceof RangeError) {
log.error('invalid global upload settings: %s', err.message);
throw err;
}
log.warn(
'config/snowluma.json is corrupt and will be ignored: %s',
err instanceof Error ? err.message : String(err),
);
return defaultGlobalSettings();
}
}
/**
* Persist global settings, SECTION-MERGING over what's on disk: only the
* top-level sections actually present in `incoming` are overwritten, so a
* partial save (e.g. just `rkey`) never wipes a sibling knob. Mirrors
* saveNotificationsConfig's merge discipline.
*/
export function saveGlobalSettings(incoming: unknown): GlobalSettings {
const merged = loadGlobalSettings();
if (isObject(incoming)) {
if (isObject(incoming.rkey)) {
merged.rkey.fallbackServers = normalizeRkeyServers(incoming.rkey.fallbackServers);
}
if ('videoUploadConcurrency' in incoming) {
merged.videoUploadConcurrency = validateUploadConcurrency(
incoming.videoUploadConcurrency,
);
}
if (typeof incoming.musicSignUrl === 'string') {
const v = incoming.musicSignUrl.trim();
merged.musicSignUrl = v && isHttpUrl(v) ? v : '';
}
}
fs.mkdirSync(CONFIG_DIR, { recursive: true });
const tmp = GLOBAL_CONFIG_PATH + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(toJson(merged), null, 2), 'utf8');
fs.renameSync(tmp, GLOBAL_CONFIG_PATH);
return merged;
}
function toJson(settings: GlobalSettings): JsonObject {
return {
rkey: { fallbackServers: settings.rkey.fallbackServers },
musicSignUrl: settings.musicSignUrl,
videoUploadConcurrency: settings.videoUploadConcurrency ?? 1,
};
}
function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* One-shot copy-up: `musicSignUrl` used to live in per-UIN config. If the global
* store has none yet, lift the first non-empty value found in any legacy
* `config/onebot*.json` into `config/snowluma.json`. Idempotent — skips once the
* global value is set. (If an operator later clears the global value while a
* stale per-UIN value lingers, a restart would re-copy it; benign and rare.)
*/
export function migrateGlobalSettings(): void {
// One-shot guard keyed on PRESENCE (not truthiness): once snowluma.json carries
// a musicSignUrl key — written by this migration or any global-config save —
// never re-scan. This is what stops an operator's intentional clear-to-'' from
// being reverted by a stale legacy value on the next boot.
if (globalConfigHasMusicSignUrlKey()) {
return;
}
const found = scanLegacyMusicSignUrl();
if (!found) {
return;
}
saveGlobalSettings({ musicSignUrl: found.value });
const extra = found.others.length
? `; ignored differing value(s) in ${found.others.join(', ')}`
: '';
log.info(
'migrated musicSignUrl to global config from %s: %s%s',
found.source,
found.value,
extra,
);
}
function globalConfigHasMusicSignUrlKey(): boolean {
if (!fs.existsSync(GLOBAL_CONFIG_PATH)) {
return false;
}
try {
const raw = JSON.parse(fs.readFileSync(GLOBAL_CONFIG_PATH, 'utf8')) as unknown;
return isObject(raw) && 'musicSignUrl' in raw;
} catch {
return false;
}
}
interface LegacyMusicSignUrl {
value: string;
source: string;
others: string[];
}
/** Scan config/onebot.json + config/onebot_<uin>.json for a non-empty
* musicSignUrl. Returns the first found plus any other files that carried a
* (differing) value, for logging. */
function scanLegacyMusicSignUrl(): LegacyMusicSignUrl | null {
let dir: string[];
try {
dir = fs.readdirSync(CONFIG_DIR);
} catch {
return null;
}
const files = dir.filter((f) => f === 'onebot.json' || /^onebot_\d+\.json$/.test(f)).sort();
let first: { value: string; source: string } | null = null;
const others: string[] = [];
for (const file of files) {
let value: unknown;
try {
const raw = JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, file), 'utf8')) as unknown;
value = isObject(raw) ? raw.musicSignUrl : undefined;
} catch {
continue;
}
if (typeof value !== 'string') {
continue;
}
const v = value.trim();
// Skip empty AND invalid here, so "first found" means "first usable" — a
// garbage value in an earlier-sorting file can't shadow a real URL in a
// later one (it would be dropped to '' on save and then locked by the guard).
if (!v || !isHttpUrl(v)) {
continue;
}
if (!first) {
first = { value: v, source: file };
} else if (v !== first.value) {
others.push(file);
}
}
return first ? { ...first, others } : null;
}
+101
View File
@@ -0,0 +1,101 @@
import type { MessageElement } from '@snowluma/protocol/events';
import { CQ_REGEX, parseCQParams, ParseMessageOptions, segmentToElement } from '../message-parser';
import type { JsonArray, JsonObject } from '../types';
export function segmentToCQ(seg: JsonObject): string {
const type = String(seg.type ?? '');
const data = (seg.data ?? {}) as Record<string, unknown>;
switch (type) {
case 'text':
return cqEscape(String(data.text ?? ''));
case 'face':
return `[CQ:face,id=${data.id ?? 0}${data.resultId !== undefined ? `,resultId=${cqEscape(String(data.resultId))}` : ''}]`;
case 'image':
return `[CQ:image,file=${cqEscape(String(data.file ?? ''))},url=${cqEscape(String(data.url ?? ''))}]`;
case 'at':
return `[CQ:at,qq=${data.qq ?? ''}]`;
case 'reply':
return `[CQ:reply,id=${data.id ?? 0}]`;
case 'record':
return `[CQ:record,file=${cqEscape(String(data.file ?? ''))},url=${cqEscape(String(data.url ?? ''))}]`;
case 'video':
return `[CQ:video,file=${cqEscape(String(data.file ?? ''))},url=${cqEscape(String(data.url ?? ''))}]`;
case 'json':
return `[CQ:json,data=${cqEscape(String(data.data ?? ''))}]`;
case 'xml':
return `[CQ:xml,data=${cqEscape(String(data.data ?? ''))}]`;
case 'forward':
return `[CQ:forward,id=${cqEscape(String(data.id ?? ''))}]`;
case 'mface':
return `[CQ:mface,name=${cqEscape(String(data.name ?? ''))}]`;
case 'poke':
return `[CQ:poke,type=${data.type ?? 0}]`;
case 'file':
return `[CQ:file,name=${cqEscape(String(data.name ?? ''))},size=${data.size ?? 0},id=${cqEscape(String(data.id ?? ''))},url=${cqEscape(String(data.url ?? ''))}]`;
default:
return `[CQ:${type}]`;
}
}
export function segmentsToRawMessage(segments: JsonArray): string {
return segments.map((seg) => segmentToCQ(seg as JsonObject)).join('');
}
export function cqEscape(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/\[/g, '&#91;')
.replace(/\]/g, '&#93;')
.replace(/,/g, '&#44;');
}
export function cqUnescape(text: string): string {
// Order matters: `&amp;` MUST be last so that, e.g., `&amp;#91;` keeps
// the literal `&#91;` instead of decoding twice into `[`.
return text
.replace(/&#91;/g, '[')
.replace(/&#93;/g, ']')
.replace(/&#44;/g, ',')
.replace(/&amp;/g, '&');
}
export async function parseFromCQString(
message: string,
options?: ParseMessageOptions,
): Promise<MessageElement[]> {
const elements: MessageElement[] = [];
let lastIndex = 0;
for (const match of message.matchAll(CQ_REGEX)) {
// Text before this CQ code
if (match.index! > lastIndex) {
const text = cqUnescape(message.substring(lastIndex, match.index!));
if (text) {
elements.push({ type: 'text', text });
}
}
lastIndex = match.index! + match[0].length;
const cqType = match[1];
const params = parseCQParams(match[2] || '');
const elem = await segmentToElement(cqType, params, options);
if (elem) {
elements.push(elem);
}
}
// Trailing text
if (lastIndex < message.length) {
const text = cqUnescape(message.substring(lastIndex));
if (text) {
elements.push({ type: 'text', text });
}
}
return elements;
}
@@ -0,0 +1,86 @@
import type { FriendMessage } from '@snowluma/protocol/events';
import { GROUP_MESSAGE_EVENT, privateMessageEventName } from './message-id';
import type { MessageStore } from './message-store';
import type { JsonObject } from './types';
/**
* Persist a converted server-history event without dispatching it.
*
* Private history needs the conversation peer explicitly because `user_id`
* names the sender and therefore equals the bot account on outgoing messages.
*/
export async function persistHistoryEvent(
store: MessageStore,
event: JsonObject,
privatePeerId?: number,
source?: FriendMessage,
): Promise<boolean> {
const messageId = toHistoryInt(event.message_id);
if (!Number.isSafeInteger(messageId) || messageId === 0) {
throw new Error(`history event has invalid message_id ${String(event.message_id)}`);
}
if (event.message_type !== 'group' && event.message_type !== 'private') {
throw new Error(`history event has invalid message_type ${String(event.message_type)}`);
}
const isGroup = event.message_type === 'group';
const sessionId = isGroup
? toHistoryInt(event.group_id)
: privatePeerId && privatePeerId > 0
? privatePeerId
: toHistoryInt(event.user_id);
const messageSequence = toHistoryInt(event.message_seq);
const sequence = source?.ntMsgSeq ?? messageSequence;
if (!Number.isSafeInteger(sessionId) || sessionId <= 0) {
throw new Error(`history event has invalid session id ${String(sessionId)}`);
}
if (!Number.isSafeInteger(sequence) || sequence <= 0) {
throw new Error(`history event has invalid sequence ${String(sequence)}`);
}
const sequenceAuthoritative = source?.sequenceAuthoritative !== false && sequence > 0;
const isOutgoingPrivate = !isGroup && source?.senderUin === source?.selfUin;
const eventName = isGroup
? GROUP_MESSAGE_EVENT
: source
? privateMessageEventName(isOutgoingPrivate, sequenceAuthoritative)
: privateMessageEventName(event.post_type === 'message_sent', false);
const meta =
!isGroup && source
? ({
isGroup: false,
targetId: sessionId,
sequence,
sequenceAuthoritative,
eventName,
clientSequence: source.clientSeq ?? source.msgSeq,
privateDirection: source.senderUin === source.selfUin ? 'outgoing' : 'incoming',
random: source.msgId,
timestamp: source.time,
} satisfies Parameters<MessageStore['storeMeta']>[1])
: undefined;
return store.storeHistoryEvent(messageId, isGroup, sessionId, sequence, eventName, event, {
sequenceAuthoritative,
meta,
});
}
export function toHistoryInt(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.trunc(value);
}
if (typeof value === 'string' && value.trim()) {
const number = Number(value);
if (Number.isFinite(number)) {
return Math.trunc(number);
}
}
return 0;
}
+580
View File
@@ -0,0 +1,580 @@
import { mapWithConcurrency } from '@snowluma/common/concurrency';
import { createLogger } from '@snowluma/common/logger';
import type { BridgeInterface } from '@snowluma/core/bridge-interface';
import {
LOGIN_HISTORY_SYNC_PROTOCOL_LIMITS,
type GroupHistorySyncState,
type PrivateHistorySyncState,
} from '@snowluma/core/apis';
import type { FriendMessage, GroupMessage } from '@snowluma/protocol/events';
import { convertEvent, type ConverterContext } from './event-converter';
import { persistHistoryEvent, toHistoryInt } from './history-persistence';
import type { MessageStore } from './message-store';
export const LOGIN_HISTORY_SYNC_LIMITS = {
startupDelayMs: 30_000,
scannedGroups: LOGIN_HISTORY_SYNC_PROTOCOL_LIMITS.maxTargetsPerKind,
scannedPrivateUsers: LOGIN_HISTORY_SYNC_PROTOCOL_LIMITS.maxTargetsPerKind,
maxGroups: 3,
maxPrivateUsers: 3,
messagesPerSession: LOGIN_HISTORY_SYNC_PROTOCOL_LIMITS.maxMessagesPerSession,
} as const;
export interface LoginHistorySyncClock {
sleep(ms: number, signal: AbortSignal): Promise<void>;
}
export interface LoginHistorySyncRef {
selfId: number;
bridge: BridgeInterface;
messageStore: MessageStore;
converterCtx: ConverterContext;
}
export interface LoginHistorySyncResult {
scannedGroups: number;
scannedPrivateUsers: number;
selectedGroups: number;
selectedPrivateUsers: number;
fetchedMessages: number;
storedMessages: number;
failedSessions: number;
truncatedGroups: boolean;
truncatedPrivateUsers: boolean;
}
interface GroupCandidate {
state: GroupHistorySyncState;
localSequence: number | null;
priority: number;
knownGap: boolean;
}
interface PrivateCandidate {
state: PrivateHistorySyncState;
localSequence: number | null;
priority: number;
knownGap: boolean;
}
const log = createLogger('OneBot.HistorySync');
const SYSTEM_CLOCK: LoginHistorySyncClock = {
sleep: (ms, signal) => abortableSleep(ms, signal),
};
/**
* Process-wide coordinator for automatic login backfill.
*
* The 30-second grace period happens before entering the global FIFO, so one
* account waiting out startup does not block another account that is already
* ready. The complete per-account operation is serialized so conversion and
* persistence cannot overlap another account's automatic sync.
*/
export class LoginHistorySyncCoordinator {
private tail: Promise<void> = Promise.resolve();
constructor(private readonly clock: LoginHistorySyncClock = SYSTEM_CLOCK) {}
async schedule(ref: LoginHistorySyncRef, signal: AbortSignal): Promise<LoginHistorySyncResult> {
await this.clock.sleep(LOGIN_HISTORY_SYNC_LIMITS.startupDelayMs, signal);
throwIfAborted(signal);
let operationStarted = false;
const operation = this.tail.then(async () => {
throwIfAborted(signal);
operationStarted = true;
return this.runAccount(ref, signal);
});
this.tail = operation.then(
() => undefined,
() => undefined,
);
return rejectWhileQueuedOnAbort(operation, signal, () => operationStarted);
}
private async runAccount(
ref: LoginHistorySyncRef,
signal: AbortSignal,
): Promise<LoginHistorySyncResult> {
const groups = ref.bridge.identity.groups.filter(
(group) => Number.isSafeInteger(group.groupId) && group.groupId > 0,
);
const privateUsers = ref.bridge.identity.friends.filter(
(friend) =>
Number.isSafeInteger(friend.uin) &&
friend.uin > 0 &&
friend.uin !== ref.selfId &&
typeof friend.uid === 'string' &&
friend.uid.trim().length > 0,
);
const truncatedGroups = groups.length > LOGIN_HISTORY_SYNC_LIMITS.scannedGroups;
const truncatedPrivateUsers =
privateUsers.length > LOGIN_HISTORY_SYNC_LIMITS.scannedPrivateUsers;
const scannedGroups = groups.slice(0, LOGIN_HISTORY_SYNC_LIMITS.scannedGroups);
const scannedPrivateUsers = privateUsers.slice(
0,
LOGIN_HISTORY_SYNC_LIMITS.scannedPrivateUsers,
);
if (truncatedGroups || truncatedPrivateUsers) {
log.warn(
'login history sync roster truncated: UIN=%d groups=%d/%d private=%d/%d',
ref.selfId,
scannedGroups.length,
groups.length,
scannedPrivateUsers.length,
privateUsers.length,
);
}
throwIfAborted(signal);
const state = await ref.bridge.apis.message.probeHistorySyncState(
scannedGroups.map((group) => group.groupId),
scannedPrivateUsers.map((friend) => ({
userId: friend.uin,
uid: friend.uid,
})),
signal,
);
const groupCandidates = await selectGroupCandidates(ref.messageStore, state.groups);
const privateCandidates = await selectPrivateCandidates(
ref.messageStore,
state.privateUsers,
);
// URL resolvers may issue additional QQ requests for rkeys or media
// download addresses. Automatic backfill must stay inside its one-probe
// plus six-page packet budget, so persist only URLs already present in the
// history response. Message-id resolution and local media indexing remain
// enabled.
const mediaSegmentSink = ref.converterCtx.mediaSegmentSink;
const persistenceRef: LoginHistorySyncRef = {
...ref,
converterCtx: {
...ref.converterCtx,
imageUrlResolver: null,
mediaUrlResolver: null,
mediaSegmentSink: mediaSegmentSink
? async (mediaType, element, data, isGroup, sessionId) => {
throwIfAborted(signal);
await mediaSegmentSink(mediaType, element, data, isGroup, sessionId);
}
: null,
},
};
const result: LoginHistorySyncResult = {
scannedGroups: scannedGroups.length,
scannedPrivateUsers: scannedPrivateUsers.length,
selectedGroups: groupCandidates.length,
selectedPrivateUsers: privateCandidates.length,
fetchedMessages: 0,
storedMessages: 0,
failedSessions: 0,
truncatedGroups,
truncatedPrivateUsers,
};
log.info(
'login history sync selected: UIN=%d scanned=%d/%d selected=%d/%d',
ref.selfId,
result.scannedGroups,
result.scannedPrivateUsers,
result.selectedGroups,
result.selectedPrivateUsers,
);
for (const candidate of groupCandidates) {
throwIfAborted(signal);
try {
const messages = await fetchGroupCandidate(persistenceRef, candidate, signal);
throwIfAborted(signal);
result.fetchedMessages += messages.length;
for (const message of messages) {
throwIfAborted(signal);
await persistGroupMessage(persistenceRef, message, signal);
result.storedMessages += 1;
}
} catch (error) {
if (signal.aborted) {
throwIfAborted(signal);
}
result.failedSessions += 1;
log.warn(
'login history sync group failed: UIN=%d group=%d error=%s',
ref.selfId,
candidate.state.groupId,
errorText(error),
);
}
}
for (const candidate of privateCandidates) {
throwIfAborted(signal);
try {
const messages = await fetchPrivateCandidate(persistenceRef, candidate, signal);
throwIfAborted(signal);
result.fetchedMessages += messages.length;
for (const message of messages) {
throwIfAborted(signal);
if (
await persistPrivateMessage(
persistenceRef,
candidate.state.userId,
message,
signal,
)
) {
result.storedMessages += 1;
}
}
} catch (error) {
if (signal.aborted) {
throwIfAborted(signal);
}
result.failedSessions += 1;
log.warn(
'login history sync private failed: UIN=%d peer=%d error=%s',
ref.selfId,
candidate.state.userId,
errorText(error),
);
}
}
log.info(
'login history sync complete: UIN=%d fetched=%d stored=%d failed=%d',
ref.selfId,
result.fetchedMessages,
result.storedMessages,
result.failedSessions,
);
return result;
}
}
export const loginHistorySyncCoordinator = new LoginHistorySyncCoordinator();
async function selectGroupCandidates(
store: MessageStore,
states: readonly GroupHistorySyncState[],
): Promise<GroupCandidate[]> {
return (
await mapWithConcurrency(states, 8, async (state): Promise<GroupCandidate[]> => {
if (!Number.isSafeInteger(state.latestSeq) || state.latestSeq <= 0) {
return [];
}
const observedSequence = await store.findLatestAuthoritativeSequence(
true,
state.groupId,
);
const localSequence = await store.findLatestPersistedAuthoritativeSequence(
true,
state.groupId,
);
if (localSequence !== null) {
if (state.latestSeq <= localSequence) {
return [];
}
return [
{
state,
localSequence,
priority: state.latestSeq - localSequence,
knownGap: true,
},
];
}
if (observedSequence !== null) {
return [
{
state,
localSequence: null,
priority: state.latestSeq,
knownGap: true,
},
];
}
if (state.latestSeq <= state.readSeq) {
return [];
}
return [
{
state,
localSequence: null,
priority: state.latestSeq - state.readSeq,
knownGap: false,
},
];
})
)
.flat()
.sort(compareCandidates)
.slice(0, LOGIN_HISTORY_SYNC_LIMITS.maxGroups);
}
async function selectPrivateCandidates(
store: MessageStore,
states: readonly PrivateHistorySyncState[],
): Promise<PrivateCandidate[]> {
return (
await mapWithConcurrency(states, 8, async (state): Promise<PrivateCandidate[]> => {
if (!Number.isSafeInteger(state.latestSeq) || state.latestSeq <= 0) {
return [];
}
const observedSequence = await store.findLatestAuthoritativeSequence(
false,
state.userId,
);
const localSequence = await store.findLatestPersistedAuthoritativeSequence(
false,
state.userId,
);
if (localSequence !== null) {
if (state.latestSeq <= localSequence) {
return [];
}
return [
{
state,
localSequence,
priority: state.latestSeq - localSequence,
knownGap: true,
},
];
}
if (!Number.isSafeInteger(state.lastMsgTime) || state.lastMsgTime <= 0) {
return [];
}
return [
{
state,
localSequence: null,
priority: state.lastMsgTime,
knownGap: observedSequence !== null,
},
];
})
)
.flat()
.sort(compareCandidates)
.slice(0, LOGIN_HISTORY_SYNC_LIMITS.maxPrivateUsers);
}
function compareCandidates<
T extends {
localSequence: number | null;
priority: number;
knownGap: boolean;
},
>(left: T, right: T): number {
if (left.knownGap !== right.knownGap) {
return left.knownGap ? -1 : 1;
}
if ((left.localSequence !== null) !== (right.localSequence !== null)) {
return left.localSequence !== null ? -1 : 1;
}
return right.priority - left.priority;
}
function fetchGroupCandidate(
ref: LoginHistorySyncRef,
candidate: GroupCandidate,
signal: AbortSignal,
): Promise<GroupMessage[]> {
const endSeq =
candidate.localSequence === null
? candidate.state.latestSeq
: Math.min(
candidate.state.latestSeq,
candidate.localSequence + LOGIN_HISTORY_SYNC_LIMITS.messagesPerSession,
);
const startSeq =
candidate.localSequence === null
? Math.max(1, endSeq - LOGIN_HISTORY_SYNC_LIMITS.messagesPerSession + 1)
: candidate.localSequence + 1;
return ref.bridge.apis.message.getGroupHistorySyncPage(
candidate.state.groupId,
startSeq,
endSeq,
ref.selfId,
signal,
);
}
function fetchPrivateCandidate(
ref: LoginHistorySyncRef,
candidate: PrivateCandidate,
signal: AbortSignal,
): Promise<FriendMessage[]> {
if (candidate.localSequence !== null) {
const endSeq = Math.min(
candidate.state.latestSeq,
candidate.localSequence + LOGIN_HISTORY_SYNC_LIMITS.messagesPerSession,
);
return ref.bridge.apis.message.getC2cHistorySyncPage(
candidate.state.uid,
candidate.localSequence + 1,
endSeq,
ref.selfId,
signal,
);
}
const beforeTime = Math.min(0xffff_ffff, candidate.state.lastMsgTime + 1);
return ref.bridge.apis.message.getC2cLatestHistorySyncPage(
candidate.state.uid,
LOGIN_HISTORY_SYNC_LIMITS.messagesPerSession,
ref.selfId,
beforeTime,
signal,
);
}
async function persistGroupMessage(
ref: LoginHistorySyncRef,
message: GroupMessage,
signal: AbortSignal,
): Promise<void> {
throwIfAborted(signal);
const converted = await convertEvent(ref.converterCtx, message);
throwIfAborted(signal);
if (!converted || converted.message_type !== 'group') {
throw new Error(
`group history conversion failed: group=${message.groupId} seq=${message.msgSeq} ` +
`messageType=${String(converted?.message_type)}`,
);
}
await persistHistoryEvent(ref.messageStore, converted);
}
async function persistPrivateMessage(
ref: LoginHistorySyncRef,
peerUin: number,
message: FriendMessage,
signal: AbortSignal,
): Promise<boolean> {
throwIfAborted(signal);
const receivedPeer = message.peerUin ?? 0;
if (receivedPeer > 0 && receivedPeer !== peerUin) {
throw new Error(
`private history peer mismatch: requested=${peerUin} received=${receivedPeer} ` +
`seq=${String(message.ntMsgSeq ?? message.msgSeq)}`,
);
}
if (receivedPeer <= 0) {
message.peerUin = peerUin;
}
const clientSequence = message.clientSeq ?? message.msgSeq;
const sentBySelf = message.senderUin === ref.selfId;
const converted = await convertEvent(ref.converterCtx, message);
throwIfAborted(signal);
if (!converted || converted.message_type !== 'private') {
throw new Error(
`private history conversion failed: peer=${peerUin} ` +
`seq=${String(message.ntMsgSeq ?? message.msgSeq)} ` +
`messageType=${String(converted?.message_type)}`,
);
}
if (
await ref.messageStore.isPrivateMessageRecalled(
peerUin,
clientSequence,
sentBySelf,
message.time,
)
) {
log.debug(
'login history sync omitted recalled private message: UIN=%d peer=%d clientSeq=%d self=%s',
ref.selfId,
peerUin,
clientSequence,
String(sentBySelf),
);
return false;
}
if (converted.post_type === 'message_sent' && toHistoryInt(converted.target_id) === 0) {
converted.target_id = peerUin;
}
return persistHistoryEvent(ref.messageStore, converted, peerUin, message);
}
function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
throwIfAborted(signal);
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve();
}, ms);
timer.unref();
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason ?? new Error('login history sync aborted'));
};
signal.addEventListener('abort', onAbort, { once: true });
});
}
function throwIfAborted(signal: AbortSignal): void {
if (signal.aborted) {
throw signal.reason ?? new Error('login history sync aborted');
}
}
function rejectWhileQueuedOnAbort<T>(
result: Promise<T>,
signal: AbortSignal,
hasStarted: () => boolean,
): Promise<T> {
if (signal.aborted && !hasStarted()) {
return Promise.reject(signal.reason ?? new Error('login history sync aborted'));
}
return new Promise<T>((resolve, reject) => {
const onAbort = () => {
if (!hasStarted()) {
signal.removeEventListener('abort', onAbort);
reject(signal.reason ?? new Error('login history sync aborted'));
}
};
signal.addEventListener('abort', onAbort);
result.then(resolve, reject).finally(() => {
signal.removeEventListener('abort', onAbort);
});
});
}
function errorText(error: unknown): string {
return error instanceof Error ? (error.stack ?? error.message) : String(error);
}
+310
View File
@@ -0,0 +1,310 @@
import type { Bridge } from '@snowluma/core/bridge';
import type { BridgeInterface } from '@snowluma/core/bridge-interface';
import type { WebHonorType } from '@snowluma/protocol/web/group-honor';
import type { ApiActionContext } from './api-handler';
import type { ConverterContext } from './event-converter';
import type { MediaStore } from './media-store';
import type { MessageStore } from './message-store';
import { readMessageWithImageRepair } from './message-image-repair';
import type { TempSessionStore } from './temp-session-store';
import {
getDownloadRKeys,
getFriendList,
getGroupFiles,
getGroupInfo,
getGroupList,
getGroupMemberInfo,
getGroupMemberList,
getGroupSystemMessages,
getLoginInfo,
getStrangerInfo,
} from './modules/contact-actions';
import {
fetchPttText as fetchPttTextAction,
getImageInfo as getCachedImageInfo,
getRecordInfo as getCachedRecordInfo,
refreshMessageMedia,
} from './modules/media-actions';
import {
deleteMessage,
forwardSingleMessage,
getForwardMessage,
getFriendHistory,
getGroupHistory,
sendGroupForwardMessage,
sendGroupMessage,
sendPrivateForwardMessage,
sendPrivateMessage,
setEssenceMessage,
uploadForwardMessage,
} from './modules/message-actions';
import { handleGroupAddRequest } from './modules/request-actions';
import type { ReactionStore } from './reaction-store';
import {
hasAuthoritativeSequence,
type JsonObject,
type MessageMeta,
type OneBotConfig,
} from './types';
export interface OneBotInstanceContext {
uin: string;
selfId: number;
bridge: BridgeInterface;
messageStore: MessageStore;
mediaStore: MediaStore;
reactionStore: ReactionStore;
/** Group temp-session records — see {@link TempSessionStore}. */
tempSessions: TempSessionStore;
converterCtx: ConverterContext;
config: OneBotConfig;
musicSignUrl?: string;
getUptimeMs(): number;
cacheMessageMeta(messageId: number, meta: MessageMeta): void | Promise<void>;
dispatchEvent(
event: JsonObject,
source?: 'bridge' | 'send',
startedAt?: number,
): void | Promise<void>;
}
export function buildApiContext(ref: OneBotInstanceContext): ApiActionContext {
const { bridge, messageStore, mediaStore, reactionStore } = ref;
return {
bridge,
getLoginInfo: () => getLoginInfo(ref),
isOnline: () => true,
getUptimeMs: () => ref.getUptimeMs(),
getMessage: (messageId) => readMessageWithImageRepair(ref, messageId),
refreshMessageMedia: (messageId, event) => refreshMessageMedia(ref, messageId, event),
getMessageMeta: async (messageId) => await messageStore.findMeta(messageId),
cacheMessageMetas: async (entries) => await messageStore.storeMetas(entries),
listReadSessions: async () =>
await messageStore.listReadSessions(
bridge.identity.groups.map((group) => group.groupId),
),
canSendImage: () => true,
canSendRecord: () => true,
sendPrivateMessage: (userId, message, autoEscape, tempGroupId) =>
sendPrivateMessage(
ref,
userId,
message,
autoEscape,
tempGroupId,
tempGroupId === undefined
? async (event) => await ref.dispatchEvent(event, 'send')
: undefined,
),
sendGroupMessage: (groupId, message, autoEscape) =>
sendGroupMessage(ref, groupId, message, autoEscape),
deleteMessage: (_messageId, meta) => deleteMessage(bridge, meta),
getFriendList: (withProfile) => getFriendList(bridge, withProfile),
getGroupList: (noCache) => getGroupList(bridge, noCache),
getGroupInfo: (groupId, noCache) => getGroupInfo(bridge, groupId, noCache),
getGroupMemberList: (groupId, noCache) => getGroupMemberList(bridge, groupId, noCache),
getGroupMemberInfo: (groupId, userId, noCache) =>
getGroupMemberInfo(bridge, groupId, userId, noCache),
getStrangerInfo: (userId) => getStrangerInfo(bridge, userId),
getGroupFiles: (groupId, folderId) => getGroupFiles(bridge, groupId, folderId),
handleGroupRequest: (flag, _subType, approve, reason) =>
handleGroupAddRequest(bridge, flag, approve, reason),
getGroupMsgHistory: (groupId, messageId, count, reverseOrder) =>
getGroupHistory(ref, groupId, messageId, count, reverseOrder),
getFriendMsgHistory: (userId, messageId, count, reverseOrder) =>
getFriendHistory(ref, userId, messageId, count, reverseOrder),
handleGetGroupSystemMsg: (query) => getGroupSystemMessages(bridge, query),
getDownloadRKeys: () => getDownloadRKeys(bridge),
sendGroupForwardMsg: (groupId, messages, meta) =>
sendGroupForwardMessage(ref, groupId, messages, meta),
sendPrivateForwardMsg: (userId, messages, meta) =>
sendPrivateForwardMessage(
ref,
userId,
messages,
meta,
async (event) => await ref.dispatchEvent(event, 'send'),
),
sendForwardMsg: (messages, groupId) => uploadForwardMessage(ref, messages, groupId),
getForwardMsg: (resId) => getForwardMessage(ref, resId),
forwardSingleMsg: (messageId, target) =>
forwardSingleMessage(
ref,
messageId,
target,
target.userId !== undefined
? async (event) => await ref.dispatchEvent(event, 'send')
: undefined,
),
setEssenceMsg: (messageId) => setEssenceMessage(bridge, messageStore, messageId, true),
deleteEssenceMsg: (messageId) => setEssenceMessage(bridge, messageStore, messageId, false),
setMsgEmojiLike: async (messageId, emojiId, set) => {
const meta = await messageStore.findMeta(messageId);
if (!meta) {
throw new Error('message not found');
}
if (!meta.isGroup) {
throw new Error('emoji reactions are not supported on private messages');
}
if (!hasAuthoritativeSequence(meta)) {
throw new Error('message has no authoritative QQ sequence');
}
await bridge.apis.interaction.setReaction(meta.targetId, meta.sequence, emojiId, set);
const emojiType = emojiId.length > 3 ? 2 : 1;
if (set) {
await reactionStore.recordAdd(
meta.targetId,
meta.sequence,
emojiId,
emojiType,
ref.selfId,
'',
Math.floor(Date.now() / 1000),
);
} else {
await reactionStore.recordRemove(meta.targetId, meta.sequence, emojiId, ref.selfId);
}
},
fetchEmojiLikeSummary: async (messageId) => {
const meta = await messageStore.findMeta(messageId);
if (!meta) {
throw new Error('message not found');
}
if (!meta.isGroup) {
throw new Error('emoji reactions are not supported on private messages');
}
if (!hasAuthoritativeSequence(meta)) {
throw new Error('message has no authoritative QQ sequence');
}
const summary = await reactionStore.summarizeMessage(meta.targetId, meta.sequence);
const out: Array<{
emoji_id: string;
emoji_type: number;
count: number;
last_reaction_time: number;
users: Array<{ user_id: number }>;
}> = [];
for (const entry of summary) {
let users = (
await reactionStore.listUsers(
meta.targetId,
meta.sequence,
entry.emojiId,
1000,
0,
)
).map((user) => ({ user_id: user.operatorUin }));
try {
const remote = await bridge.apis.interaction.getEmojiLikes(
meta.targetId,
meta.sequence,
entry.emojiId,
entry.emojiType,
1000,
);
if (remote.users.length > 0) {
users = remote.users.map((user) => ({ user_id: user.uin }));
}
} catch {
/* keep cached users */
}
out.push({
emoji_id: entry.emojiId,
emoji_type: entry.emojiType,
count: Math.max(entry.count, users.length),
last_reaction_time: entry.lastSetAt,
users,
});
}
return out;
},
fetchEmojiLikeUsers: async (messageId, emojiId, count, offset = 0) => {
const meta = await messageStore.findMeta(messageId);
if (!meta) {
throw new Error('message not found');
}
if (!meta.isGroup) {
throw new Error('emoji reactions are not supported on private messages');
}
if (!hasAuthoritativeSequence(meta)) {
throw new Error('message has no authoritative QQ sequence');
}
const raw = await reactionStore.listUsers(
meta.targetId,
meta.sequence,
emojiId,
count,
offset,
);
let users = raw.map((r) => ({
uin: r.operatorUin,
uid: r.operatorUid,
setAt: r.setAt,
}));
const cachedCount = await reactionStore.countUsers(
meta.targetId,
meta.sequence,
emojiId,
);
let serverCount = cachedCount;
try {
const remote = await bridge.apis.interaction.getEmojiLikes(
meta.targetId,
meta.sequence,
emojiId,
emojiId.length > 3 ? 2 : 1,
count,
);
if (remote.users.length > 0) {
serverCount = remote.users.length;
if (offset === 0) {
users = remote.users.slice(0, count).map((user) => ({
uin: user.uin,
uid: '',
setAt: 0,
}));
}
}
} catch {
/* keep cached users / count */
}
return {
users,
cachedCount,
serverCount,
complete: cachedCount >= serverCount,
};
},
getImageInfo: (file) =>
getCachedImageInfo(mediaStore, file, ref.converterCtx.imageUrlResolver),
getRecordInfo: (file) => getCachedRecordInfo(bridge, mediaStore, file),
fetchPttText: (messageId) =>
fetchPttTextAction(messageStore, mediaStore, bridge, ref.selfId, messageId),
};
}
export type { Bridge, WebHonorType };
+363
View File
@@ -0,0 +1,363 @@
import { createLogger } from '@snowluma/common/logger';
import type { DownloadRKeyInfo } from '@snowluma/core/bridge';
import type { BridgeInterface } from '@snowluma/core/bridge-interface';
import type { MessageElement } from '@snowluma/protocol/events';
import type { RKeyConfig } from './types';
const log = createLogger('OneBot');
interface CachedRKey {
value: string;
expiresAt: number;
}
const RKEY_REFRESH_SKEW = 60;
const RKEY_REFRESH_COOLDOWN = 30;
// When the cache holds NO usable image rkey, the alternative to refreshing is
// handing out a bare URL the CDN rejects with `invalid rkey` (issue #156). So
// retry far more eagerly than the 30s steady-state backoff — but still throttled
// so a persistently-failing fetch isn't hammered once per inbound image.
const RKEY_EMPTY_COOLDOWN = 10;
// Upper bound on how long URL resolution may block on a refresh round-trip.
// A stuck packet send must never wedge message conversion — past this we
// fall back to whatever the cache holds (possibly nothing).
const RKEY_REFRESH_TIMEOUT_MS = 5000;
const PRIVATE_IMAGE_RKEY_TYPE = 10;
const GROUP_IMAGE_RKEY_TYPE = 20;
const PRIVATE_VIDEO_RKEY_TYPE = 12;
const GROUP_VIDEO_RKEY_TYPE = 22;
const PRIVATE_PTT_RKEY_TYPE = 14;
const GROUP_PTT_RKEY_TYPE = 24;
const FALLBACK_IMAGE_RKEY_TYPE = 2;
export class RKeyCache {
private cache = new Map<number, CachedRKey>();
private lastRefreshAttempt = 0;
private refreshInflight: Promise<void> | null = null;
/** Opt-in remote rkey endpoints; empty = feature off (the default). */
private fallbackServers: string[];
constructor(config?: RKeyConfig) {
this.fallbackServers = config?.fallbackServers ?? [];
}
/** Replace the fallback endpoint list (global-config hot-reload). */
setFallbackServers(servers: string[]): void {
this.fallbackServers = servers;
}
warmUp(bridge: BridgeInterface, uin: string): void {
this.ensureFresh(bridge).then(
() => log.info('rkeys loaded: UIN=%s count=%d', uin, this.cache.size),
(err) =>
log.warn(
'failed to load rkeys for UIN %s: %s',
uin,
err instanceof Error ? err.message : String(err),
),
);
}
resolveImageUrl(
bridge: BridgeInterface,
element: MessageElement,
isGroup: boolean,
): Promise<string> {
const url = element.imageUrl ?? '';
// Forwarded images retain their upload scene, encoded by appid in the URL.
const type =
imageRKeyTypeFromUrl(url) ??
(isGroup ? GROUP_IMAGE_RKEY_TYPE : PRIVATE_IMAGE_RKEY_TYPE);
return this.resolveUrl(bridge, url, type);
}
resolveMediaUrl(
bridge: BridgeInterface,
element: MessageElement,
isGroup: boolean,
): Promise<string> {
let type: number;
if (element.type === 'video') {
type = isGroup ? GROUP_VIDEO_RKEY_TYPE : PRIVATE_VIDEO_RKEY_TYPE;
} else if (element.type === 'record') {
type = isGroup ? GROUP_PTT_RKEY_TYPE : PRIVATE_PTT_RKEY_TYPE;
} else {
type = isGroup ? GROUP_IMAGE_RKEY_TYPE : PRIVATE_IMAGE_RKEY_TYPE;
}
return this.resolveUrl(bridge, element.url ?? '', type);
}
private async resolveUrl(bridge: BridgeInterface, url: string, type: number): Promise<string> {
if (!urlNeedsRKey(url)) {
return url;
}
const rkey = await this.findRKeyForType(bridge, type);
if (!rkey) {
return url;
}
const separator = url.includes('?') ? '&rkey=' : '?rkey=';
return url + separator + encodeURIComponent(stripRKeyPrefix(rkey));
}
private updateCache(rkeys: DownloadRKeyInfo[]): void {
const now = Math.floor(Date.now() / 1000);
for (const rk of rkeys) {
if (!rk.rkey || !rk.type) {
continue;
}
const baseTime = rk.createTime || now;
const ttl = rk.ttlSeconds || 3600;
this.cache.set(rk.type, {
value: rk.rkey,
expiresAt: baseTime + ttl,
});
}
}
/** True when at least one common image rkey (group or private) is cached and
* still valid — i.e. we can sign a typical image without a fresh fetch. */
private hasUsableRkey(): boolean {
return (
this.findInCache(GROUP_IMAGE_RKEY_TYPE) !== null ||
this.findInCache(PRIVATE_IMAGE_RKEY_TYPE) !== null
);
}
/** Pull a still-valid rkey out of the cache, or null if missing/expiring. */
private findInCache(primaryType: number): string | null {
const now = Math.floor(Date.now() / 1000);
const tryFind = (type: number): string | null => {
const cached = this.cache.get(type);
if (!cached || !cached.value) {
return null;
}
if (cached.expiresAt !== 0 && now + RKEY_REFRESH_SKEW >= cached.expiresAt) {
return null;
}
return cached.value;
};
return tryFind(primaryType) ?? tryFind(FALLBACK_IMAGE_RKEY_TYPE);
}
private async findRKeyForType(
bridge: BridgeInterface,
primaryType: number,
): Promise<string | null> {
const cached = this.findInCache(primaryType);
if (cached) {
return cached;
}
// Cache miss or inside the expiry skew: block on a fresh fetch rather
// than hand back a URL with a stale/absent rkey — the CDN rejects those
// with `{"retcode":-5503010,"retmsg":"invalid rkey"}`. Mirrors NapCat's
// getImageUrl path, which does `if (isExpired()) await refreshRkey()`
// before building the URL instead of refreshing in the background.
await this.ensureFresh(bridge);
return this.findInCache(primaryType);
}
/**
* Refresh the rkey cache via OIDB 0x9067_202, coalescing concurrent
* callers onto one in-flight request and backing off (RKEY_REFRESH_COOLDOWN
* when a usable rkey is held, RKEY_EMPTY_COOLDOWN while none is) so a
* persistently-failing fetch isn't hammered once per inbound image. The await
* is bounded by RKEY_REFRESH_TIMEOUT_MS.
*/
private async ensureFresh(bridge: BridgeInterface): Promise<void> {
if (!this.refreshInflight) {
const now = Math.floor(Date.now() / 1000);
// Back off hard once we hold a usable rkey; retry quickly while we don't,
// so a transient empty/failed fetch self-heals in seconds, not 30s (#156).
const cooldown = this.hasUsableRkey() ? RKEY_REFRESH_COOLDOWN : RKEY_EMPTY_COOLDOWN;
if (now - this.lastRefreshAttempt < cooldown) {
return;
}
this.lastRefreshAttempt = now;
this.refreshInflight = this.runRefresh(bridge).finally(() => {
this.refreshInflight = null;
});
}
const inflight = this.refreshInflight;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, RKEY_REFRESH_TIMEOUT_MS);
});
try {
await Promise.race([inflight, timeout]);
} finally {
clearTimeout(timer);
}
}
/** One refresh round: native OIDB fetch, then — only if that left us without
* a usable image rkey AND fallback servers are configured — the remote
* fallback. Never throws; logs and degrades to whatever the cache holds. */
private async runRefresh(bridge: BridgeInterface): Promise<void> {
try {
const rkeys = await bridge.apis.contacts.fetchDownloadRKeys();
this.updateCache(rkeys);
} catch (err) {
log.warn('rkey refresh failed: %s', err instanceof Error ? err.message : String(err));
}
if (this.fallbackServers.length > 0 && !this.hasUsableRkey()) {
await this.refreshFromFallback();
}
}
/** Ask each configured fallback server in turn for an rkey, stopping at the
* first that yields one. Populates the group/private image rkey types. */
private async refreshFromFallback(): Promise<void> {
for (const server of this.fallbackServers) {
try {
const data = await fetchFallbackRkey(server);
if (!data) {
continue;
}
const now = Math.floor(Date.now() / 1000);
const expiresAt = data.expiredTime > now ? data.expiredTime : now + 3600;
if (data.groupRkey) {
this.setFallbackRkey(GROUP_IMAGE_RKEY_TYPE, data.groupRkey, expiresAt);
}
if (data.privateRkey) {
this.setFallbackRkey(PRIVATE_IMAGE_RKEY_TYPE, data.privateRkey, expiresAt);
}
if (data.groupRkey || data.privateRkey) {
log.info('rkey loaded from fallback server %s', server);
return;
}
} catch (err) {
log.warn(
'rkey fallback server %s failed: %s',
server,
err instanceof Error ? err.message : String(err),
);
}
}
}
private setFallbackRkey(type: number, value: string, expiresAt: number): void {
this.cache.set(type, { value, expiresAt });
}
}
interface FallbackRkeyData {
groupRkey: string;
privateRkey: string;
/** Absolute unix-seconds expiry (0 when the server omits it). */
expiredTime: number;
}
/** Fetch + parse one fallback server's response. Accepts the bare
* `{ group_rkey, private_rkey, expired_time }` shape and an OneBot
* `{ retcode, data: {...} }` wrapper. Returns null when neither rkey is
* present. The stored values keep any leading `&rkey=`/`?rkey=` — resolveImageUrl
* strips it before re-appending, so both prefixed and bare tokens work. */
async function fetchFallbackRkey(server: string): Promise<FallbackRkeyData | null> {
const res = await fetch(server, { signal: AbortSignal.timeout(RKEY_REFRESH_TIMEOUT_MS) });
if (!res.ok) {
throw new Error(`http ${res.status}`);
}
let body = (await res.json()) as Record<string, unknown>;
if (body && typeof body === 'object' && 'retcode' in body && isRecord(body.data)) {
body = body.data;
}
if (!isRecord(body)) {
return null;
}
const groupRkey = typeof body.group_rkey === 'string' ? body.group_rkey : '';
const privateRkey = typeof body.private_rkey === 'string' ? body.private_rkey : '';
if (!groupRkey && !privateRkey) {
return null;
}
const expiredTime = typeof body.expired_time === 'number' ? body.expired_time : 0;
return { groupRkey, privateRkey, expiredTime };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
// QQ-NT download appids embedded in the image URL query string. The scene
// they encode is a property of the image (where it was uploaded), so they
// outrank the carrying message's group/private context — see resolveImageUrl.
const APPID_PRIVATE_IMAGE = '1406';
const APPID_GROUP_IMAGE = '1407';
/** Map the `appid` baked into an NT download URL to its rkey type. Returns
* null when the URL carries no recognised appid, leaving the caller to fall
* back to the message's group/private context. */
function imageRKeyTypeFromUrl(url: string): number | null {
const match = url.match(/[?&]appid=(\d+)/);
if (!match) {
return null;
}
if (match[1] === APPID_PRIVATE_IMAGE) {
return PRIVATE_IMAGE_RKEY_TYPE;
}
if (match[1] === APPID_GROUP_IMAGE) {
return GROUP_IMAGE_RKEY_TYPE;
}
return null;
}
function urlNeedsRKey(url: string): boolean {
if (!url || url.includes('rkey=')) {
return false;
}
if (url.includes('gchat.qpic.cn')) {
return false;
}
return (
url.includes('multimedia.nt.qq.com.cn') ||
url.includes('.nt.qq.com.cn') ||
url.includes('/download')
);
}
function stripRKeyPrefix(rkey: string): string {
for (const prefix of ['&rkey=', '?rkey=']) {
if (rkey.startsWith(prefix)) {
return rkey.slice(prefix.length);
}
}
return rkey;
}
+856
View File
@@ -0,0 +1,856 @@
import { APP_VERSION } from '@snowluma/common/version';
import { createLogger, type Logger } from '@snowluma/common/logger';
import type { BridgeInterface } from '@snowluma/core/bridge-interface';
import {
formatGroup,
formatMessageSegments,
formatReply,
formatUser,
} from '@snowluma/protocol/format';
import path from 'path';
import { ApiHandler, type ActionObserver } from './api-handler';
import type { ConverterContext } from './event-converter';
import { registerEventPipeline, type EventPipelineHandle } from './event-pipeline';
import { buildApiContext, type OneBotInstanceContext } from './instance-context';
import { TempSessionStore } from './temp-session-store';
import { RKeyCache } from './instance-rkey';
import type { GlobalSettings } from './global-config';
import { MediaIndexer } from './media-indexer';
import { MediaStore } from './media-store';
import { MediaUrlResolver } from './media-url-resolver';
import { GROUP_MESSAGE_EVENT, PRIVATE_MESSAGE_EVENT, privateMessageEventName } from './message-id';
import { MessageStore } from './message-store';
import { createMessageIdResolver } from './message-id-resolver';
import { sendGroupMessage, sendPrivateMessage } from './modules/message-actions';
import {
buildStatusText,
matchesStatusCommand,
statusCooldownElapsed,
} from './modules/status-command';
import { loginHistorySyncCoordinator } from './history-sync';
import {
HttpPostAdapter,
HttpServerAdapter,
OneBotNetworkManager,
WsClientAdapter,
WsServerAdapter,
type AdapterStatus,
type DesiredNetworkAdapter,
type NetworkAdapterContext,
type NetworkReconcileResult,
type NetworkShutdownResult,
} from './network';
import { ReactionStore } from './reaction-store';
import { sameSelfSentMessage, selfSentEventKey, SELF_SENT_ECHO_WINDOW_MS } from './self-sent-event';
import type { ApiResponse, JsonObject, JsonValue, MessageMeta, OneBotConfig } from './types';
const moduleLog = createLogger('Event');
export class OneBotInstance {
readonly uin: string;
private readonly bridge: BridgeInterface;
private readonly apiHandler: ApiHandler;
private readonly converterCtx: ConverterContext;
private readonly messageStore: MessageStore;
private readonly mediaStore: MediaStore;
private readonly reactionStore: ReactionStore;
private readonly networkManager: OneBotNetworkManager;
private readonly networkReady: Promise<NetworkReconcileResult>;
private lifecycleTail: Promise<void>;
private readonly rkeyCache: RKeyCache;
private readonly ctx: OneBotInstanceContext;
/** Account-session uptime shared by the status action and `#sl`. */
private readonly startedAt = Date.now();
/** Per-conversation last-reply timestamp for the `#sl` cooldown. */
private readonly statusCommandCooldown = new Map<string, number>();
/** Reported self sends retained while matching subsequent QQ echoes. */
private readonly recentSelfSentEvents = new Map<
string,
{ event: JsonObject; expiresAt: number }
>();
private eventPipeline: EventPipelineHandle | null = null;
private eventPipelineDrain: Promise<void> = Promise.resolve();
private disposePromise: Promise<NetworkShutdownResult> | null = null;
private disposeRequested = false;
private acceptingActions = true;
private readonly inFlightActions = new Set<Promise<void>>();
private readonly historySyncAbortController = new AbortController();
private readonly historySyncEnabledAtStartup: boolean;
private historySyncStartRequested = false;
private historySyncTask: Promise<void> | null = null;
private readonly storeCloseState = {
message: false,
media: false,
reaction: false,
};
private readonly pids = new Set<number>();
private online = true;
private heartbeatTimer: NodeJS.Timeout | null = null;
private static readonly HEARTBEAT_INTERVAL = 30000;
private readonly log: Logger;
get nickname(): string {
return this.bridge.identity.nickname;
}
/** Live status of this account's OneBot network adapters. */
getConnectionStatuses(): AdapterStatus[] {
return this.networkManager.describeStatuses();
}
// ── Debug taps (WebUI debug stream / tester) ──
/** Tap every emitted OneBot event for this account. Returns an unsubscribe. */
subscribeDebugEvents(cb: (event: JsonObject) => void): () => void {
return this.networkManager.subscribeDebug(cb);
}
/** Observe every handled action for this account. Returns an unsubscribe. */
observeActions(cb: ActionObserver): () => void {
return this.apiHandler.setObserver(cb);
}
/** Invoke an OneBot action against this account (debug tester). */
invokeAction(action: string, params: JsonObject): Promise<ApiResponse> {
return this.trackAction(`debug action ${action}`, () =>
this.apiHandler.handle(action, params),
);
}
/** Drive an action through the streaming seam (debug tester, stream actions).
* `rawRequest` is a `{action, params, echo?}` JSON string; `emit` receives
* each frame as a JSON string. Non-stream actions emit a single frame. */
invokeStream(
rawRequest: string,
emit: (json: string) => void | Promise<void>,
isAlive?: () => boolean,
): Promise<void> {
return this.trackAction('debug stream action', () =>
this.apiHandler.processStreamRequest(rawRequest, emit, isAlive),
);
}
constructor(
uin: string,
bridge: BridgeInterface,
config: OneBotConfig,
globalSettings: GlobalSettings,
) {
this.uin = uin;
this.bridge = bridge;
this.historySyncEnabledAtStartup = config.historySync.enabled;
const uinNum = Number.parseInt(uin, 10);
this.log =
Number.isFinite(uinNum) && uinNum > 0 ? moduleLog.child({ uin: uinNum }) : moduleLog;
this.rkeyCache = new RKeyCache(globalSettings.rkey);
this.mediaStore = new MediaStore(path.join('data', this.uin, 'messages.db'));
this.messageStore = new MessageStore(path.join('data', this.uin, 'messages.json'));
this.reactionStore = new ReactionStore(path.join('data', this.uin, 'reactions.db'));
const mediaUrlResolver = new MediaUrlResolver(this.bridge, this.rkeyCache);
const mediaIndexer = new MediaIndexer(this.mediaStore);
this.converterCtx = {
selfId: parseInt(this.uin, 10) || 0,
imageUrlResolver: (element, isGroup) =>
this.rkeyCache.resolveImageUrl(this.bridge, element, isGroup),
mediaUrlResolver: (element, isGroup, sessionId) =>
mediaUrlResolver.resolve(element, isGroup, sessionId),
messageIdResolver: createMessageIdResolver(this.messageStore),
mediaSegmentSink: async (mediaType, element, data, isGroup, sessionId) =>
await mediaIndexer.remember(mediaType, element, data, isGroup, sessionId),
};
const ctx: OneBotInstanceContext = {
uin: this.uin,
selfId: parseInt(this.uin, 10) || 0,
bridge: this.bridge,
messageStore: this.messageStore,
mediaStore: this.mediaStore,
reactionStore: this.reactionStore,
tempSessions: new TempSessionStore(),
converterCtx: this.converterCtx,
config,
musicSignUrl: globalSettings.musicSignUrl,
getUptimeMs: () => Math.max(0, Date.now() - this.startedAt),
cacheMessageMeta: async (messageId, meta) =>
await this.cacheMessageMeta(messageId, meta),
dispatchEvent: (event, source = 'bridge', startedAt) =>
this.dispatchEvent(event, source, startedAt),
};
this.ctx = ctx;
this.apiHandler = new ApiHandler(buildApiContext(ctx), uinNum > 0 ? uinNum : undefined);
const networkCtx = this.buildNetworkContext();
this.networkManager = new OneBotNetworkManager((desired) =>
this.createNetworkAdapter(desired, networkCtx),
);
this.networkReady = Promise.all([
bridge.identity.initialization,
this.messageStore.initialization,
this.mediaStore.initialization,
this.reactionStore.initialization,
]).then(() => this.networkManager.reconcile(config));
this.lifecycleTail = this.networkReady.then(
() => undefined,
() => undefined,
);
this.startHeartbeat();
this.rkeyCache.warmUp(this.bridge, this.uin);
this.eventPipeline = registerEventPipeline(ctx);
}
waitUntilNetworkReady(): Promise<NetworkReconcileResult> {
return this.networkReady;
}
/** Bridge session edge → OneBot notice. Awaited so offline can leave
* adapters before this generation is disposed. */
emitBotStatus(subType: 'online' | 'offline'): Promise<void> {
return this.dispatchEvent(makeBotStatusEvent(parseInt(this.uin, 10) || 0, subType));
}
/**
* Start the optional login-time backfill once roster warmup has completed.
* The startup snapshot is intentional: hot config edits take effect on the
* next account lifecycle and never cause an unexpected QQ request.
*/
startLoginHistorySync(): void {
if (this.historySyncStartRequested || this.disposeRequested) {
return;
}
this.historySyncStartRequested = true;
if (!this.historySyncEnabledAtStartup) {
this.log.debug('login history sync disabled for this account lifecycle');
return;
}
this.historySyncTask = loginHistorySyncCoordinator
.schedule(
{
selfId: Number.parseInt(this.uin, 10) || 0,
bridge: this.bridge,
messageStore: this.messageStore,
converterCtx: this.converterCtx,
},
this.historySyncAbortController.signal,
)
.then(
() => undefined,
(error) => {
if (this.historySyncAbortController.signal.aborted) {
this.log.debug(
'login history sync cancelled: %s',
error instanceof Error ? error.message : String(error),
);
return;
}
this.log.error(
'login history sync failed: %s',
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
},
);
}
reloadConfig(config: OneBotConfig): Promise<NetworkReconcileResult> {
if (this.disposeRequested) {
return Promise.reject(new Error(`OneBot instance UIN=${this.uin} is disposing`));
}
const snapshot = structuredClone(config);
// Validate before entering the FIFO so deterministic errors fail fast.
this.networkManager.compileDesiredPlan(snapshot);
return this.enqueueLifecycle(async () => {
// Keep the shared context's config in sync for exactly the same ordered
// snapshot the network manager is applying. Concurrent POSTs therefore
// linearize as A then B instead of publishing B while A is reconciling.
this.ctx.config = snapshot;
return this.networkManager.reconcile(snapshot);
});
}
/** Apply edited global (all-accounts) settings without a restart. Called by
* the manager when config/snowluma.json is saved from the WebUI. */
applyGlobalSettings(globalSettings: GlobalSettings): void {
this.rkeyCache.setFallbackServers(globalSettings.rkey.fallbackServers);
this.ctx.musicSignUrl = globalSettings.musicSignUrl;
}
/** Drop listeners and sockets without waiting for shutdown to finish.
* Used when dispose() does not settle and a replacement session must bind. */
forceRelease(): void {
try {
this.quiesce();
} catch (error) {
this.log.warn(
'quiesce during forced release failed: %s',
error instanceof Error ? error.message : String(error),
);
}
this.networkManager.forceRelease();
}
/** Synchronously stop every ingress seam for this instance generation.
*
* This operation is sticky and idempotent. It is intentionally separate
* from the asynchronous resource release so the manager can quiesce all
* generations before waiting for older lifecycle work to settle. */
quiesce(): void {
if (this.disposeRequested) {
return;
}
this.disposeRequested = true;
this.acceptingActions = false;
this.apiHandler.quiesce();
this.online = false;
this.stopHeartbeat();
this.historySyncAbortController?.abort(
new Error(`OneBot instance UIN=${this.uin} is disposing`),
);
const pipeline = this.eventPipeline;
if (pipeline) {
pipeline.stop();
this.eventPipeline = null;
this.eventPipelineDrain = pipeline.drain();
}
}
dispose(): Promise<NetworkShutdownResult> {
if (this.disposePromise) {
return this.disposePromise;
}
this.quiesce();
const attempt = this.enqueueLifecycle(async () => {
await Promise.all([
Promise.all(this.inFlightActions),
this.eventPipelineDrain,
this.historySyncTask ?? Promise.resolve(),
]);
const shutdown = await this.networkManager.shutdown();
if (!shutdown.closed) {
throw new AggregateError(
shutdown.errors.map(
(error) =>
new Error(
`adapter ${error.name} ${error.phase} failed: ${error.message}`,
),
),
`network shutdown incomplete for UIN=${this.uin}; stores remain open`,
);
}
// Stores are intentionally closed only after every queued network
// reconcile and every transport have actually closed.
const closeErrors: unknown[] = [];
const stores = [
['message', async () => await this.messageStore.close()],
['media', async () => await this.mediaStore.close()],
['reaction', async () => await this.reactionStore.close()],
] as const;
for (const [name, close] of stores) {
if (this.storeCloseState[name]) {
continue;
}
try {
await close();
this.storeCloseState[name] = true;
} catch (error) {
closeErrors.push(error);
}
}
if (closeErrors.length > 0) {
throw new AggregateError(
closeErrors,
`failed to close OneBot stores for UIN=${this.uin}`,
);
}
return shutdown;
});
this.disposePromise = attempt;
void attempt.then(
() => undefined,
() => {
// Network-close and per-store completion state are retained. Let a
// later dispose call retry only the meaningful unfinished remainder.
if (this.disposePromise === attempt) {
this.disposePromise = null;
}
},
);
return attempt;
}
addPid(pid: number): void {
this.pids.add(pid);
}
removePid(pid: number): void {
this.pids.delete(pid);
}
hasPid(pid: number): boolean {
return this.pids.has(pid);
}
getPids(): number[] {
return [...this.pids];
}
get empty(): boolean {
return this.pids.size === 0;
}
private async dispatchEvent(
event: JsonObject,
source: 'bridge' | 'send' = 'bridge',
startedAt = Date.now(),
): Promise<void> {
if (!(await this.cacheMessageEvent(event))) {
this.traceEventHandoffTerminal(event, startedAt, 'suppressed', 'private_recall');
return;
}
if (source === 'bridge') {
if (this.isDuplicateSelfSentEcho(event)) {
this.log.trace(
'duplicate self-message echo suppressed message_id=%d message_seq=%d',
toInt(event.message_id),
toInt(event.message_seq),
);
this.traceEventHandoffTerminal(
event,
startedAt,
'suppressed',
'duplicate_self_echo',
);
return;
}
if (event.post_type === 'message_sent') {
this.rememberSelfSentEvent(event);
}
} else {
this.rememberSelfSentEvent(event);
}
// The send path already logged the real peer and payload. Logging its
// synthetic event here would produce a second, less accurate "received"
// line whose private user_id points at the bot itself.
if (source === 'bridge') {
await this.logReceivedMessage(event);
}
// Built-in `#sl` only consumes events observed from QQ. Action-originated
// messages must not recursively trigger the command handler.
if (source === 'bridge' && this.handleStatusCommand(event)) {
this.traceEventHandoffTerminal(event, startedAt, 'suppressed', 'status_command');
return;
}
this.traceEventHandoffTerminal(event, startedAt, 'reporting_started');
try {
await this.networkManager.emitEvent(event);
} catch (err) {
this.log.warn(
'emitEvent failed: %s',
err instanceof Error ? (err.stack ?? err.message) : String(err),
);
}
}
private traceEventHandoffTerminal(
event: JsonObject,
startedAt: number,
outcome: 'suppressed' | 'reporting_started',
reason?: 'duplicate_self_echo' | 'status_command' | 'private_recall',
): void {
this.log.trace(() => [
'event_terminal post_type=%s outcome=%s%s ms=%d',
String(event.post_type ?? '?'),
outcome,
reason === undefined ? '' : ` reason=${reason}`,
Date.now() - startedAt,
]);
}
private rememberSelfSentEvent(event: JsonObject): void {
const key = selfSentEventKey(event);
if (key === null) {
return;
}
const now = Date.now();
this.sweepSelfSentEvents(now);
this.recentSelfSentEvents.set(key, { event, expiresAt: now + SELF_SENT_ECHO_WINDOW_MS });
}
private isDuplicateSelfSentEcho(event: JsonObject): boolean {
const key = selfSentEventKey(event);
if (key === null) {
return false;
}
const now = Date.now();
this.sweepSelfSentEvents(now);
const pending = this.recentSelfSentEvents.get(key);
if (!pending || !sameSelfSentMessage(pending.event, event)) {
return false;
}
return true;
}
private sweepSelfSentEvents(now: number): void {
for (const [key, pending] of this.recentSelfSentEvents) {
if (pending.expiresAt <= now) {
this.recentSelfSentEvents.delete(key);
}
}
}
/**
* Built-in `#sl` status command. Returns `true` when the event matched AND
* `swallow` is configured — the caller then skips `emitEvent` so downstream
* adapters never see it. The reply itself is fired async and rate-limited
* per conversation; matching and replying are independent of swallowing.
*/
private handleStatusCommand(event: JsonObject): boolean {
const cfg = this.ctx.config.statusCommand;
if (!cfg.enabled) {
return false;
}
const postType = event.post_type;
if (postType !== 'message' && postType !== 'message_sent') {
return false;
}
if (!matchesStatusCommand(event.message, cfg.trigger)) {
return false;
}
const isGroup = event.message_type === 'group';
const sessionId = isGroup ? toInt(event.group_id) : toInt(event.user_id);
if (sessionId === 0) {
return cfg.swallow;
}
const key = `${isGroup ? 'g' : 'p'}:${sessionId}`;
const now = Date.now();
if (statusCooldownElapsed(this.statusCommandCooldown.get(key), now, cfg.cooldownSeconds)) {
this.statusCommandCooldown.set(key, now);
void this.trackAction('status command reply', () =>
this.replyStatus(isGroup, sessionId),
);
}
return cfg.swallow;
}
private async replyStatus(isGroup: boolean, sessionId: number): Promise<void> {
const text = buildStatusText({
version: APP_VERSION,
platform: process.platform,
arch: process.arch,
uptimeMs: this.ctx.getUptimeMs(),
});
if (isGroup) {
await sendGroupMessage(this.ctx, sessionId, text, true);
} else {
await sendPrivateMessage(this.ctx, sessionId, text, true);
}
}
private buildNetworkContext(): NetworkAdapterContext {
return {
uin: this.uin,
api: this.apiHandler,
buildLifecycleEvent: (subType) => this.makeLifecycleEvent(subType),
buildHeartbeatEvent: () => this.makeHeartbeatEvent(),
};
}
private createNetworkAdapter(desired: DesiredNetworkAdapter, ctx: NetworkAdapterContext) {
switch (desired.kind) {
case 'httpServer':
return new HttpServerAdapter(desired.name, desired.config, ctx);
case 'httpClient':
return new HttpPostAdapter(desired.name, desired.config, ctx);
case 'wsServer':
return new WsServerAdapter(desired.name, desired.config, ctx);
case 'wsClient':
return new WsClientAdapter(desired.name, desired.config, ctx);
}
}
private enqueueLifecycle<T>(operation: () => Promise<T>): Promise<T> {
const result = this.lifecycleTail.then(operation);
this.lifecycleTail = result.then(
() => undefined,
() => undefined,
);
return result;
}
private trackAction<T>(label: string, start: () => Promise<T>): Promise<T> {
if (!this.acceptingActions) {
return this.rejectedAction(
label,
new Error(`OneBot instance UIN=${this.uin} is disposing; ${label} rejected`),
);
}
let action: Promise<T>;
try {
action = start();
} catch (error) {
return this.rejectedAction(label, error);
}
const tracked = action.then(
() => undefined,
(error) => {
this.log.error(
'%s failed: %s',
label,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
},
);
this.inFlightActions.add(tracked);
void tracked.then(() => {
this.inFlightActions.delete(tracked);
});
return action;
}
private rejectedAction<T>(label: string, error: unknown): Promise<T> {
const rejected = Promise.reject<T>(error);
void rejected.catch((reason) => {
this.log.error(
'%s rejected: %s',
label,
reason instanceof Error ? (reason.stack ?? reason.message) : String(reason),
);
});
return rejected;
}
private async logReceivedMessage(event: JsonObject): Promise<void> {
const isSelf = event.post_type === 'message_sent';
if (event.post_type !== 'message' && !isSelf) {
return;
}
const messageId = toInt(event.message_id);
const isGroup = event.message_type === 'group';
const idStr = `ID:${messageId}`;
const selfTag = isSelf ? '[自身] ' : '';
const identity = this.bridge.identity;
// Walk the segment array once: render via the shared formatter for
// non-reply segments, and resolve `reply` segments through the
// message store so the chain reference becomes legible
// ("[回复 <user>: <body>...]" instead of "[回复:1234567890]").
const renderedParts: string[] = [];
const message = event.message;
if (Array.isArray(message)) {
for (const seg of message) {
if (typeof seg !== 'object' || seg === null || Array.isArray(seg)) {
continue;
}
const type = String((seg as JsonObject).type ?? '');
const data =
typeof (seg as JsonObject).data === 'object' &&
(seg as JsonObject).data !== null &&
!Array.isArray((seg as JsonObject).data)
? ((seg as JsonObject).data as Record<string, unknown>)
: {};
if (type === 'reply') {
const replyId = toInt(data.id);
renderedParts.push(await formatReply(this.messageStore, identity, replyId));
} else {
renderedParts.push(formatMessageSegments([seg as JsonValue]));
}
}
} else if (typeof message === 'string') {
renderedParts.push(formatMessageSegments(message));
}
const content = renderedParts.join(' ').trim() || '[空消息]';
if (isGroup) {
const groupId = toInt(event.group_id);
const userId = toInt(event.user_id);
const sender =
typeof event.sender === 'object' &&
event.sender !== null &&
!Array.isArray(event.sender)
? (event.sender as JsonObject)
: {};
const nicknameFromEvent = (sender.card as string) || (sender.nickname as string) || '';
const userPart = nicknameFromEvent
? `[${nicknameFromEvent}(${userId})]`
: formatUser(identity, groupId, userId);
this.log.success(
`${selfTag}群 ${formatGroup(identity, groupId)} | ${userPart}: ${idStr} ${content}`,
);
} else {
const userId = toInt(event.user_id);
const sender =
typeof event.sender === 'object' &&
event.sender !== null &&
!Array.isArray(event.sender)
? (event.sender as JsonObject)
: {};
const nicknameFromEvent = (sender.nickname as string) || '';
const userPart = nicknameFromEvent
? `[${nicknameFromEvent}(${userId})]`
: formatUser(identity, undefined, userId);
this.log.success(`${selfTag}私聊 ${userPart}: ${idStr} ${content}`);
}
}
private async cacheMessageEvent(event: JsonObject): Promise<boolean> {
if (event.post_type !== 'message' && event.post_type !== 'message_sent') {
return true;
}
const messageId = toInt(event.message_id);
if (messageId === 0) {
return true;
}
const isGroup = event.message_type === 'group';
const sessionId = isGroup
? toInt(event.group_id)
: event.post_type === 'message_sent'
? toInt(event.target_id) || toInt(event.user_id)
: toInt(event.user_id);
const sequence = toInt(event.message_seq);
const eventName = isGroup
? GROUP_MESSAGE_EVENT
: event.sub_type === 'friend'
? privateMessageEventName(event.post_type === 'message_sent', false)
: PRIVATE_MESSAGE_EVENT;
if (sessionId === 0) {
return true;
}
return this.messageStore.storeEvent(
messageId,
isGroup,
sessionId,
sequence,
eventName,
event,
);
}
private async cacheMessageMeta(messageId: number, meta: MessageMeta): Promise<void> {
if (!Number.isInteger(messageId) || messageId === 0) {
return;
}
await this.messageStore.storeMeta(messageId, meta);
}
private makeLifecycleEvent(subType: 'connect' | 'enable' | 'disable'): JsonObject {
const selfId = parseInt(this.uin, 10) || 0;
const time = Math.floor(Date.now() / 1000);
return {
time,
self_id: selfId,
post_type: 'meta_event',
meta_event_type: 'lifecycle',
sub_type: subType,
status: {
online: this.online,
good: this.online && this.bridge.receiveHealthy,
},
};
}
private startHeartbeat(): void {
this.heartbeatTimer = setInterval(() => {
this.dispatchEvent(this.makeHeartbeatEvent());
}, OneBotInstance.HEARTBEAT_INTERVAL);
this.heartbeatTimer.unref();
}
private makeHeartbeatEvent(): JsonObject {
const selfId = parseInt(this.uin, 10) || 0;
const time = Math.floor(Date.now() / 1000);
return {
time,
self_id: selfId,
post_type: 'meta_event',
meta_event_type: 'heartbeat',
status: { online: this.online, good: this.online && this.bridge.receiveHealthy },
interval: OneBotInstance.HEARTBEAT_INTERVAL,
};
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
}
export function makeBotStatusEvent(
selfId: number,
subType: 'online' | 'offline',
time = Math.floor(Date.now() / 1000),
): JsonObject {
return {
time,
self_id: selfId,
post_type: 'notice',
notice_type: 'bot_status',
sub_type: subType,
user_id: selfId,
};
}
function toInt(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.trunc(value);
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return Math.trunc(parsed);
}
}
return 0;
}
+828
View File
@@ -0,0 +1,828 @@
import { setVideoUploadConcurrency } from '@snowluma/protocol/highway/upload-settings';
import { createLogger, runWithoutRequestContext } from '@snowluma/common/logger';
import type { BridgeInterface, BridgeSessionSource } from '@snowluma/core/bridge-interface';
import { loadOneBotConfig, OneBotConfigValidationError } from './config';
import { loadGlobalSettings } from './global-config';
import { OneBotInstance } from './instance';
import type { MessageStoreMigrationStatus } from './message-store-migration';
import {
createMessageStoreMigrationTask,
estimateRemainingSeconds,
} from './message-store-migration-task';
import type { AdapterStatus, NetworkApplyError, NetworkShutdownResult } from './network';
import type { OneBotConfig } from './types';
const log = createLogger('OneBot');
class InstanceLifecycleError extends Error {
constructor(
readonly instance: OneBotInstance,
readonly rootCause: unknown,
) {
super(rootCause instanceof Error ? rootCause.message : String(rootCause));
this.name = 'InstanceLifecycleError';
}
}
export interface AccountDatabaseMigration {
phase: 'preparing' | 'migrating' | 'complete' | 'failed';
usable: boolean;
processed: number;
total: number | null;
progress: number | null;
estimatedRemainingSeconds: number | null;
error?: string;
}
export interface DatabaseMigrationCallbacks {
onReady(): void;
onProgress(status: MessageStoreMigrationStatus, elapsedMs: number): void;
onFailed(error: unknown): void;
}
export interface DatabaseMigrationTask {
start(callbacks: DatabaseMigrationCallbacks): void;
beginMigration(): void;
cancel(): void;
}
/** A peer that never finishes a close handshake must not keep the next
* account session offline. Ten seconds covers a normal drain.
* SNOWLUMA_SESSION_RELEASE_DEADLINE_MS overrides the configured value. */
const DEFAULT_SESSION_RELEASE_DEADLINE_MS = 10_000;
const SESSION_RELEASE_DEADLINE_ENV = 'SNOWLUMA_SESSION_RELEASE_DEADLINE_MS';
const MAX_SESSION_RELEASE_DEADLINE_MS = 2_147_483_647;
/** Positive integer milliseconds, or `undefined` when the raw value is absent
* or not usable. Callers decide the fallback. */
export function parseSessionReleaseDeadlineMs(raw: string | undefined): number | undefined {
if (raw === undefined) {
return undefined;
}
const value = raw.trim();
if (!/^[1-9]\d*$/.test(value)) {
return undefined;
}
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed > MAX_SESSION_RELEASE_DEADLINE_MS) {
return undefined;
}
return parsed;
}
/** Env wins over an explicit deadline. An unusable env value is ignored. */
export function resolveSessionReleaseDeadlineMs(configured?: number, envRaw?: string): number {
const fromEnv = parseSessionReleaseDeadlineMs(envRaw);
if (fromEnv !== undefined) {
return fromEnv;
}
if (
configured !== undefined &&
Number.isSafeInteger(configured) &&
configured > 0 &&
configured <= MAX_SESSION_RELEASE_DEADLINE_MS
) {
return configured;
}
return DEFAULT_SESSION_RELEASE_DEADLINE_MS;
}
export interface OneBotManagerOptions {
/** How long a retiring instance may block the replacement session.
* SNOWLUMA_SESSION_RELEASE_DEADLINE_MS overrides this when it is set. */
sessionReleaseDeadlineMs?: number;
createDatabaseMigrationTask?: (uin: string) => DatabaseMigrationTask;
createInstance?: (
uin: string,
bridge: BridgeInterface,
config: OneBotConfig,
globalSettings: ReturnType<typeof loadGlobalSettings>,
) => OneBotInstance;
}
/** Per-account OneBot connection health, surfaced to the WebUI dashboard. */
export interface AccountConnections {
uin: string;
nickname: string;
adapters: AdapterStatus[];
databaseMigration?: AccountDatabaseMigration;
}
export interface ConfigApplyResult {
online: boolean;
applied: boolean;
errors: NetworkApplyError[];
adapters: AdapterStatus[];
}
export class OneBotManager {
private readonly instances = new Map<string, OneBotInstance>();
private readonly pendingLifecycle = new Set<Promise<void>>();
private readonly retiringInstances = new Set<OneBotInstance>();
private readonly lifecycleFailures: Array<{
label: string;
error: unknown;
instances?: Set<OneBotInstance>;
}> = [];
private readonly pendingStarts = new Map<string, { bridge: BridgeInterface }>();
private readonly databaseMigrations = new Map<
string,
{
bridge: BridgeInterface;
task: DatabaseMigrationTask;
state: AccountDatabaseMigration;
rowsPerSecond: number | null;
retryTimer: NodeJS.Timeout | null;
}
>();
private readonly createDatabaseMigrationTask: (uin: string) => DatabaseMigrationTask;
private readonly createInstance: NonNullable<OneBotManagerOptions['createInstance']>;
private disposePromise: Promise<void> | null = null;
private disposed = false;
private unsubscribeSessions: (() => void) | null = null;
private readonly sessionReleaseDeadlineMs: number;
constructor(options: OneBotManagerOptions = {}) {
setVideoUploadConcurrency(loadGlobalSettings().videoUploadConcurrency ?? 1);
const envRaw = process.env[SESSION_RELEASE_DEADLINE_ENV];
if (
envRaw !== undefined &&
envRaw.trim() &&
parseSessionReleaseDeadlineMs(envRaw) === undefined
) {
log.warn(
'%s=%j is ignored; expected a positive integer number of milliseconds',
SESSION_RELEASE_DEADLINE_ENV,
envRaw,
);
}
this.sessionReleaseDeadlineMs = resolveSessionReleaseDeadlineMs(
options.sessionReleaseDeadlineMs,
envRaw,
);
this.createDatabaseMigrationTask =
options.createDatabaseMigrationTask ?? createMessageStoreMigrationTask;
this.createInstance =
options.createInstance ??
((uin, bridge, config, globalSettings) =>
new OneBotInstance(uin, bridge, config, globalSettings));
}
bind(bridgeManager: BridgeSessionSource): void {
if (this.disposed) {
return;
}
this.unsubscribeSessions?.();
this.unsubscribeSessions = bridgeManager.subscribeSessions({
started: (uin, bridge) => {
runWithoutRequestContext(() => this.onSessionStarted(uin, bridge));
},
closed: (uin) => this.onSessionClosed(uin),
});
}
getInstance(uin: string): OneBotInstance | null {
return this.instances.get(uin) ?? null;
}
getInstances(): OneBotInstance[] {
return [...this.instances.values()];
}
/** Live OneBot adapter status for active accounts plus failed retiring
* generations. A zombie that still owns a port must remain observable. */
getConnectionStatuses(): AccountConnections[] {
const visible = new Set([...this.instances.values(), ...this.retiringInstances]);
const connections = [...visible].map((instance) => {
const migration = this.databaseMigrations.get(instance.uin);
return {
uin: instance.uin,
nickname: instance.nickname,
adapters: instance.getConnectionStatuses(),
...(migration ? { databaseMigration: migration.state } : {}),
};
});
const activeUins = new Set(this.instances.keys());
for (const [uin, migration] of this.databaseMigrations) {
if (activeUins.has(uin)) {
continue;
}
connections.push({
uin,
nickname: migration.bridge.identity?.nickname || uin,
adapters: [],
databaseMigration: migration.state,
});
}
return connections;
}
async reloadConfig(uin: string, config: OneBotConfig): Promise<ConfigApplyResult> {
const instance = this.instances.get(uin);
if (!instance) {
return { online: false, applied: false, errors: [], adapters: [] };
}
const result = await runWithoutRequestContext(() => instance.reloadConfig(config));
if (result.applied) {
log.info('configuration applied: UIN=%s adapters=%d', uin, result.statuses.length);
} else {
log.warn(
'configuration saved but not fully applied: UIN=%s failures=%d',
uin,
result.errors.length,
);
}
return {
online: true,
applied: result.applied,
errors: result.errors,
adapters: result.statuses,
};
}
/** Re-read global (all-accounts) settings from config/snowluma.json and push
* them to every live instance. Called after the WebUI saves global config. */
reloadGlobalSettings(): void {
const globalSettings = loadGlobalSettings();
setVideoUploadConcurrency(globalSettings.videoUploadConcurrency ?? 1);
for (const instance of this.instances.values()) {
instance.applyGlobalSettings(globalSettings);
}
log.info('global settings reloaded for %d instance(s)', this.instances.size);
}
dispose(): Promise<void> {
if (this.disposePromise) {
return this.disposePromise;
}
this.disposed = true;
this.unsubscribeSessions?.();
this.unsubscribeSessions = null;
const migrations = [...this.databaseMigrations.values()];
this.databaseMigrations.clear();
for (const migration of migrations) {
if (migration.retryTimer) {
clearTimeout(migration.retryTimer);
}
migration.task.cancel();
}
for (const instance of this.instances.values()) {
this.retiringInstances.add(instance);
}
// Quiesce every live generation before awaiting older startup/shutdown
// work. A deferred lifecycle operation must not leave Action ingress open
// during process teardown.
for (const instance of this.retiringInstances) {
instance.quiesce();
}
this.instances.clear();
const attempt = (async () => {
// Let already-tracked startup/session-close work settle first. Rejected
// operations are recorded by trackLifecycle and their instances remain
// in retiringInstances for the retry below.
await Promise.all(this.pendingLifecycle);
let failures = this.lifecycleFailures.splice(0);
const targets = [...this.retiringInstances];
const settled = await Promise.allSettled(targets.map((instance) => instance.dispose()));
for (let index = 0; index < settled.length; index += 1) {
const instance = targets[index];
const result = settled[index];
const shutdownLabel = `network shutdown UIN=${instance.uin}`;
if (result.status === 'rejected') {
failures.push({
label: shutdownLabel,
error: result.reason,
instances: new Set([instance]),
});
continue;
}
this.retirementSucceeded(instance);
// A later successful retry supersedes failures only for this exact
// generation. Same-UIN instances must never clear one another.
failures = failures.flatMap((failure) => {
if (!failure.instances?.has(instance)) {
return [failure];
}
const remaining = new Set(failure.instances);
remaining.delete(instance);
return remaining.size > 0 ? [{ ...failure, instances: remaining }] : [];
});
}
if (failures.length > 0) {
throw new AggregateError(
failures.map(
({ label, error }) =>
new Error(
`${label}: ${error instanceof Error ? error.message : String(error)}`,
),
),
'failed to dispose OneBot manager cleanly',
);
}
this.pendingStarts.clear();
})();
this.disposePromise = attempt;
void attempt.then(
() => undefined,
() => {
if (this.disposePromise === attempt) {
this.disposePromise = null;
}
},
);
return attempt;
}
private onSessionStarted(uin: string, bridge: BridgeInterface): void {
if (this.disposed || this.instances.has(uin) || this.pendingStarts.has(uin)) {
return;
}
const retiring = [...this.retiringInstances].filter((instance) => instance.uin === uin);
if (retiring.length > 0) {
const pending = { bridge };
this.pendingStarts.set(uin, pending);
this.trackLifecycle(
`session handoff UIN=${uin}`,
this.finishRetiringBeforeStart(uin, pending, retiring),
);
return;
}
this.prepareDatabase(uin, bridge);
}
private prepareDatabase(uin: string, bridge: BridgeInterface): void {
if (this.disposed || this.instances.has(uin) || this.databaseMigrations.has(uin)) {
return;
}
const migration = {
bridge,
task: this.createDatabaseMigrationTask(uin),
state: preparingMigrationState(),
rowsPerSecond: null as number | null,
retryTimer: null as NodeJS.Timeout | null,
};
this.databaseMigrations.set(uin, migration);
const startTask = (): void => {
const task = migration.task;
const isCurrent = () =>
this.databaseMigrations.get(uin) === migration &&
migration.task === task &&
migration.state.phase !== 'failed' &&
migration.state.phase !== 'complete';
const handleFailure = (error: unknown, stage = 'database migration'): void => {
if (!isCurrent()) {
return;
}
const retryable = !(error instanceof OneBotConfigValidationError);
const detail = error instanceof Error ? error.message : String(error);
const label = stage === 'session startup' ? '账号启动失败' : '数据库迁移失败';
migration.state = {
...migration.state,
phase: 'failed',
estimatedRemainingSeconds: null,
error: `${label}:${detail}${retryable ? ';将自动重试' : ';请修正配置后重启'}`,
};
log.error(
'%s failed: UIN=%s retryable=%s error=%s',
stage,
uin,
retryable,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
task.cancel();
if (!retryable || migration.retryTimer) {
return;
}
migration.retryTimer = setTimeout(() => {
migration.retryTimer = null;
if (this.databaseMigrations.get(uin) !== migration) {
return;
}
migration.task = this.createDatabaseMigrationTask(uin);
migration.state = migration.state.usable
? { ...migration.state, phase: 'migrating', usable: true, error: undefined }
: preparingMigrationState();
startTask();
}, 5_000);
migration.retryTimer.unref();
};
try {
task.start({
onReady: () => {
if (!isCurrent()) {
return;
}
try {
this.startSession(uin, bridge);
} catch (error) {
handleFailure(error, 'session startup');
return;
}
migration.state = {
...migration.state,
phase: 'migrating',
usable: true,
error: undefined,
};
try {
task.beginMigration();
} catch (error) {
handleFailure(error);
}
},
onProgress: (status, elapsedMs) => {
if (!isCurrent()) {
return;
}
const previousProcessed = migration.state.processed;
const processedDelta = Math.max(0, status.processed - previousProcessed);
if (processedDelta > 0 && elapsedMs > 0) {
const observed = processedDelta / (elapsedMs / 1000);
migration.rowsPerSecond =
migration.rowsPerSecond === null
? observed
: migration.rowsPerSecond * 0.7 + observed * 0.3;
}
migration.state = migrationPublicState(status, migration.rowsPerSecond);
if (status.phase === 'complete') {
task.cancel();
}
},
onFailed: handleFailure,
});
} catch (error) {
handleFailure(error);
}
};
startTask();
}
private startSession(uin: string, bridge: BridgeInterface): void {
if (this.disposed || this.instances.has(uin)) {
return;
}
const config = loadOneBotConfig(uin, { persistDefaults: true });
const instance = this.createInstance(uin, bridge, config, loadGlobalSettings());
const activePid = bridge.activePid;
if (activePid !== null) {
instance.addPid(activePid);
}
if (!bridge.identity.nickname) {
bridge.identity.nickname = uin;
}
this.instances.set(uin, instance);
log.info('session started: UIN=%s', uin);
this.trackLifecycle(
`network startup UIN=${uin}`,
instance.waitUntilNetworkReady().then(async (result) => {
if (result.applied) {
log.info(
'network startup applied: UIN=%s adapters=%d',
uin,
result.statuses.length,
);
} else {
log.warn(
'network startup degraded: UIN=%s failures=%d',
uin,
result.errors.length,
);
}
if (this.disposed || this.instances.get(uin) !== instance) {
return;
}
try {
await instance.emitBotStatus('online');
} catch (error) {
log.warn(
'bot_status online failed: UIN=%s: %s',
uin,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
}
}),
);
void this.armLoginHistorySync(uin, instance, bridge);
}
private async armLoginHistorySync(
uin: string,
instance: OneBotInstance,
bridge: BridgeInterface,
): Promise<void> {
let warmup;
try {
warmup = await bridge.whenRosterWarmupSettled();
} catch (err) {
log.warn(
'warmup error for UIN %s: %s',
uin,
err instanceof Error ? (err.stack ?? err.message) : String(err),
);
return;
}
if (this.disposed || this.instances.get(uin) !== instance) {
return;
}
if (warmup.friendsLoaded && warmup.groupsLoaded) {
instance.startLoginHistorySync();
return;
}
log.warn(
'login history sync skipped because roster warmup was incomplete: ' +
'UIN=%s friendsLoaded=%s groupsLoaded=%s',
uin,
String(warmup.friendsLoaded),
String(warmup.groupsLoaded),
);
}
private onSessionClosed(uin: string): void {
if (this.disposed) {
return;
}
const migration = this.databaseMigrations.get(uin);
if (migration) {
this.databaseMigrations.delete(uin);
if (migration.retryTimer) {
clearTimeout(migration.retryTimer);
}
migration.task.cancel();
}
const instance = this.instances.get(uin);
if (!instance) {
this.pendingStarts.delete(uin);
return;
}
this.instances.delete(uin);
this.retiringInstances.add(instance);
this.trackLifecycle(`network shutdown UIN=${uin}`, this.emitOfflineThenDispose(instance), [
instance,
]);
log.info('session closed: UIN=%s', uin);
}
private async emitOfflineThenDispose(instance: OneBotInstance): Promise<NetworkShutdownResult> {
try {
await instance.emitBotStatus('offline');
} catch (error) {
log.warn(
'bot_status offline failed: UIN=%s: %s',
instance.uin,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
}
const result = await instance.dispose();
this.retirementSucceeded(instance);
return result;
}
private async releaseRetiringInstance(
uin: string,
pending: { bridge: BridgeInterface },
instance: OneBotInstance,
): Promise<void> {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<'timeout'>((resolve) => {
timer = setTimeout(() => resolve('timeout'), this.sessionReleaseDeadlineMs);
timer.unref();
});
// Map rejection here so a dispose() that fails after the deadline still
// has a handler. The race may already have moved on.
const releasing = instance.dispose().then(
() => 'released' as const,
(error: unknown) => ({ failed: error }),
);
try {
const outcome = await Promise.race([releasing, timeout]);
if (outcome === 'timeout') {
log.error(
'session release timed out; forcing adapters closed and starting the replacement: UIN=%s',
uin,
);
try {
instance.forceRelease();
} catch (error) {
log.error(
'forced session release failed: UIN=%s: %s',
uin,
error instanceof Error ? error.message : String(error),
);
}
return;
}
if (typeof outcome === 'object') {
// Do not leave this UIN permanently guarded by a failed handoff. A
// later session-start observation may retry the still-visible retire.
if (this.pendingStarts.get(uin) === pending) {
this.pendingStarts.delete(uin);
}
throw new InstanceLifecycleError(instance, outcome.failed);
}
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
private async finishRetiringBeforeStart(
uin: string,
pending: { bridge: BridgeInterface },
retiring: OneBotInstance[],
): Promise<void> {
for (const instance of retiring) {
await this.releaseRetiringInstance(uin, pending, instance);
this.retirementSucceeded(instance);
}
if (this.pendingStarts.get(uin) !== pending) {
return;
}
this.pendingStarts.delete(uin);
if (this.disposed) {
return;
}
this.prepareDatabase(uin, pending.bridge);
}
private trackLifecycle(
label: string,
operation: Promise<unknown>,
instances?: OneBotInstance[],
): void {
const tracked = operation.then(
() => undefined,
(error) => {
const rootCause = error instanceof InstanceLifecycleError ? error.rootCause : error;
const relatedInstances =
error instanceof InstanceLifecycleError ? [error.instance] : instances;
log.error(
'%s failed: %s',
label,
rootCause instanceof Error
? (rootCause.stack ?? rootCause.message)
: String(rootCause),
);
this.lifecycleFailures.push({
label,
error: rootCause,
...(relatedInstances && relatedInstances.length > 0
? { instances: new Set(relatedInstances) }
: {}),
});
},
);
this.pendingLifecycle.add(tracked);
void tracked.then(() => {
this.pendingLifecycle.delete(tracked);
});
}
private retirementSucceeded(instance: OneBotInstance): void {
this.retiringInstances.delete(instance);
for (let index = this.lifecycleFailures.length - 1; index >= 0; index -= 1) {
const failure = this.lifecycleFailures[index];
if (!failure.instances?.has(instance)) {
continue;
}
failure.instances.delete(instance);
if (failure.instances.size === 0) {
this.lifecycleFailures.splice(index, 1);
}
}
}
}
function preparingMigrationState(): AccountDatabaseMigration {
return {
phase: 'preparing',
usable: false,
processed: 0,
total: null,
progress: null,
estimatedRemainingSeconds: null,
};
}
function migrationPublicState(
status: MessageStoreMigrationStatus,
rowsPerSecond: number | null,
): AccountDatabaseMigration {
const progress = status.total === 0 ? 1 : Math.min(1, status.processed / status.total);
return {
phase: status.phase,
usable: true,
processed: status.processed,
total: status.total,
progress,
estimatedRemainingSeconds: estimateRemainingSeconds(status, rowsPerSecond),
};
}
+78
View File
@@ -0,0 +1,78 @@
import type { MessageElement } from '@snowluma/protocol/events';
import type { MediaStore } from './media-store';
import type { JsonObject } from './types';
export type MediaType = 'image' | 'record' | 'video';
export class MediaIndexer {
constructor(private readonly mediaStore: MediaStore) {}
async remember(
mediaType: MediaType,
element: MessageElement,
data: JsonObject,
isGroup: boolean,
sessionId: number,
): Promise<void> {
const url = typeof data.url === 'string' ? data.url : '';
const file = typeof data.file === 'string' ? data.file : '';
if (mediaType === 'image') {
await this.mediaStore.rememberImage({
file: file || element.fileId || '',
url,
fileSize: element.fileSize ?? 0,
fileName: element.fileName || element.fileId || '',
subType: element.subType ?? 0,
summary: element.summary ?? '',
imageUrl: element.imageUrl ?? '',
isGroup,
sessionId,
md5Hex: element.md5Hex,
sha1Hex: element.sha1Hex,
width: element.width,
height: element.height,
picFormat: element.picFormat,
});
return;
}
if (mediaType === 'record') {
await this.mediaStore.rememberRecord({
file: file || element.fileName || element.fileId || '',
fileId: element.fileId ?? '',
url,
fileSize: element.fileSize ?? 0,
fileName: element.fileName ?? '',
duration: element.duration ?? 0,
fileHash: element.fileHash ?? '',
mediaNode: element.mediaNode,
isGroup,
sessionId,
md5Hex: element.md5Hex,
sha1Hex: element.sha1Hex,
voiceFormat: element.voiceFormat,
});
return;
}
// video
await this.mediaStore.rememberVideo({
file: file || element.fileName || element.fileId || '',
fileId: element.fileId ?? '',
url,
fileSize: element.fileSize ?? 0,
fileName: element.fileName ?? '',
duration: element.duration ?? 0,
fileHash: element.fileHash ?? '',
mediaNode: element.mediaNode,
isGroup,
sessionId,
md5Hex: element.md5Hex,
sha1Hex: element.sha1Hex,
width: element.width,
height: element.height,
videoFormat: element.videoFormat,
});
}
}
@@ -0,0 +1,137 @@
import fs from 'node:fs';
import path from 'node:path';
import { SqliteDatabase } from '@snowluma/sqlite';
import { createLogger } from '@snowluma/common/logger';
import { openSqliteDb } from './sqlite-open';
import {
foldMediaKey,
MediaStore,
rollbackMediaTransaction,
serializeMediaInfo,
} from './media-store';
const log = createLogger('OneBot.MediaMigration');
const BATCH_SIZE = 200;
/** Runs in the existing database worker, before the account is marked ready.
* Copy only: leave the old database (including its WAL) intact for recovery.
* Each committed batch advances its cursor in the destination transaction.
*/
export async function migrateLegacyMedia(
messageDbPath: string,
cancelled: () => boolean = () => false,
): Promise<void> {
const store = new MediaStore(messageDbPath);
await store.close();
const destination = openSqliteDb(messageDbPath);
let source: SqliteDatabase | undefined;
try {
await destination.exec(`CREATE TABLE IF NOT EXISTS media_migration_state (
id INTEGER PRIMARY KEY CHECK (id = 1), phase TEXT NOT NULL, cursor INTEGER NOT NULL
); INSERT OR IGNORE INTO media_migration_state VALUES (1, 'entries', 0)`);
const state = (await destination
.prepare('SELECT phase, cursor FROM media_migration_state WHERE id = 1')
.get()) as { phase: string; cursor: number };
if (state.phase === 'complete') {
return;
}
const sourcePath = path.join(path.dirname(messageDbPath), 'media.db');
if (!fs.existsSync(sourcePath)) {
await destination.exec(
"UPDATE media_migration_state SET phase = 'complete' WHERE id = 1",
);
return;
}
source = new SqliteDatabase(sourcePath, { readOnly: true });
log.info('preserving legacy media metadata: migration started');
const insertEntry =
destination.prepare(`INSERT INTO legacy_media_entries (type, primary_key, data, last_seen)
VALUES (?, ?, ?, ?) ON CONFLICT(type, primary_key) DO NOTHING`);
const insertKey =
destination.prepare(`INSERT INTO legacy_media_keys (type, key, primary_key)
VALUES (?, ?, ?) ON CONFLICT(type, key) DO NOTHING`);
const saveState = destination.prepare(
'UPDATE media_migration_state SET phase = ?, cursor = ? WHERE id = 1',
);
while (!cancelled() && state.phase !== 'complete') {
const table = state.phase === 'entries' ? 'media_entries' : 'media_keys';
const rows = (await source
.prepare(
`SELECT rowid AS migration_rowid, * FROM ${table} WHERE rowid > ? ORDER BY rowid LIMIT ?`,
)
.all(state.cursor, BATCH_SIZE)) as Array<{
migration_rowid: number;
type: string;
primary_key: string;
data: string;
last_seen: number;
key: string;
}>;
const nextPhase =
rows.length === 0 ? (state.phase === 'entries' ? 'keys' : 'complete') : state.phase;
const nextCursor = rows.at(-1)?.migration_rowid ?? 0;
await destination.exec('BEGIN IMMEDIATE');
try {
for (const row of rows) {
if (!['image', 'record', 'video'].includes(row.type)) {
throw new Error(
`unsupported legacy media type at row ${row.migration_rowid}`,
);
}
if (state.phase === 'entries') {
const info: unknown = JSON.parse(row.data);
if (!info || typeof info !== 'object' || Array.isArray(info)) {
throw new Error(
`invalid legacy media metadata at row ${row.migration_rowid}`,
);
}
await insertEntry.run(
row.type,
foldMediaKey(row.primary_key),
serializeMediaInfo(info),
row.last_seen,
);
} else {
await insertKey.run(
row.type,
foldMediaKey(row.key),
foldMediaKey(row.primary_key),
);
}
}
await saveState.run(nextPhase, nextCursor);
await destination.exec('COMMIT');
} catch (error) {
await rollbackMediaTransaction(destination, error);
}
state.phase = nextPhase;
state.cursor = nextCursor;
log.debug(
'preserving legacy media metadata: phase=%s cursor=%d',
state.phase,
state.cursor,
);
// Yield between committed batches so cancellation is handled by the worker.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
if (!cancelled()) {
log.info('preserving legacy media metadata: migration complete; source retained');
}
} finally {
await source?.close();
await destination.close();
}
}
+395
View File
@@ -0,0 +1,395 @@
import type { MessageElement } from '@snowluma/protocol/events';
import { createHash } from 'node:crypto';
import { SqliteDatabase, type SqliteStatement } from '@snowluma/sqlite';
import { openSqliteDb } from './sqlite-open';
export interface CachedImage {
file: string;
url: string;
fileSize: number;
fileName: string;
subType: number;
summary: string;
isGroup: boolean;
sessionId: number;
imageUrl: string;
md5Hex?: string;
sha1Hex?: string;
width?: number;
height?: number;
picFormat?: number;
}
export interface CachedRecord {
file: string;
fileId: string;
url: string;
fileSize: number;
fileName: string;
duration: number;
fileHash: string;
mediaNode?: MessageElement['mediaNode'];
isGroup: boolean;
sessionId: number;
/** Fingerprints used for md5/sha1 fast-upload on forward. */
md5Hex?: string;
sha1Hex?: string;
voiceFormat?: number;
}
export interface CachedVideo {
file: string;
fileId: string;
url: string;
fileSize: number;
fileName: string;
duration: number;
fileHash: string;
mediaNode?: MessageElement['mediaNode'];
isGroup: boolean;
sessionId: number;
/** Fingerprints used for md5/sha1 fast-upload on forward. */
md5Hex?: string;
sha1Hex?: string;
width?: number;
height?: number;
videoFormat?: number;
}
const TYPE_IMAGE = 'image';
const TYPE_RECORD = 'record';
const TYPE_VIDEO = 'video';
const DEFAULT_KEEP_ENTRIES = 4096;
export type MediaLookupSource = 'current' | 'legacy';
/** SQLite keys / aliases longer than this are folded to a short digest. */
export const MEDIA_KEY_MAX_CHARS = 2048;
/** Persisted JSON must stay well under a page-overflow storm. */
export const MEDIA_DATA_MAX_CHARS = 64 * 1024;
const INLINE_MEDIA_SOURCE = /^(base64:\/\/|data:)/i;
/** Durable media metadata. Production shares messages.db with MessageStore.
* Only the in-memory lookup cache is bounded; history dependencies never expire.
*/
export class MediaStore {
private readonly db: SqliteDatabase;
private readonly cache = new Map<string, string>();
private upsertEntry!: SqliteStatement;
private upsertKey!: SqliteStatement;
private findEntry!: SqliteStatement;
private findLegacyEntry!: SqliteStatement;
readonly initialization: Promise<void>;
constructor(
dbPath: string,
private readonly maxCachedEntries = DEFAULT_KEEP_ENTRIES,
) {
this.db = openSqliteDb(dbPath);
this.initialization = this.db.serial(async () => {
for (const prefix of ['', 'legacy_']) {
await this.db.exec(`
CREATE TABLE IF NOT EXISTS ${prefix}media_entries (
type TEXT NOT NULL, primary_key TEXT NOT NULL, data TEXT NOT NULL,
last_seen INTEGER NOT NULL, PRIMARY KEY (type, primary_key)
);
CREATE TABLE IF NOT EXISTS ${prefix}media_keys (
type TEXT NOT NULL, key TEXT NOT NULL, primary_key TEXT NOT NULL,
PRIMARY KEY (type, key)
);
`);
}
this.upsertEntry = this.db.prepare(`
INSERT INTO media_entries (type, primary_key, data, last_seen) VALUES (?, ?, ?, ?)
ON CONFLICT(type, primary_key) DO UPDATE SET data = excluded.data, last_seen = excluded.last_seen
`);
this.upsertKey = this.db.prepare(`
INSERT INTO media_keys (type, key, primary_key) VALUES (?, ?, ?)
ON CONFLICT(type, key) DO UPDATE SET primary_key = excluded.primary_key
`);
this.findEntry = this.db.prepare(`
SELECT data FROM media_entries WHERE type = ? AND primary_key = COALESCE(
(SELECT primary_key FROM media_keys WHERE type = ? AND key = ?), ?
)
`);
this.findLegacyEntry = this.db.prepare(`
SELECT data FROM legacy_media_entries WHERE type = ? AND primary_key = COALESCE(
(SELECT primary_key FROM legacy_media_keys WHERE type = ? AND key = ?), ?
)
`);
});
void this.initialization.catch(() => undefined);
}
async close(): Promise<void> {
await this.initialization.catch(() => undefined);
await this.db.close();
}
async rememberImage(info: CachedImage): Promise<void> {
info = structuredClone(info);
return this.db.serial(async () => {
await this.initialization;
await this.remember(TYPE_IMAGE, info, [info.file, info.fileName, info.url]);
});
}
async rememberRecord(info: CachedRecord): Promise<void> {
info = structuredClone(info);
return this.db.serial(async () => {
await this.initialization;
await this.remember(TYPE_RECORD, info, [
info.file,
info.fileName,
info.fileId,
info.url,
]);
});
}
async rememberVideo(info: CachedVideo): Promise<void> {
info = structuredClone(info);
return this.db.serial(async () => {
await this.initialization;
await this.remember(TYPE_VIDEO, info, [
info.file,
info.fileName,
info.fileId,
info.url,
]);
});
}
async findImage(
key: string,
source: MediaLookupSource = 'current',
): Promise<CachedImage | null> {
return this.db.serial(async () => {
await this.initialization;
return await this.find(TYPE_IMAGE, key, source);
});
}
async findRecord(
key: string,
source: MediaLookupSource = 'current',
): Promise<CachedRecord | null> {
return this.db.serial(async () => {
await this.initialization;
return await this.find(TYPE_RECORD, key, source);
});
}
async findVideo(
key: string,
source: MediaLookupSource = 'current',
): Promise<CachedVideo | null> {
return this.db.serial(async () => {
await this.initialization;
return await this.find(TYPE_VIDEO, key, source);
});
}
async updateImageUrl(key: string, url: string): Promise<void> {
return this.db.serial(async () => {
await this.initialization;
await this.updateUrl(TYPE_IMAGE, key, url);
});
}
async updateRecordUrl(key: string, url: string): Promise<void> {
return this.db.serial(async () => {
await this.initialization;
await this.updateUrl(TYPE_RECORD, key, url);
});
}
async updateVideoUrl(key: string, url: string): Promise<void> {
return this.db.serial(async () => {
await this.initialization;
await this.updateUrl(TYPE_VIDEO, key, url);
});
}
/** Counts durable records, not the bounded lookup cache. */
async size(): Promise<{ images: number; records: number; videos: number }> {
return this.db.serial(async () => {
await this.initialization;
const counts = (await this.db
.prepare(
`SELECT type, COUNT(*) AS n FROM (
SELECT type FROM media_entries UNION ALL SELECT type FROM legacy_media_entries
) GROUP BY type`,
)
.all()) as Array<{ type: string; n: number }>;
const count = (type: string) => counts.find((row) => row.type === type)?.n ?? 0;
return {
images: await count(TYPE_IMAGE),
records: await count(TYPE_RECORD),
videos: await count(TYPE_VIDEO),
};
});
}
private async remember(type: string, info: object, aliases: string[]): Promise<void> {
const primary = pickPrimary(aliases);
if (!primary) {
throw new Error(`cannot store ${type} metadata without an identifier`);
}
const data = serializeMediaInfo(info);
await this.db.exec('BEGIN IMMEDIATE');
try {
await this.upsertEntry.run(type, primary, data, Math.floor(Date.now() / 1000));
for (const key of new Set(aliases.filter(Boolean).map(foldMediaKey))) {
await this.upsertKey.run(type, key, primary);
}
await this.db.exec('COMMIT');
} catch (error) {
await rollbackMediaTransaction(this.db, error);
}
// Alias remapping can invalidate any cached lookup. JSON strings ensure
// callers cannot mutate the durable record through a returned object.
this.cache.clear();
}
private async find<T>(
type: string,
key: string,
source: MediaLookupSource = 'current',
): Promise<T | null> {
const lookup = foldMediaKey(key);
if (!lookup) {
return null;
}
const cacheKey = `${source}:${type}:${lookup}`;
let data = this.cache.get(cacheKey);
if (data === undefined) {
// Legacy history has no per-message snapshots. Its frozen migration
// index must never resolve through a later, mutable filename alias.
const current =
source === 'current'
? await this.findEntry.get(type, type, lookup, lookup)
: undefined;
const row = (current ??
(await this.findLegacyEntry.get(type, type, lookup, lookup))) as
{ data: string } | undefined;
if (!row) {
return null;
}
data = row.data;
}
// Parse before caching: malformed persisted metadata is an observable error.
const result: unknown = JSON.parse(data);
if (!result || typeof result !== 'object' || Array.isArray(result)) {
throw new Error(`invalid persisted ${type} metadata`);
}
this.cache.delete(cacheKey);
this.cache.set(cacheKey, data);
while (this.cache.size > Math.max(0, this.maxCachedEntries)) {
this.cache.delete(this.cache.keys().next().value!);
}
return result as T;
}
private async updateUrl(type: string, key: string, url: string): Promise<void> {
if (!url || mustFoldMediaKey(url)) {
return;
}
const info = await this.find<Record<string, unknown>>(type, key);
if (!info || info.url === url) {
return;
}
await this.remember(
type,
{ ...info, url },
[info.file, info.fileName, info.fileId, url].filter(
(value): value is string => typeof value === 'string',
),
);
}
}
export function serializeMediaInfo(info: object): string {
const data = JSON.stringify(persistableMediaInfo(info));
if (data.length > MEDIA_DATA_MAX_CHARS) {
throw new Error(`media metadata exceeds storage limit (${data.length} characters)`);
}
return data;
}
export async function rollbackMediaTransaction(db: SqliteDatabase, error: unknown): Promise<never> {
try {
await db.exec('ROLLBACK');
} catch (rollbackError) {
throw new AggregateError([error, rollbackError], 'media write and rollback failed', {
cause: error,
});
}
throw error;
}
function mustFoldMediaKey(value: string): boolean {
return value.length > MEDIA_KEY_MAX_CHARS || INLINE_MEDIA_SOURCE.test(value);
}
export function foldMediaKey(value: string): string {
if (!value) {
return '';
}
if (!mustFoldMediaKey(value)) {
return value;
}
return `inline:${createHash('sha256').update(value).digest('hex')}`;
}
function persistableMediaInfo(info: object): Record<string, unknown> {
const next: Record<string, unknown> = { ...info };
for (const field of ['file', 'fileId', 'fileName'] as const) {
const value = next[field];
if (typeof value === 'string') {
next[field] = foldMediaKey(value);
}
}
for (const field of ['url', 'imageUrl'] as const) {
const value = next[field];
if (typeof value === 'string' && mustFoldMediaKey(value)) {
next[field] = '';
}
}
return next;
}
function pickPrimary(candidates: (string | undefined)[]): string {
for (const c of candidates) {
if (c && c.length > 0) {
return foldMediaKey(c);
}
}
return '';
}
+82
View File
@@ -0,0 +1,82 @@
import type { BridgeInterface } from '@snowluma/core/bridge-interface';
import { createLogger } from '@snowluma/common/logger';
import type { MessageElement } from '@snowluma/protocol/events';
import type { RKeyCache } from './instance-rkey';
const log = createLogger('OneBot');
export function resolveRemoteMediaUrl(
bridge: BridgeInterface,
type: 'record' | 'video',
node: MessageElement['mediaNode'],
isGroup: boolean,
sessionId: number,
): Promise<string> {
if (!node) {
throw new Error(`${type} media metadata is missing`);
}
const api = bridge.apis.groupFile;
if (type === 'record') {
return isGroup ? api.getPttUrl(sessionId, node) : api.getPrivatePttUrl(node);
}
return isGroup ? api.getVideoUrl(sessionId, node) : api.getPrivateVideoUrl(node);
}
export class MediaUrlResolver {
constructor(
private readonly bridge: BridgeInterface,
private readonly rkeyCache: RKeyCache,
) {}
async resolve(element: MessageElement, isGroup: boolean, sessionId: number): Promise<string> {
if (!element.url) {
try {
await this.populateUrl(element, isGroup, sessionId);
} catch (err) {
// Preserve the media segment when its download URL is unavailable.
log.warn(
'media URL resolution failed type=%s is_group=%s: %s',
element.type,
isGroup,
err instanceof Error ? err.message : String(err),
);
}
}
return this.rkeyCache.resolveMediaUrl(this.bridge, element, isGroup);
}
private async populateUrl(
element: MessageElement,
isGroup: boolean,
sessionId: number,
): Promise<void> {
if (element.type === 'file' && element.fileId) {
if (isGroup) {
element.url = await this.bridge.apis.groupFile.getUrl(sessionId, element.fileId);
} else if (element.fileHash) {
element.url = await this.bridge.apis.groupFile.getPrivateUrl(
sessionId,
element.fileId,
element.fileHash,
);
} else {
element.url = '';
}
return;
}
if ((element.type === 'record' || element.type === 'video') && element.mediaNode) {
element.url = await resolveRemoteMediaUrl(
this.bridge,
element.type,
element.mediaNode,
isGroup,
sessionId,
);
}
}
}
@@ -0,0 +1,54 @@
import type { MessageIdResolver } from './event-converter';
import type { MessageStore } from './message-store';
import {
GROUP_MESSAGE_EVENT,
PRIVATE_MESSAGE_EVENT,
PRIVATE_NT_MESSAGE_EVENT,
PRIVATE_SENT_MESSAGE_EVENT,
hashMessageIdInt32,
} from './message-id';
export function createMessageIdResolver(
store: Pick<MessageStore, 'resolvePrivateReplyMessageId'>,
): MessageIdResolver {
return async (
isGroup,
sessionId,
sequence,
eventName,
timestamp,
replyElements,
excludedMessage,
) => {
const resolvedEventName =
eventName || (isGroup ? GROUP_MESSAGE_EVENT : PRIVATE_MESSAGE_EVENT);
if (
!isGroup &&
timestamp !== undefined &&
(resolvedEventName === PRIVATE_MESSAGE_EVENT ||
resolvedEventName === PRIVATE_SENT_MESSAGE_EVENT)
) {
const storedId = await store.resolvePrivateReplyMessageId(
sessionId,
sequence,
resolvedEventName === PRIVATE_SENT_MESSAGE_EVENT,
timestamp,
replyElements,
excludedMessage,
);
// Quote sequences are not canonical message-id inputs. A miss must
// remain unresolved so the live pipeline can fetch and verify history.
return storedId ?? 0;
}
return hashMessageIdInt32(
sequence,
sessionId,
resolvedEventName === PRIVATE_SENT_MESSAGE_EVENT
? PRIVATE_NT_MESSAGE_EVENT
: resolvedEventName,
);
};
}
+31
View File
@@ -0,0 +1,31 @@
import { createHash } from 'node:crypto';
export const GROUP_MESSAGE_EVENT = 'group_message';
export const PRIVATE_MESSAGE_EVENT = 'private_message';
/** Legacy/fallback namespace for private messages sent by the current account. */
export const PRIVATE_SENT_MESSAGE_EVENT = 'private_message_sent';
/** Namespace for private messages keyed by QQ's conversation-wide NT sequence. */
export const PRIVATE_NT_MESSAGE_EVENT = 'private_message_nt';
export function privateMessageEventName(sentBySelf: boolean, hasNtSequence: boolean): string {
if (hasNtSequence) {
return PRIVATE_NT_MESSAGE_EVENT;
}
return sentBySelf ? PRIVATE_SENT_MESSAGE_EVENT : PRIVATE_MESSAGE_EVENT;
}
export function hashMessageIdInt32(sequence: number, sessionId: number, eventName: string): number {
const seq = Number.isFinite(sequence) ? Math.trunc(sequence) : 0;
const session = Number.isFinite(sessionId) ? Math.trunc(sessionId) : 0;
const key = `${seq}:${session}:${eventName}`;
const digest = createHash('sha1').update(key).digest();
let id = digest.readInt32BE(0);
if (id === 0) {
id = 1;
}
return id;
}
+288
View File
@@ -0,0 +1,288 @@
import { createLogger } from '@snowluma/common/logger';
import type { FriendMessage, GroupMessage, MessageElementOf } from '@snowluma/protocol/events';
import type { OneBotInstanceContext } from './instance-context';
import { segmentsToRawMessage } from './helper/cq';
import {
attachMessageMedia,
imageMd5FromFile,
isStoredMediaSegment,
type MessageMedia,
type StoredMediaSegment,
} from './message-media';
import { PRIVATE_QUOTE_MAX_DRIFT_SECONDS } from './private-quote';
import { hasAuthoritativeSequence, type JsonObject, type MessageMeta } from './types';
const log = createLogger('OneBot');
type RepairContext = Pick<OneBotInstanceContext, 'selfId' | 'bridge' | 'messageStore'>;
type HistoryMessage = FriendMessage | GroupMessage;
function remoteUrl(value: unknown): value is string {
return typeof value === 'string' && /^https?:\/\//i.test(value);
}
function imageReference(element: MessageMedia | undefined): string {
return remoteUrl(element?.imageUrl)
? element.imageUrl
: remoteUrl(element?.url)
? element.url
: '';
}
/** Repair only missing image references. Full server events must not replace the
* original message body, sender classification, signed id or sequence metadata.
*/
export async function readMessageWithImageRepair(
ref: RepairContext,
messageId: number,
): Promise<JsonObject | null> {
const event = await ref.messageStore.findEvent(messageId);
if (!event || !Array.isArray(event.message)) {
return event;
}
const segments = event.message.filter(isStoredMediaSegment);
if (!segments.some((segment) => segment.type === 'image' && !remoteUrl(segment.data.url))) {
return event;
}
try {
const media = await ref.messageStore.findMedia(messageId);
if (
media &&
(media.length !== segments.length ||
media.some((element, index) => element.type !== segments[index]!.type))
) {
throw new Error('stored media association is inconsistent');
}
const missing = segments.map(
(segment, index) =>
segment.type === 'image' &&
!remoteUrl(segment.data.url) &&
!imageReference(media?.[index]),
);
if (!missing.some(Boolean)) {
return event;
}
const storedMeta = await ref.messageStore.findMeta(messageId);
// As in private history listing, old self-keyed outgoing rows require
// their explicit recipient. This changes lookup scope, never stored identity.
const meta =
storedMeta &&
!storedMeta.isGroup &&
storedMeta.targetId === ref.selfId &&
event.user_id === ref.selfId &&
typeof event.target_id === 'number' &&
Number.isSafeInteger(event.target_id) &&
event.target_id > 0
? { ...storedMeta, targetId: event.target_id }
: storedMeta;
if (
!meta ||
meta.targetId <= 0 ||
meta.timestamp <= 0 ||
!Number.isSafeInteger(event.user_id) ||
Number(event.user_id) <= 0 ||
(event.self_id !== undefined && event.self_id !== ref.selfId) ||
event.message_type !== (meta.isGroup ? 'group' : 'private') ||
(meta.isGroup ? event.group_id !== meta.targetId : event.sub_type !== 'friend') ||
(!meta.isGroup && event.target_id !== undefined && event.target_id !== meta.targetId)
) {
throw new Error('stored message identity is incomplete');
}
const candidates = (await fetchHistory(ref, meta)).filter((source) =>
matchesIdentity(source, event, meta, ref.selfId),
);
if (candidates.length !== 1) {
throw new Error(`expected one matching history message, got ${candidates.length}`);
}
const images = candidates[0]!.elements.filter(
(element): element is MessageElementOf<'image'> => element.type === 'image',
);
const storedImages = segments.filter((segment) => segment.type === 'image');
if (images.length !== storedImages.length) {
throw new Error('history image count differs from stored message');
}
const updated = structuredClone(event);
const updatedSegments = (updated.message as JsonObject[]).filter(isStoredMediaSegment);
// Never create empty placeholders for other media: they would prevent
// a later complete history response from filling their real metadata.
const attachSnapshots =
media !== null || segments.every((segment) => segment.type === 'image');
let imageIndex = 0;
for (const [index, segment] of updatedSegments.entries()) {
const old = media?.[index];
let element: MessageMedia = old ?? { type: segment.type };
if (segment.type === 'image') {
const image = images[imageIndex++]!;
verifyImage(old, segment, image);
if (missing[index]) {
const url = imageReference(image);
if (!url) {
throw new Error('history image has no download reference');
}
element = { ...image, imageUrl: url };
segment.data.url = url;
} else if (!old) {
element = { ...image, imageUrl: String(segment.data.url) };
}
}
if (attachSnapshots) {
attachMessageMedia(segment, element);
}
}
updated.raw_message = segmentsToRawMessage(updated.message as JsonObject[]);
if (!(await ref.messageStore.updateMedia(messageId, event, updated))) {
log.debug(
'[get_msg] image history repair skipped after concurrent update message_id=%d',
messageId,
);
return ref.messageStore.findEvent(messageId);
}
log.debug(
'[get_msg] image history repair saved message_id=%d images=%d',
messageId,
missing.filter(Boolean).length,
);
return updated;
} catch (error) {
// get_msg retains its documented partial-media behavior; do not invent
// a URL or overwrite a record when history cannot prove the association.
log.warn(
'[get_msg] image history repair failed message_id=%d: %s',
messageId,
error instanceof Error ? error.message : String(error),
);
return ref.messageStore.findEvent(messageId);
}
}
async function fetchHistory(ref: RepairContext, meta: MessageMeta): Promise<HistoryMessage[]> {
if (meta.isGroup) {
if (!hasAuthoritativeSequence(meta)) {
throw new Error('group message has no verified sequence');
}
const message = await ref.bridge.apis.message.getGroupMessageBySeq(
meta.targetId,
meta.sequence,
ref.selfId,
);
return message ? [message] : [];
}
const uid = await ref.bridge.resolveUserUid(meta.targetId);
if (!uid) {
throw new Error('private history peer could not be resolved');
}
if (meta.sequenceAuthoritative && Number.isSafeInteger(meta.sequence) && meta.sequence > 0) {
const message = await ref.bridge.apis.message.getC2cMessageBySeq(
uid,
meta.sequence,
ref.selfId,
);
return message ? [message] : [];
}
if (meta.clientSequence <= 0) {
throw new Error('private message has no verified client sequence');
}
return ref.bridge.apis.message.getC2cLatestHistory(
uid,
20,
ref.selfId,
Math.min(0xffff_ffff, meta.timestamp + 1),
);
}
function matchesIdentity(
source: HistoryMessage,
event: JsonObject,
meta: MessageMeta,
selfId: number,
): boolean {
if (
source.selfUin !== selfId ||
source.senderUin !== event.user_id ||
!Number.isSafeInteger(source.time) ||
source.time <= 0 ||
Math.abs(source.time - meta.timestamp) > PRIVATE_QUOTE_MAX_DRIFT_SECONDS
) {
return false;
}
if (source.kind === 'group_message') {
return meta.isGroup && source.groupId === meta.targetId && source.msgSeq === meta.sequence;
}
// These history requests explicitly bind the peer UID. Older outgoing
// responses can omit toUin; preserve that binding, never substitute selfId.
const peer = source.peerUin ?? (source.senderUin !== selfId ? source.senderUin : meta.targetId);
if (
meta.isGroup ||
peer !== meta.targetId ||
(meta.privateDirection &&
(source.senderUin === selfId) !== (meta.privateDirection === 'outgoing'))
) {
return false;
}
return meta.sequenceAuthoritative && Number.isSafeInteger(meta.sequence) && meta.sequence > 0
? source.sequenceAuthoritative !== false && source.ntMsgSeq === meta.sequence
: (source.clientSeq ?? source.msgSeq) === meta.clientSequence;
}
function verifyImage(
old: MessageMedia | undefined,
segment: StoredMediaSegment,
image: MessageElementOf<'image'>,
): void {
// Older rows lack per-message snapshots but expose the content hash as the
// image filename. Arbitrary aliases cannot establish this association.
const legacyMd5 = imageMd5FromFile(segment.data.file ?? segment.data.file_id);
const fingerprints = { md5Hex: old?.md5Hex || legacyMd5, sha1Hex: old?.sha1Hex };
let matched = false;
for (const field of ['md5Hex', 'sha1Hex'] as const) {
const before = fingerprints[field];
const after = image[field];
if (!before || !after) {
continue;
}
if (before.toLowerCase() !== after.toLowerCase()) {
throw new Error('history image fingerprint conflicts with stored message');
}
matched = true;
}
if (!matched) {
throw new Error('history image fingerprint cannot be verified');
}
}
+226
View File
@@ -0,0 +1,226 @@
import type { MessageElement } from '@snowluma/protocol/events';
import { serializeMediaInfo } from './media-store';
import type { JsonObject, JsonValue } from './types';
export type MessageMedia = Extract<MessageElement, { type: 'image' | 'record' | 'video' }>;
export type StoredMediaSegment = JsonObject & { type: MessageMedia['type']; data: JsonObject };
export function isStoredMediaSegment(segment: JsonValue): segment is StoredMediaSegment {
return (
segment !== null &&
typeof segment === 'object' &&
!Array.isArray(segment) &&
(segment.type === 'image' || segment.type === 'record' || segment.type === 'video') &&
segment.data !== null &&
typeof segment.data === 'object' &&
!Array.isArray(segment.data)
);
}
/** Legacy image segments use a content hash filename; arbitrary aliases prove nothing. */
export function imageMd5FromFile(file: unknown): string | undefined {
return typeof file === 'string'
? /^([a-f0-9]{32})\.(?:png|jpe?g|webp|gif|bmp)$/i.exec(file)?.[1]?.toLowerCase()
: undefined;
}
const mediaSnapshot = Symbol('message media snapshot');
type MediaSegment = JsonObject & { [mediaSnapshot]?: MessageMedia };
/** Snapshot queued writes, including metadata that is deliberately absent from JSON. */
export function snapshotMessageEvent(event: JsonObject): JsonObject {
const snapshot = structuredClone(event);
if (Array.isArray(event.message) && Array.isArray(snapshot.message)) {
for (const [index, segment] of event.message.entries()) {
if (!segment || typeof segment !== 'object' || Array.isArray(segment)) {
continue;
}
const media = (segment as MediaSegment)[mediaSnapshot];
const copied = snapshot.message[index];
if (media && copied && typeof copied === 'object') {
Object.defineProperty(copied, mediaSnapshot, { value: structuredClone(media) });
}
}
}
return snapshot;
}
export function isMessageMedia(element: MessageElement): element is MessageMedia {
return element.type === 'image' || element.type === 'record' || element.type === 'video';
}
/** Keep persistence-only metadata beside the converted segment, never on the
* public JSON wire. Capture after successful conversion so skipped elements
* cannot shift the association between a message and its media snapshots.
*/
export function attachMessageMedia(segment: JsonObject, element: MessageElement): void {
if (!isMessageMedia(element)) {
return;
}
const {
type,
fileId,
fileName,
fileSize,
url,
imageUrl,
md5Hex,
sha1Hex,
subType,
summary,
width,
height,
picFormat,
duration,
voiceFormat,
videoFormat,
fileHash,
mediaNode,
flash,
} = element;
const snapshot = JSON.parse(
serializeMediaInfo({
type,
fileId,
fileName,
fileSize,
url,
imageUrl,
md5Hex,
sha1Hex,
subType,
summary,
width,
height,
picFormat,
duration,
voiceFormat,
videoFormat,
fileHash,
mediaNode,
flash,
}),
) as MessageMedia;
Object.defineProperty(segment, mediaSnapshot, { value: snapshot });
}
export function serializeMessageMedia(event: JsonObject): string | null {
if (!Array.isArray(event.message)) {
return null;
}
const snapshots = event.message.flatMap((segment) => {
if (!segment || typeof segment !== 'object' || Array.isArray(segment)) {
return [];
}
const snapshot = (segment as MediaSegment)[mediaSnapshot];
return snapshot ? [snapshot] : [];
});
return snapshots.length > 0 ? JSON.stringify(snapshots) : null;
}
export function parseMessageMedia(data: string): MessageMedia[] {
const parsed: unknown = JSON.parse(data);
if (
!Array.isArray(parsed) ||
parsed.some(
(value) =>
!value ||
typeof value !== 'object' ||
!['image', 'record', 'video'].includes(value.type),
)
) {
throw new Error('invalid persisted message media metadata');
}
return parsed as MessageMedia[];
}
/** A repeated server representation may omit fields already learned from an
* upload or earlier receive. Preserve those fields only for the same resource;
* conflicting fingerprints or uncertain associations must not rewrite history.
*/
export function mergeMessageMedia(previous: string | null, next: string | null): string | null {
if (!previous) {
return next;
}
if (previous === next) {
return previous;
}
if (!next) {
throw new Error('message update is missing previously stored media metadata');
}
const oldMedia = parseMessageMedia(previous);
const newMedia = parseMessageMedia(next);
if (oldMedia.length !== newMedia.length) {
throw new Error('message media association changed');
}
const merged = newMedia.map((media, index) => {
const old = oldMedia[index]!;
if (old.type !== media.type) {
throw new Error('message media association changed');
}
let matched = false;
for (const field of ['md5Hex', 'sha1Hex'] as const) {
if (!old[field] || !media[field]) {
continue;
}
if (old[field].toLowerCase() !== media[field].toLowerCase()) {
throw new Error('message update has conflicting media fingerprints');
}
matched = true;
}
// Images can expose a filename as fileId, so only their fingerprints can
// prove an association. Voice/video fileId is a source resource UUID.
matched ||= media.type !== 'image' && !!old.fileId && old.fileId === media.fileId;
if (!matched && JSON.stringify(old) !== JSON.stringify(media)) {
throw new Error('message update has an unverified media association');
}
return mergeKnownMediaFields(old, media);
});
return JSON.stringify(merged);
}
function mergeKnownMediaFields(old: object, next: object): Record<string, unknown> {
const merged: Record<string, unknown> = { ...old };
for (const [key, value] of Object.entries(next)) {
// Zero dimensions, sizes and durations are incomplete observations, not
// evidence that known properties of the same resource have changed.
if (value === undefined || value === null || value === '' || value === 0) {
continue;
}
const previous = merged[key];
merged[key] =
typeof value === 'object' &&
!Array.isArray(value) &&
previous !== null &&
typeof previous === 'object' &&
!Array.isArray(previous)
? mergeKnownMediaFields(previous, value)
: value;
}
return merged;
}
+764
View File
@@ -0,0 +1,764 @@
import { createLogger } from '@snowluma/common/logger';
import type { MessageElement } from '@snowluma/protocol/events';
import {
assertVideoSendPolicy,
assertWindowShakeSendPolicy,
assertValidMessageElement,
ELEMENT_MANIFEST,
MessageElementValidationError,
type OutboundMessageScene,
} from '@snowluma/protocol/element-manifest';
import { parseFromCQString } from './helper/cq';
import { getElementCodec, intOr } from './event-converter/element-codecs';
import type { JsonValue } from './types';
import { assertReplyPreviewType, elementsToReplyPreview } from './reply-preview';
const log = createLogger('MsgParser');
export { MessageElementValidationError } from '@snowluma/protocol/element-manifest';
interface ForwardReplyContext {
senderUin: number;
time?: number;
}
export interface ParseMessageOptions {
/** Custom quote defaults, supplied only while parsing a merged-forward node. */
forwardReply?: ForwardReplyContext;
/** Internal: quote content cannot nest replies or invoke action-like codecs. */
replyPreview?: boolean;
resolveReplySequence?: (replyMessageId: number) => number | null | Promise<number | null>;
resolveReplyMeta?: (replyMessageId: number) =>
| {
senderUin: number;
time: number;
random: number;
elements?: MessageElement[];
sequenceAuthoritative?: boolean;
}
| null
| Promise<{
senderUin: number;
time: number;
random: number;
elements?: MessageElement[];
sequenceAuthoritative?: boolean;
} | null>;
resolveMentionUid?: (targetUin: number) => string | null | Promise<string | null>;
resolveContactArk?: (
contactType: string,
contactId: number,
) => string | null | Promise<string | null>;
musicSignUrl?: string;
}
// --- CQ Code parsing ---
export const CQ_REGEX = /\[CQ:([A-Za-z][A-Za-z0-9_]*)(?:,([^\]]*))?\]/g;
export function parseCQParams(raw: string): Record<string, string> {
const params: Record<string, string> = {};
if (!raw) {
return params;
}
for (const pair of raw.split(',')) {
const eq = pair.indexOf('=');
if (eq > 0) {
params[pair.substring(0, eq)] = pair
.substring(eq + 1)
.replaceAll('&#91;', '[')
.replaceAll('&#93;', ']')
.replaceAll('&#44;', ',')
.replaceAll('&amp;', '&');
}
}
return params;
}
// --- JSON segment parsing ---
interface MessageSegment {
type: string;
data?: unknown;
[key: string]: unknown;
}
function isSegmentArray(val: unknown): val is MessageSegment[] {
return (
Array.isArray(val) &&
val.every(
(item) =>
typeof item === 'object' &&
item !== null &&
!Array.isArray(item) &&
typeof (item as { type?: unknown }).type === 'string' &&
(item as { type: string }).type.trim().length > 0,
)
);
}
function normalizeSegmentType(type: string): string {
return type.toLowerCase();
}
interface OutboundInputSegmentSummary {
rawTypes: string[];
effectiveVideoTypes: string[];
}
function isExplicitEmptyTextPlaceholder(segment: MessageSegment): boolean {
return normalizeSegmentType(segment.type) === 'text' && segmentPayload(segment).text === '';
}
/** Node segments from an exclusive forward payload, or null if it is not one. */
export function exclusiveForwardNodeList(
message: JsonValue,
autoEscape: boolean,
): JsonValue[] | null {
if (autoEscape && typeof message === 'string') {
return null;
}
if (typeof message === 'object' && message !== null && !Array.isArray(message)) {
const type = (message as { type?: unknown }).type;
return typeof type === 'string' && normalizeSegmentType(type) === 'node' ? [message] : null;
}
if (!Array.isArray(message) || message.length === 0) {
return null;
}
const nodes: JsonValue[] = [];
for (const item of message) {
if (typeof item !== 'object' || item === null || Array.isArray(item)) {
return null;
}
const type = (item as { type?: unknown }).type;
if (typeof type !== 'string' || type.trim().length === 0) {
return null;
}
const segment = item as MessageSegment;
if (isExplicitEmptyTextPlaceholder(segment)) {
continue;
}
if (normalizeSegmentType(type) !== 'node') {
return null;
}
nodes.push(item);
}
return nodes.length > 0 ? nodes : null;
}
function outboundInputSegmentSummary(
message: JsonValue,
autoEscape: boolean,
): OutboundInputSegmentSummary {
if (typeof message === 'string') {
if (autoEscape) {
return { rawTypes: ['text'], effectiveVideoTypes: ['text'] };
}
const types: string[] = [];
let lastIndex = 0;
for (const match of message.matchAll(CQ_REGEX)) {
if (match.index! > lastIndex) {
types.push('text');
}
types.push(normalizeSegmentType(match[1] ?? ''));
lastIndex = match.index! + match[0].length;
}
if (lastIndex < message.length) {
types.push('text');
}
return { rawTypes: types, effectiveVideoTypes: types };
}
if (Array.isArray(message)) {
const rawTypes: string[] = [];
const effectiveVideoTypes: string[] = [];
for (const item of message) {
if (typeof item !== 'object' || item === null || Array.isArray(item)) {
continue;
}
const type = (item as { type?: unknown }).type;
if (typeof type !== 'string') {
continue;
}
const normalizedType = normalizeSegmentType(type);
rawTypes.push(normalizedType);
if (!isExplicitEmptyTextPlaceholder(item as MessageSegment)) {
effectiveVideoTypes.push(normalizedType);
}
}
return { rawTypes, effectiveVideoTypes };
}
if (typeof message === 'object' && message !== null) {
const type = (message as { type?: unknown }).type;
const types = typeof type === 'string' ? [normalizeSegmentType(type)] : [];
return { rawTypes: types, effectiveVideoTypes: types };
}
return { rawTypes: [], effectiveVideoTypes: [] };
}
/**
* Reject unsupported message combinations before parsers can resolve
* identities, fetch cards, sign music, or perform any other externally
* visible work.
*/
export function assertOutboundMessageInput(
message: JsonValue,
autoEscape: boolean,
scene: OutboundMessageScene,
): void {
const { rawTypes, effectiveVideoTypes } = outboundInputSegmentSummary(message, autoEscape);
assertVideoSendPolicy(
effectiveVideoTypes.filter((type) => type === 'video').length,
effectiveVideoTypes.length,
);
const shakeCount = rawTypes.filter((type) => type === 'poke' || type === 'shake').length;
assertWindowShakeSendPolicy(shakeCount, rawTypes.length, scene);
}
function invalidMessageShape(message: string): MessageElementValidationError {
return new MessageElementValidationError('MISSING_FIELD', message, undefined, 'message');
}
function validatedOutboundElement(element: MessageElement): MessageElement {
// P validates the OneBot→MessageElement conversion; W ensures the resulting
// element can actually enter QQ's message-element send pipeline. Keeping
// both checks here makes parseMessage all-or-nothing before any send starts.
assertValidMessageElement(element, 'P');
assertValidMessageElement(element, 'W');
return element;
}
function assertScalarSegmentData(type: string, data: Record<string, unknown>): void {
for (const [field, value] of Object.entries(data)) {
const isJsonObjectField =
type === 'json' &&
(field === 'data' || field === 'config') &&
typeof value === 'object' &&
value !== null &&
!Array.isArray(value);
if (
value === undefined ||
value === null ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
isJsonObjectField
) {
continue;
}
throw new MessageElementValidationError(
'INVALID_FIELD',
`message segment "${type}" field "${field}" must be a scalar value`,
type,
field,
);
}
}
function requireNonEmptyStringField(
type: string,
data: Record<string, unknown>,
field: string,
): string {
const value = data[field];
if (typeof value !== 'string' || !value.trim()) {
throw new MessageElementValidationError(
'INVALID_FIELD',
`message segment "${type}" field "${field}" must be a non-empty string`,
type,
field,
);
}
return value;
}
function requireCoordinate(data: Record<string, unknown>, field: 'lat' | 'lon'): string {
const raw = data[field];
const text = typeof raw === 'number' ? String(raw) : typeof raw === 'string' ? raw : '';
const value = Number(text);
const limit = field === 'lat' ? 90 : 180;
if (
!text.trim() ||
text !== text.trim() ||
!Number.isFinite(value) ||
value < -limit ||
value > limit
) {
throw new MessageElementValidationError(
'INVALID_FIELD',
`message segment "location" field "${field}" must be a finite coordinate between ${String(-limit)} and ${String(limit)}`,
'location',
field,
);
}
return text;
}
function replyInteger(
data: Record<string, unknown>,
field: string,
defaultValue: number,
max: number,
min = 1,
): number {
const raw = data[field];
const value = raw === undefined ? defaultValue : intOr(raw);
if (raw === null || (typeof raw === 'string' && !raw.trim()) || value < min || value > max) {
throw new MessageElementValidationError(
'INVALID_FIELD',
`reply.${field} must be an integer between ${min} and ${max}`,
'reply',
field,
);
}
return value;
}
async function parseForwardReply(
data: Record<string, unknown>,
context: ForwardReplyContext,
): Promise<MessageElement> {
const { message, ...metadata } = data;
assertScalarSegmentData('reply', metadata);
const replySenderUin = replyInteger(data, 'qq', context.senderUin, Number.MAX_SAFE_INTEGER);
const replyTime = replyInteger(
data,
'time',
context.time ?? Math.floor(Date.now() / 1000),
0xffffffff,
0,
);
// A synthetic quote has no live message to navigate to; QQ still needs a
// positive origSeqs entry to render its inline preview.
const replySeq = replyInteger(data, 'seq', 1, 0xffffffff);
const elements = await parseMessage(message, false, { replyPreview: true });
return validatedOutboundElement({
type: 'reply',
replySeq,
replySenderUin,
replyTime,
replyElements: elementsToReplyPreview(elements),
});
}
export async function segmentToElement(
type: string,
data: Record<string, unknown>,
options?: ParseMessageOptions,
): Promise<MessageElement | null> {
const normalizedType = normalizeSegmentType(type);
if (options?.replyPreview) {
assertReplyPreviewType(normalizedType);
}
if (normalizedType === 'reply' && Object.hasOwn(data, 'message')) {
if (!options?.forwardReply) {
throw new MessageElementValidationError(
'UNSENDABLE_TYPE',
'reply.message is only supported inside a forward node',
'reply',
'message',
);
}
return parseForwardReply(data, options.forwardReply);
}
// Ordinary OneBot segment fields are scalar. JSON deliberately accepts its
// object-valued data/config compatibility fields; every other object/array
// is rejected before a codec can coerce it and alter caller intent. Forward
// nodes own nested content but are rejected by their dedicated normal-send
// branch below; anonymous is rejected regardless of payload.
if (normalizedType !== 'node' && normalizedType !== 'anonymous') {
assertScalarSegmentData(normalizedType, data);
}
// 纯 OneBot 输入词:可执行的塌缩成 json/face/poke。整条消息都是 node 时由
// send 路径交给 parseForwardNodes;混进普通消息的 node、以及 anonymous,
// 在这里明确拒绝。真实元素(收发同名)走下方的 ELEMENT_CODECS。
switch (normalizedType) {
case 'node': {
// Exclusive node arrays never reach this function. A node mixed into a
// normal message is not sendable.
throw new MessageElementValidationError(
'UNSENDABLE_TYPE',
'message segment "node" is only valid inside a forward node list',
normalizedType,
);
}
case 'share': {
// Link share — map to json card message
const url = requireNonEmptyStringField(normalizedType, data, 'url');
const title = requireNonEmptyStringField(normalizedType, data, 'title');
const content = String(data.content ?? '');
const image = String(data.image ?? '');
const jsonData = JSON.stringify({
app: 'com.tencent.structmsg',
view: 'news',
prompt: title,
meta: { news: { title, desc: content, jumpUrl: url, preview: image } },
});
return validatedOutboundElement({ type: 'json', text: jsonData });
}
case 'music': {
// Music share — uses external signing service (NapCat-compatible)
const musicType = requireNonEmptyStringField(normalizedType, data, 'type');
// Non-custom platform names are deliberately open: musicSignUrl is
// configurable and private signers may support platforms beyond the
// built-in NapCat set. NapCat also treats an id-less platform-labelled
// segment as custom music data, so presence of id selects platform mode.
const hasMusicId =
data.id !== undefined && data.id !== null && String(data.id).trim() !== '';
const hasCustomMusicData = ['url', 'audio', 'title', 'image', 'content'].some((field) =>
Object.prototype.hasOwnProperty.call(data, field),
);
const usesCustomMusicData =
musicType === 'custom' || (!hasMusicId && hasCustomMusicData);
if (usesCustomMusicData) {
requireNonEmptyStringField(normalizedType, data, 'url');
requireNonEmptyStringField(normalizedType, data, 'audio');
requireNonEmptyStringField(normalizedType, data, 'title');
} else if (!hasMusicId) {
throw new MessageElementValidationError(
'MISSING_FIELD',
'message segment "music" requires field "id" for a platform card',
normalizedType,
'id',
);
}
const signUrl = options?.musicSignUrl || 'https://ss.xingzhige.com/music_card/card';
try {
let postData: Record<string, unknown>;
if (usesCustomMusicData) {
postData = {
type: musicType,
id: undefined,
url: String(data.url ?? ''),
audio: String(data.audio ?? ''),
title: String(data.title ?? ''),
image: String(data.image ?? ''),
singer: String(data.content ?? ''),
};
} else {
postData = { type: musicType, id: String(data.id ?? '') };
}
const resp = await fetch(signUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(postData),
});
if (!resp.ok) {
throw new Error(`music sign HTTP ${resp.status}`);
}
const musicJson = await resp.text();
return validatedOutboundElement({ type: 'json', text: musicJson });
} catch (e) {
log.warn(
'music sign failed: %s, falling back to local card',
e instanceof Error ? e.message : String(e),
);
// Fallback: build a basic card locally
const title = String(data.title ?? 'Music');
const jsonData = JSON.stringify({
app: 'com.tencent.structmsg',
view: 'music',
prompt: `[音乐]${title}`,
meta: {
music: {
title,
desc: String(data.content ?? ''),
jumpUrl: String(data.url ?? ''),
musicUrl: String(data.audio ?? ''),
preview: String(data.image ?? ''),
},
},
});
return validatedOutboundElement({ type: 'json', text: jsonData });
}
}
case 'location': {
// Location — map to json card
const lat = requireCoordinate(data, 'lat');
const lon = requireCoordinate(data, 'lon');
const title = String(data.title ?? '位置');
const content = String(data.content ?? `${lat},${lon}`);
const jsonData = JSON.stringify({
app: 'com.tencent.map',
view: 'LocationShare',
prompt: `[位置]${title}`,
meta: { Location: { lat, lng: lon, title, address: content } },
});
return validatedOutboundElement({ type: 'json', text: jsonData });
}
case 'contact': {
// Contact card — map to json card
const contactType = requireNonEmptyStringField(normalizedType, data, 'type');
if (contactType !== 'qq' && contactType !== 'group') {
throw new MessageElementValidationError(
'INVALID_FIELD',
'message segment "contact" field "type" must be "qq" or "group"',
normalizedType,
'type',
);
}
const contactId = String(data.id ?? '').trim();
const numericId = intOr(contactId, 0);
if (numericId <= 0) {
throw new MessageElementValidationError(
'INVALID_FIELD',
'message segment "contact" field "id" must be a positive integer',
normalizedType,
'id',
);
}
const normalizedContactType = contactType.trim().toLowerCase();
if (
numericId > 0 &&
options?.resolveContactArk &&
(normalizedContactType === 'qq' || normalizedContactType === 'group')
) {
const ark = await options.resolveContactArk(contactType, numericId);
if (!ark) {
throw new Error(`contact ark unavailable for ${contactType}:${numericId}`);
}
return validatedOutboundElement({ type: 'json', text: ark });
}
const jsonData = JSON.stringify({
app: 'com.tencent.contact.lua',
view: 'contact',
prompt: `[推荐${contactType === 'group' ? '群' : '好友'}]`,
meta: { contact: { type: contactType, id: contactId } },
});
return validatedOutboundElement({ type: 'json', text: jsonData });
}
case 'rps': {
// Rock-paper-scissors — map to dice-like face
return validatedOutboundElement({ type: 'face', faceId: 359 });
}
case 'dice': {
// Dice — map to dice face
return validatedOutboundElement({ type: 'face', faceId: 358 });
}
case 'shake': {
// Legacy OneBot window-shake sugar. The destination guard later limits
// the normalized poke element to ordinary friend private messages.
return validatedOutboundElement({ type: 'poke', subType: 1 });
}
case 'anonymous': {
throw new MessageElementValidationError(
'UNSENDABLE_TYPE',
'message segment "anonymous" has no executable send semantics',
normalizedType,
);
}
}
// 真实元素(P 发·解,段 type 与 element.type 同名):查 codec 表。
// 见 event-converter/element-codecs.ts。
const codec = getElementCodec(normalizedType);
if (codec?.fromSegment) {
const element = await codec.fromSegment(data, options);
if (!element) {
// A reply decorates another sendable segment. NapCat-compatible clients
// treat an unusable reply target as best-effort and still deliver the
// remaining content; a reply-only message is rejected by parseMessage's
// final non-empty check below.
if (normalizedType === 'reply') {
return null;
}
throw new MessageElementValidationError(
'MISSING_FIELD',
`message segment "${normalizedType}" is missing required or usable fields`,
normalizedType,
);
}
return validatedOutboundElement(element);
}
if (Object.hasOwn(ELEMENT_MANIFEST, normalizedType)) {
// Known receive-only/by-design-no type (currently flash_file). Ask the
// executable manifest for the stable error + dedicated-Action hint.
assertValidMessageElement({ type: normalizedType }, 'P');
}
throw new MessageElementValidationError(
'UNKNOWN_TYPE',
`unknown message segment type: ${type}`,
normalizedType,
);
}
function segmentPayload(seg: MessageSegment): Record<string, unknown> {
const topLevel = { ...seg } as Record<string, unknown>;
delete topLevel.type;
delete topLevel.data;
if (normalizeSegmentType(seg.type) === 'json' && typeof seg.data === 'string') {
return { ...topLevel, data: seg.data };
}
const nested =
seg.data && typeof seg.data === 'object' && !Array.isArray(seg.data) ? seg.data : {};
return { ...topLevel, ...nested };
}
// --- Public API ---
export async function parseMessage(
message: unknown,
autoEscape: boolean,
options?: ParseMessageOptions,
): Promise<MessageElement[]> {
if (typeof message === 'string') {
if (message.length === 0) {
throw invalidMessageShape('message must not be empty');
}
if (autoEscape) {
return [validatedOutboundElement({ type: 'text', text: message })];
}
const elements = await parseFromCQString(message, options);
if (elements.length === 0) {
throw invalidMessageShape('message must contain at least one sendable segment');
}
return elements;
}
if (Array.isArray(message)) {
if (!isSegmentArray(message)) {
throw new MessageElementValidationError(
'INVALID_FIELD',
'message segment array entries must be objects with a non-empty string type',
undefined,
'message',
);
}
if (message.length === 0) {
throw invalidMessageShape('message segment array must not be empty');
}
const elements: MessageElement[] = [];
for (const seg of message) {
const data = segmentPayload(seg);
// Some OneBot clients insert empty text between media as a separator.
// Only the exact empty string is a no-op; malformed and whitespace text
// must still pass through strict element validation.
if (isExplicitEmptyTextPlaceholder(seg)) {
log.debug('ignored explicit empty text placeholder in message segment array');
continue;
}
const elem = await segmentToElement(seg.type, data, options);
if (elem) {
elements.push(elem);
}
}
if (elements.length === 0) {
throw invalidMessageShape('message must contain at least one sendable segment');
}
return elements;
}
// Single segment object
if (typeof message === 'object' && message !== null && !Array.isArray(message)) {
const seg = message as MessageSegment;
if (typeof seg.type === 'string' && seg.type.trim()) {
const data = segmentPayload(seg);
const elem = await segmentToElement(seg.type, data, options);
if (elem) {
return [elem];
}
throw invalidMessageShape('message must contain at least one sendable segment');
}
throw new MessageElementValidationError(
'MISSING_FIELD',
'single message segment requires a non-empty string type',
undefined,
'type',
);
}
throw new MessageElementValidationError(
'INVALID_FIELD',
'message must be a string, a segment object, or a segment array',
undefined,
'message',
);
}
@@ -0,0 +1,138 @@
import { createRequire } from 'node:module';
import path from 'node:path';
import { Worker, type WorkerOptions } from 'node:worker_threads';
import { createLogger } from '@snowluma/common/logger';
import type { DatabaseMigrationCallbacks, DatabaseMigrationTask } from './manager';
import type { MessageStoreMigrationStatus } from './message-store-migration';
import {
MESSAGE_STORE_WORKER_KIND,
type MessageStoreMigrationWorkerData,
type MessageStoreMigrationWorkerMessage,
} from './message-store-migration-worker';
export interface MessageStoreMigrationWorkerHandle {
on(event: 'message', listener: (message: MessageStoreMigrationWorkerMessage) => void): this;
once(event: 'error', listener: (error: Error) => void): this;
once(event: 'exit', listener: (code: number) => void): this;
postMessage(message: 'start' | 'cancel'): void;
unref(): void;
}
type CreateMigrationWorker = (
filename: string,
options: WorkerOptions,
) => MessageStoreMigrationWorkerHandle;
export function createMessageStoreMigrationTask(
uin: string,
createWorker: CreateMigrationWorker = (filename, options) => new Worker(filename, options),
): DatabaseMigrationTask {
const log = createLogger('OneBot.MessageMigration').child({ uin: Number(uin) });
let worker: MessageStoreMigrationWorkerHandle | null = null;
let cancelled = false;
let finished = false;
let failureReported = false;
return {
start(callbacks: DatabaseMigrationCallbacks): void {
const reportFailure = (error: Error): void => {
if (cancelled || failureReported) {
return;
}
failureReported = true;
callbacks.onFailed(error);
};
const data: MessageStoreMigrationWorkerData = {
kind: MESSAGE_STORE_WORKER_KIND,
dbPath: path.join('data', uin, 'messages.db'),
};
const entryPath = process.argv[1];
const sourceEntry = /\.(?:cts|mts|ts|tsx)$/.test(entryPath);
worker = createWorker(sourceEntry ? sourceWorkerBootstrap(entryPath) : entryPath, {
workerData: data,
eval: sourceEntry,
execArgv: sourceEntry ? [] : process.execArgv,
});
worker.on('message', (message: MessageStoreMigrationWorkerMessage) => {
if (cancelled) {
return;
}
if (message.kind === 'ready') {
callbacks.onReady();
} else if (message.kind === 'preparation') {
const progress = message.progress;
if (progress.stage === 'started') {
log.info('preparing message history migration');
} else if (progress.stage === 'indexed') {
log.info(
'message history migration preparation finished in %dms',
Math.round(progress.elapsedMs),
);
} else {
log.info(
'message history migration: pending=%d checked in %dms',
progress.total,
Math.round(progress.elapsedMs),
);
}
} else if (message.kind === 'progress') {
callbacks.onProgress(message.status, message.elapsedMs);
if (message.status.phase === 'complete') {
finished = true;
}
} else {
reportFailure(new Error(message.message));
}
});
worker.once('error', reportFailure);
worker.once('exit', (code) => {
if (!cancelled && !finished) {
reportFailure(
new Error(
`database migration worker exited before completion with code ${code}`,
),
);
}
});
},
beginMigration(): void {
if (!cancelled) {
worker?.postMessage('start');
}
},
cancel(): void {
cancelled = true;
worker?.postMessage('cancel');
worker?.unref();
},
};
}
function sourceWorkerBootstrap(entryPath: string): string {
const require = createRequire(path.resolve(entryPath));
return [
`const { register } = require(${JSON.stringify(require.resolve('tsx/esm/api'))});`,
'register();',
`import(${JSON.stringify(path.resolve(entryPath))});`,
].join('\n');
}
export function estimateRemainingSeconds(
status: MessageStoreMigrationStatus,
recentRowsPerSecond: number | null,
): number | null {
if (status.phase === 'complete') {
return 0;
}
if (!recentRowsPerSecond || recentRowsPerSecond <= 0) {
return null;
}
return Math.max(0, Math.ceil((status.total - status.processed) / recentRowsPerSecond));
}
@@ -0,0 +1,119 @@
import { parentPort, workerData } from 'node:worker_threads';
import { migrateLegacyMedia } from './media-store-migration';
import {
MessageStoreMigrator,
prepareMessageStoreDatabase,
type MessageStoreMigrationPreparation,
type MessageStoreMigrationStatus,
} from './message-store-migration';
export const MESSAGE_STORE_WORKER_KIND = 'snowluma-message-store-migration';
const DEFAULT_BATCH_SIZE = 200;
const DEFAULT_BATCH_PAUSE_MS = 25;
export interface MessageStoreMigrationWorkerData {
kind: typeof MESSAGE_STORE_WORKER_KIND;
dbPath: string;
}
export type MessageStoreMigrationWorkerMessage =
| { kind: 'ready' }
| { kind: 'preparation'; progress: MessageStoreMigrationPreparation }
| {
kind: 'progress';
status: MessageStoreMigrationStatus;
elapsedMs: number;
}
| { kind: 'failed'; message: string };
export function isMessageStoreMigrationWorkerData(
value: unknown = workerData,
): value is MessageStoreMigrationWorkerData {
if (value === null || typeof value !== 'object') {
return false;
}
const candidate = value as Partial<MessageStoreMigrationWorkerData>;
return candidate.kind === MESSAGE_STORE_WORKER_KIND && typeof candidate.dbPath === 'string';
}
export async function runMessageStoreMigrationWorker(
data: MessageStoreMigrationWorkerData,
port = parentPort,
): Promise<void> {
if (!port) {
throw new Error('database migration worker port is unavailable');
}
let migrator: MessageStoreMigrator | null = null;
let cancelled = false;
let releaseStart!: () => void;
const startRequested = new Promise<void>((resolve) => {
releaseStart = resolve;
});
const handleControlMessage = (message: unknown): void => {
if (message === 'cancel') {
cancelled = true;
}
if (message === 'start' || message === 'cancel') {
releaseStart();
}
};
port.on('message', handleControlMessage);
try {
await prepareMessageStoreDatabase(data.dbPath);
await migrateLegacyMedia(data.dbPath, () => cancelled);
if (cancelled) {
return;
}
port.postMessage({ kind: 'ready' } satisfies MessageStoreMigrationWorkerMessage);
await startRequested;
if (cancelled) {
return;
}
migrator = new MessageStoreMigrator(data.dbPath, (progress) => {
port.postMessage({
kind: 'preparation',
progress,
} satisfies MessageStoreMigrationWorkerMessage);
});
let lastProgressAt: number | null = null;
while (!cancelled) {
const status = await migrator.runBatch(DEFAULT_BATCH_SIZE);
const progressAt = performance.now();
port.postMessage({
kind: 'progress',
status,
elapsedMs: lastProgressAt === null ? 0 : Math.max(0, progressAt - lastProgressAt),
} satisfies MessageStoreMigrationWorkerMessage);
lastProgressAt = progressAt;
if (status.phase === 'complete') {
return;
}
await pause(DEFAULT_BATCH_PAUSE_MS);
}
} catch (error) {
port.postMessage({
kind: 'failed',
message: error instanceof Error ? error.message : String(error),
} satisfies MessageStoreMigrationWorkerMessage);
} finally {
port.off('message', handleControlMessage);
await migrator?.close();
}
}
function pause(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -0,0 +1,598 @@
import { SqliteDatabase, type SqliteStatement } from '@snowluma/sqlite';
import { openSqliteDb } from './sqlite-open';
const CLASSIFICATION_VERSION = 1;
const PENDING_INDEX_NAME = `idx_messages_pending_classification_v${CLASSIFICATION_VERSION}`;
const MIGRATION_NAME = 'message-classification-v1';
const LEGACY_PRIVATE_SEQUENCE_MIGRATION = 'private-nt-sequence-v1';
export type MessageStoreMigrationPhase = 'migrating' | 'complete';
export interface MessageStoreMigrationStatus {
phase: MessageStoreMigrationPhase;
processed: number;
total: number;
}
export type MessageStoreMigrationPreparation =
| { stage: 'started' }
| { stage: 'indexed'; elapsedMs: number }
| { stage: 'counted'; total: number; elapsedMs: number };
interface MigrationStateRow {
phase: MessageStoreMigrationPhase;
processed: number;
total: number;
cursor: number | null;
}
interface PendingMessageRow {
message_hash: number;
is_group: number;
sequence: number;
sequence_authoritative: number;
client_sequence: number;
private_direction: number;
random: number;
data: string | null;
}
export class MessageStoreMigrator {
private readonly db: SqliteDatabase;
private selectInitialBatch!: SqliteStatement;
private selectNextBatch!: SqliteStatement;
private updateRow!: SqliteStatement;
private updateState!: SqliteStatement;
private state!: MigrationStateRow;
readonly initialization: Promise<void>;
constructor(
dbPath: string,
reportPreparation?: (progress: MessageStoreMigrationPreparation) => void,
) {
this.db = openSqliteDb(dbPath.replace(/\.json$/, '.db'));
this.initialization = this.db.serial(async () => {
await createMigrationStateTable(this.db);
this.state = await this.loadOrCreateState(reportPreparation);
this.selectInitialBatch = this.db.prepare(`
SELECT message_hash, is_group, sequence, sequence_authoritative, client_sequence, private_direction, random, data
FROM messages
WHERE classification_version < ?
ORDER BY message_hash ASC
LIMIT ?
`);
this.selectNextBatch = this.db.prepare(`
SELECT message_hash, is_group, sequence, sequence_authoritative, client_sequence, private_direction, random, data
FROM messages
WHERE classification_version < ? AND message_hash > ?
ORDER BY message_hash ASC
LIMIT ?
`);
this.updateRow = this.db.prepare(`
UPDATE messages
SET sequence_authoritative = ?, private_direction = ?, classification_version = ?
WHERE message_hash = ?
AND classification_version < ?
AND is_group = ?
AND sequence = ?
AND sequence_authoritative = ?
AND client_sequence = ?
AND private_direction = ?
AND random = ?
AND data IS ?
`);
this.updateState = this.db.prepare(`
UPDATE message_store_migration_state
SET phase = ?, processed = ?, total = ?, cursor = ?, updated_at = ?
WHERE name = ?
`);
});
void this.initialization.catch(() => undefined);
}
async close(): Promise<void> {
await this.initialization.catch(() => undefined);
await this.db.close();
}
async getStatus(): Promise<MessageStoreMigrationStatus> {
return this.db.serial(async () => {
await this.initialization;
return publicStatus(this.state);
});
}
async runBatch(batchSize: number): Promise<MessageStoreMigrationStatus> {
return this.db.serial(async () => {
await this.initialization;
const limit = normalizeBatchSize(batchSize);
if (this.state.phase === 'complete') {
return this.getStatus();
}
let rows = await this.readBatch(limit, this.state.cursor);
if (rows.length === 0 && this.state.cursor !== null) {
rows = await this.readBatch(limit, null);
}
if (rows.length === 0) {
const remaining = await this.countPending();
await this.persistState({
...this.state,
phase: remaining === 0 ? 'complete' : 'migrating',
processed: remaining === 0 ? this.state.total : this.state.total - remaining,
cursor: null,
});
return this.getStatus();
}
const lastMessageHash = rows[rows.length - 1].message_hash;
const classified = rows.map((row) => ({
row,
classification: classifyLegacyMessage(row),
}));
let updatedRows = 0;
await this.db.exec('BEGIN IMMEDIATE');
try {
for (const { row, classification } of classified) {
const result = await this.updateRow.run(
classification.sequenceAuthoritative ? 1 : 0,
classification.privateDirection,
CLASSIFICATION_VERSION,
row.message_hash,
CLASSIFICATION_VERSION,
row.is_group,
row.sequence,
row.sequence_authoritative,
row.client_sequence,
row.private_direction,
row.random,
row.data,
);
updatedRows += Number(result.changes);
}
const nextState: MigrationStateRow = {
...this.state,
processed: Math.min(this.state.total, this.state.processed + updatedRows),
cursor: lastMessageHash,
};
await this.writeState(nextState);
await this.db.exec('COMMIT');
this.state = nextState;
} catch (error) {
await this.db.exec('ROLLBACK');
throw error;
}
if (rows.length < limit) {
const remaining = await this.countPending();
if (remaining === 0) {
await this.persistState({
...this.state,
phase: 'complete',
processed: this.state.total,
cursor: null,
});
}
}
return this.getStatus();
});
}
private async loadOrCreateState(
reportPreparation?: (progress: MessageStoreMigrationPreparation) => void,
): Promise<MigrationStateRow> {
const existing = (await this.db
.prepare(
`
SELECT phase, processed, total, cursor
FROM message_store_migration_state
WHERE name = ?
`,
)
.get(MIGRATION_NAME)) as MigrationStateRow | undefined;
if (existing?.phase === 'complete') {
return existing;
}
reportPreparation?.({ stage: 'started' });
const preparationStartedAt = performance.now();
await this.db.exec(`
CREATE INDEX IF NOT EXISTS ${PENDING_INDEX_NAME}
ON messages(message_hash, classification_version)
WHERE classification_version < ${CLASSIFICATION_VERSION}
`);
reportPreparation?.({
stage: 'indexed',
elapsedMs: Math.max(0, performance.now() - preparationStartedAt),
});
if (existing) {
return existing;
}
const countStartedAt = performance.now();
const row = (await this.db
.prepare(
`
SELECT COUNT(*) AS total
FROM messages
WHERE classification_version < ?
`,
)
.get(CLASSIFICATION_VERSION)) as { total: number };
const total = Number(row.total);
reportPreparation?.({
stage: 'counted',
total,
elapsedMs: Math.max(0, performance.now() - countStartedAt),
});
const now = Math.floor(Date.now() / 1000);
const state: MigrationStateRow = {
phase: total === 0 ? 'complete' : 'migrating',
processed: 0,
total,
cursor: null,
};
await this.db
.prepare(
`
INSERT INTO message_store_migration_state
(name, phase, processed, total, cursor, started_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`,
)
.run(MIGRATION_NAME, state.phase, 0, total, null, now, now);
return state;
}
private async countPending(): Promise<number> {
const row = (await this.db
.prepare(
`
SELECT COUNT(*) AS total
FROM messages
WHERE classification_version < ?
`,
)
.get(CLASSIFICATION_VERSION)) as { total: number };
return Number(row.total);
}
private async readBatch(limit: number, cursor: number | null): Promise<PendingMessageRow[]> {
return (cursor === null
? await this.selectInitialBatch.all(CLASSIFICATION_VERSION, limit)
: await this.selectNextBatch.all(
CLASSIFICATION_VERSION,
cursor,
limit,
)) as unknown as PendingMessageRow[];
}
private async persistState(state: MigrationStateRow): Promise<void> {
await this.db.exec('BEGIN IMMEDIATE');
try {
await this.writeState(state);
await this.db.exec('COMMIT');
this.state = state;
} catch (error) {
await this.db.exec('ROLLBACK');
throw error;
}
}
private async writeState(state: MigrationStateRow): Promise<void> {
await this.updateState.run(
state.phase,
state.processed,
state.total,
state.cursor,
Math.floor(Date.now() / 1000),
MIGRATION_NAME,
);
}
}
export async function prepareMessageStoreDatabase(dbPath: string): Promise<void> {
const db = openSqliteDb(dbPath.replace(/\.json$/, '.db'));
try {
const messagesTableExists =
(await db
.prepare(
`
SELECT 1 AS present
FROM sqlite_schema
WHERE type = 'table' AND name = 'messages'
`,
)
.get()) !== undefined;
await db.exec(`
CREATE TABLE IF NOT EXISTS messages (
message_hash INTEGER PRIMARY KEY,
is_group INTEGER NOT NULL,
session_id INTEGER NOT NULL,
sequence INTEGER NOT NULL,
sequence_authoritative INTEGER NOT NULL DEFAULT 1,
event_name TEXT NOT NULL,
client_sequence INTEGER NOT NULL DEFAULT 0,
private_direction INTEGER NOT NULL DEFAULT -1,
random INTEGER NOT NULL DEFAULT 0,
timestamp INTEGER NOT NULL DEFAULT 0,
data TEXT,
classification_version INTEGER NOT NULL DEFAULT 1
)
`);
await prepareMessageStoreSchema(db);
if (!messagesTableExists) {
await createMessageStoreIndexes(db);
}
} finally {
await db.close();
}
}
export async function inspectMessageStoreMigration(
dbPath: string,
): Promise<MessageStoreMigrationStatus> {
const db = openSqliteDb(dbPath.replace(/\.json$/, '.db'));
try {
await createMigrationStateTable(db);
const row = (await db
.prepare(
`
SELECT phase, processed, total
FROM message_store_migration_state
WHERE name = ?
`,
)
.get(MIGRATION_NAME)) as MessageStoreMigrationStatus | undefined;
if (row) {
return row;
}
const pending = (await db
.prepare(
`
SELECT COUNT(*) AS total
FROM messages
WHERE classification_version < ?
`,
)
.get(CLASSIFICATION_VERSION)) as { total: number };
const total = Number(pending.total);
return { phase: total === 0 ? 'complete' : 'migrating', processed: 0, total };
} finally {
await db.close();
}
}
export async function prepareMessageStoreSchema(db: SqliteDatabase): Promise<void> {
const before = (await db.prepare('PRAGMA table_info(messages)').all()) as Array<{
name: string;
}>;
const existingColumns = new Set(before.map((column) => column.name));
if (!existingColumns.has('media_data')) {
await db.exec('ALTER TABLE messages ADD COLUMN media_data TEXT');
}
const existingRowsAreCurrent =
existingColumns.has('sequence_authoritative') &&
existingColumns.has('private_direction') &&
(await hasLegacyPrivateSequenceMigration(db));
if (!existingColumns.has('sequence_authoritative')) {
await db.exec(
'ALTER TABLE messages ADD COLUMN sequence_authoritative INTEGER NOT NULL DEFAULT 1',
);
}
if (!existingColumns.has('private_direction')) {
await db.exec(
'ALTER TABLE messages ADD COLUMN private_direction INTEGER NOT NULL DEFAULT -1',
);
}
if (!existingColumns.has('classification_version')) {
await db.exec(
`ALTER TABLE messages ADD COLUMN classification_version INTEGER NOT NULL DEFAULT ${existingRowsAreCurrent ? CLASSIFICATION_VERSION : 0}`,
);
}
}
export async function createMessageStoreIndexes(db: SqliteDatabase): Promise<void> {
await db.exec(
'CREATE INDEX IF NOT EXISTS idx_messages_session_seq ON messages(is_group, session_id, sequence)',
);
await db.exec(
'CREATE INDEX IF NOT EXISTS idx_messages_session_authoritative_seq ON messages(is_group, session_id, sequence_authoritative, sequence)',
);
await db.exec(
'CREATE INDEX IF NOT EXISTS idx_messages_private_client_seq ON messages(is_group, session_id, client_sequence)',
);
await db.exec(
'CREATE INDEX IF NOT EXISTS idx_messages_private_client_direction ON messages(is_group, session_id, client_sequence, private_direction)',
);
}
async function hasLegacyPrivateSequenceMigration(db: SqliteDatabase): Promise<boolean> {
const tableExists =
(await db
.prepare(
`
SELECT 1 AS present
FROM sqlite_schema
WHERE type = 'table' AND name = 'message_store_migrations'
`,
)
.get()) !== undefined;
if (!tableExists) {
return false;
}
return (
(await db
.prepare(
`
SELECT 1 AS applied
FROM message_store_migrations
WHERE name = ?
`,
)
.get(LEGACY_PRIVATE_SEQUENCE_MIGRATION)) !== undefined
);
}
async function createMigrationStateTable(db: SqliteDatabase): Promise<void> {
await db.exec(`
CREATE TABLE IF NOT EXISTS message_store_migration_state (
name TEXT PRIMARY KEY,
phase TEXT NOT NULL,
processed INTEGER NOT NULL,
total INTEGER NOT NULL,
cursor INTEGER,
started_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
}
function publicStatus(state: MigrationStateRow): MessageStoreMigrationStatus {
return {
phase: state.phase,
processed: state.processed,
total: state.total,
};
}
function normalizeBatchSize(value: number): number {
if (!Number.isSafeInteger(value) || value <= 0 || value > 1000) {
throw new RangeError('migration batch size must be an integer between 1 and 1000');
}
return value;
}
function classifyLegacyMessage(row: PendingMessageRow): {
sequenceAuthoritative: boolean;
privateDirection: number;
} {
const event = parseEvent(row.data);
if (row.is_group !== 1) {
return {
sequenceAuthoritative: row.sequence_authoritative === 1 && row.client_sequence > 0,
privateDirection: privateDirection(event),
};
}
return {
sequenceAuthoritative:
row.sequence_authoritative === 1 &&
(row.sequence <= 0 || !isSyntheticGroupMessage(row, event)),
privateDirection: -1,
};
}
function parseEvent(data: string | null): Record<string, unknown> | null {
if (data === null) {
return null;
}
try {
const parsed = JSON.parse(data) as unknown;
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function privateDirection(event: Record<string, unknown> | null): number {
if (event?.message_type !== 'private' || event.sub_type !== 'friend') {
return -1;
}
if (event.post_type === 'message') {
return 0;
}
if (event.post_type === 'message_sent') {
return 1;
}
return -1;
}
function isSyntheticGroupMessage(
row: PendingMessageRow,
event: Record<string, unknown> | null,
): boolean {
if (!event || event.message_type !== 'group') {
return false;
}
const message = Array.isArray(event.message) ? event.message : [];
const first = message[0];
const firstSegment =
first !== null && typeof first === 'object' ? (first as Record<string, unknown>) : null;
const firstData =
firstSegment?.data !== null && typeof firstSegment?.data === 'object'
? (firstSegment.data as Record<string, unknown>)
: null;
if (
event.post_type === 'message_sent' &&
row.random === row.sequence &&
(firstSegment?.type === 'file' || firstSegment?.type === 'video')
) {
return true;
}
if (event.post_type !== 'message') {
return false;
}
if (event.user_id === 0 && event.time === 0) {
return true;
}
if (
event.user_id === 0 &&
message.length === 1 &&
firstSegment?.type === 'text' &&
firstData?.text === '[引用消息]'
) {
return true;
}
const sender =
event.sender !== null && typeof event.sender === 'object'
? (event.sender as Record<string, unknown>)
: null;
return (
!Object.prototype.hasOwnProperty.call(event, 'group_name') &&
sender !== null &&
sender.user_id === event.user_id &&
sender.nickname === '' &&
sender.card === '' &&
sender.role === 'member' &&
sender.sex === 'unknown' &&
sender.age === 0
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,831 @@
import { mapWithConcurrency } from '@snowluma/common/concurrency';
import { createLogger } from '@snowluma/common/logger';
import type { BridgeInterface } from '@snowluma/core/bridge-interface';
import {
datalineFriendListEntries,
findDatalineDeviceByUin,
} from '@snowluma/protocol/dataline/device-contacts';
import {
formatGroupRequestFlag,
groupRequestSubType,
isSelfGroupInvitation,
type GroupMemberInfo,
type GroupRequestInfo,
type UserProfileInfo,
} from '@snowluma/protocol/qq-info';
import type { QidianCorpInfo } from '@snowluma/protocol/services/qidian/fetch-corp-info';
import type { OneBotInstanceContext } from '../instance-context';
import type { JsonObject } from '../types';
import { formatFriend, formatPublicProfile } from './contact-profile';
const REQUESTER_PROFILE_CONCURRENCY = 4;
const log = createLogger('OneBot.Contacts');
export interface GroupSystemMessageQuery {
groupId?: number;
onlyPending?: boolean;
/** Maximum records read from each QQ request inbox. */
count?: number;
}
export function getLoginInfo(ref: OneBotInstanceContext): { userId: number; nickname: string } {
const userId = parseInt(ref.uin, 10) || 0;
const nickname = ref.bridge.identity.nickname || ref.uin;
return { userId, nickname };
}
async function fetchSingleGroupMembers(
bridge: BridgeInterface,
groupId: number,
force = false,
): Promise<GroupMemberInfo[]> {
try {
return await bridge.apis.contacts.fetchGroupMemberList(groupId, { force });
} catch (err) {
log.warn(
'group member refresh failed: uin=%s group=%d err=%s',
bridge.identity.uin,
groupId,
err instanceof Error ? (err.stack ?? err.message) : String(err),
);
throw err;
}
}
export async function getFriendList(
bridge: BridgeInterface,
withProfile = false,
): Promise<JsonObject[]> {
let rows: JsonObject[];
try {
rows = (await bridge.apis.contacts.fetchFriendList({ withProfile })).map((friend) =>
formatFriend(friend),
);
} catch (error) {
log.warn(
'friend list refresh failed: withProfile=%s error=%s',
withProfile,
error instanceof Error ? error.message : String(error),
);
if (withProfile) {
throw error;
}
rows = bridge.identity.friends.map((friend) => formatFriend(friend));
}
const occupied = new Set(rows.map((row) => Number(row.user_id)));
for (const extra of datalineFriendListEntries()) {
if (occupied.has(extra.user_id)) {
continue;
}
rows.push({
user_id: extra.user_id,
nickname: extra.nickname,
remark: extra.remark,
});
}
return rows;
}
export async function getGroupList(
bridge: BridgeInterface,
noCache?: boolean,
): Promise<JsonObject[]> {
try {
if (noCache || bridge.identity.groups.length === 0) {
await bridge.apis.contacts.fetchGroupList();
}
} catch (err) {
if (noCache) {
throw err;
}
log.warn(
'group list refresh failed, using cached roster: uin=%s err=%s',
bridge.identity.uin,
err instanceof Error ? (err.stack ?? err.message) : String(err),
);
}
return bridge.identity.groups.map((g) => ({
group_id: g.groupId,
group_name: g.groupName,
group_remark: g.remark,
member_count: g.memberCount,
max_member_count: g.memberMax,
// #197: create_time + memo come free from the list; group_level is only in
// the per-group 0x88D_0 detail, so it stays 0 here (a per-group fetch across
// the whole list would be far too expensive). get_group_info fills it in.
group_create_time: g.createTime ?? 0,
group_level: g.level ?? 0,
group_memo: g.memo ?? '',
// #490: the description is its own field; group_memo prefers the
// announcement, so it can't be used to recover the description.
group_description: g.description ?? '',
group_all_shut: g.allMuted ? -1 : 0,
}));
}
// group_level lives only in the per-group detail response. Keep its existing
// short cache, but read mutable group state from the roster refreshed above.
const GROUP_LEVEL_TTL_MS = 5 * 60 * 1000;
const groupLevelCache = new Map<number, { level: number; at: number }>();
async function getGroupLevel(
bridge: BridgeInterface,
groupId: number,
noCache?: boolean,
): Promise<number> {
if (!noCache) {
const cached = groupLevelCache.get(groupId);
if (cached && Date.now() - cached.at < GROUP_LEVEL_TTL_MS) {
return cached.level;
}
}
try {
const detail = await bridge.apis.contacts.fetchGroupDetail(groupId);
const level = detail?.level ?? 0;
groupLevelCache.set(groupId, { level, at: Date.now() });
return level;
} catch (err) {
if (noCache) {
throw err;
}
log.warn(
'group detail refresh failed, using roster metadata: uin=%s group=%d err=%s',
bridge.identity.uin,
groupId,
err instanceof Error ? (err.stack ?? err.message) : String(err),
);
return 0;
}
}
// Short-TTL cache for the non-member group lookup (0x88D_0). Joined groups come
// from the identity roster (kept fresh by fetchGroupList) and are NOT cached
// here; this only memoizes the per-id server query so a burst of invites for the
// same group doesn't hammer 0x88D_0 (which would risk a rate-limit / kick).
const NON_MEMBER_GROUP_TTL_MS = 5 * 60 * 1000;
const nonMemberGroupCache = new Map<string, { info: JsonObject; at: number }>();
function nonMemberGroupCacheKey(bridge: BridgeInterface, groupId: number): string {
return `${bridge.identity.uin}:${groupId}`;
}
export async function getGroupInfo(
bridge: BridgeInterface,
groupId: number,
noCache?: boolean,
): Promise<JsonObject | null> {
if (noCache || !bridge.identity.findGroup(groupId)) {
try {
await bridge.apis.contacts.fetchGroupList();
} catch (err) {
if (noCache) {
throw err;
}
log.warn(
'group list refresh failed, using cached roster: uin=%s group=%d err=%s',
bridge.identity.uin,
groupId,
err instanceof Error ? (err.stack ?? err.message) : String(err),
);
}
}
const g = bridge.identity.findGroup(groupId);
if (g) {
return {
group_id: g.groupId,
group_name: g.groupName,
group_remark: g.remark,
member_count: g.memberCount,
max_member_count: g.memberMax,
group_create_time: g.createTime ?? 0,
group_level: await getGroupLevel(bridge, groupId, noCache),
group_memo: g.memo ?? '',
group_description: g.description ?? '',
group_all_shut: g.allMuted ? -1 : 0,
};
}
// Not a joined group — fall back to the by-id server lookup so a group invite
// can still resolve its name. Cached with a short TTL (skipped when noCache).
const cacheKey = nonMemberGroupCacheKey(bridge, groupId);
if (!noCache) {
const cached = nonMemberGroupCache.get(cacheKey);
if (cached && Date.now() - cached.at < NON_MEMBER_GROUP_TTL_MS) {
return { ...cached.info };
}
}
try {
const detail = await bridge.apis.contacts.fetchGroupDetail(groupId);
if (detail) {
const info: JsonObject = {
group_id: detail.groupId,
group_name: detail.groupName,
group_remark: detail.remark,
member_count: detail.memberCount,
max_member_count: detail.memberMax,
group_create_time: detail.createTime ?? 0,
group_level: detail.level ?? 0,
group_memo: detail.memo ?? '',
// #490: always '' here — the non-member detail lookup has no
// description to read (see QQGroupInfo.description). The key is still
// returned so the response shape doesn't change with membership.
group_description: detail.description ?? '',
group_all_shut: detail.allMuted ? -1 : 0,
};
nonMemberGroupCache.set(cacheKey, { info, at: Date.now() });
return { ...info };
}
} catch (err) {
if (noCache) {
throw err;
}
log.warn(
'non-member group lookup failed: uin=%s group=%d err=%s',
bridge.identity.uin,
groupId,
err instanceof Error ? (err.stack ?? err.message) : String(err),
);
}
return null;
}
export async function getGroupMemberList(
bridge: BridgeInterface,
groupId: number,
noCache?: boolean,
): Promise<JsonObject[]> {
if (noCache) {
const members = await fetchSingleGroupMembers(bridge, groupId, true);
return members.map((m) =>
formatGroupMember(groupId, m, cachedQidianProfile(bridge, m.uin)),
);
}
try {
const members = await bridge.apis.contacts.fetchGroupMemberList(groupId);
return members.map((m) =>
formatGroupMember(groupId, m, cachedQidianProfile(bridge, m.uin)),
);
} catch (err) {
log.warn(
'group member fetch failed, using classified cache: uin=%s group=%d err=%s',
bridge.identity.uin,
groupId,
err instanceof Error ? (err.stack ?? err.message) : String(err),
);
const cached = getCachedGroupMembers(bridge, groupId);
if (cached.length === 0) {
throw err;
}
return cached;
}
}
export async function getGroupMemberInfo(
bridge: BridgeInterface,
groupId: number,
userId: number,
noCache?: boolean,
): Promise<JsonObject | null> {
const cached = bridge.identity.findGroupMember(groupId, userId);
if (noCache || !cached || cached.isRobot === undefined || cached.card === undefined) {
await fetchSingleGroupMembers(bridge, groupId, noCache === true);
}
const m = bridge.identity.findGroupMember(groupId, userId);
if (!m) {
return null;
}
// 企点标志(#404 后续 PR):优先读身份缓存,无缓存时再按需拉取一次用户资料。
// 均为 best-effort —— 失败时回退为 0,不影响成员信息主流程。
let profile = cachedQidianProfile(bridge, userId);
if (noCache || !profile) {
try {
profile = await bridge.apis.contacts.fetchUserProfile(userId);
} catch (error) {
log.warn(
'group member profile unavailable: group=%d member=%d error=%s',
groupId,
userId,
error instanceof Error ? error.message : String(error),
);
}
}
return formatGroupMember(groupId, m, profile ?? undefined);
}
export async function getGroupFiles(
bridge: BridgeInterface,
groupId: number,
folderId?: string,
): Promise<JsonObject> {
const result = await bridge.apis.groupFile.list(groupId, folderId ?? '/');
return {
files: result.files.map(
(file) =>
({
group_id: groupId,
file_id: file.fileId,
file_name: file.fileName,
busid: file.busId,
file_size: file.fileSize,
upload_time: file.uploadTime,
dead_time: file.deadTime,
modify_time: file.modifyTime,
download_times: file.downloadTimes,
uploader: file.uploader,
uploader_name: file.uploaderName,
}) as JsonObject,
),
folders: result.folders.map(
(folder) =>
({
group_id: groupId,
folder_id: folder.folderId,
folder_name: folder.folderName,
create_time: folder.createTime,
creator: folder.creator,
create_name: folder.creatorName,
total_file_count: folder.totalFileCount,
last_upload_time: folder.lastUploadTime,
last_uploader: folder.lastUploader,
last_uploader_name: folder.lastUploaderName,
}) as JsonObject,
),
};
}
function formatStrangerInfo(p: UserProfileInfo, corp?: QidianCorpInfo | null): JsonObject {
return {
user_id: p.uin,
uid: p.uid,
qid: p.qid,
nickname: p.nickname,
remark: p.remark,
sex: p.sex,
age: p.age,
long_nick: p.sign,
qq_level: p.level,
qqLevel: p.level,
level: p.level,
...(p.regTime !== undefined ? { reg_time: p.regTime } : {}),
...(p.birthdayYear !== undefined ? { birthday_year: p.birthdayYear } : {}),
...(p.birthdayMonth !== undefined ? { birthday_month: p.birthdayMonth } : {}),
...(p.birthdayDay !== undefined ? { birthday_day: p.birthdayDay } : {}),
...(p.svipFlag !== undefined ? { is_vip: p.svipFlag } : {}),
...(p.yearVipFlag !== undefined ? { is_years_vip: p.yearVipFlag } : {}),
...(p.vipLevel !== undefined ? { vip_level: p.vipLevel } : {}),
...(p.vipFlag !== undefined ? { is_qq_vip: p.vipFlag } : {}),
...formatPublicProfile(p.profile),
status: p.status ?? 0,
extStatus: p.extStatus ?? 0,
ext_status: p.extStatus ?? 0,
batteryStatus: p.batteryStatus ?? 0,
customStatus: p.customStatus ?? null,
customStatusDescInfo: p.customStatusDesc ?? '',
qidian_master_flag: p.qidianMasterFlag ?? 0,
qidian_crew_flag: p.qidianCrewFlag ?? 0,
qidian_crew_flag_2: p.qidianCrewFlag2 ?? 0,
// #404 后续 PR:企点企业资料卡。企业资料卡不含独立的企业 ID,
// 因此这里只透出企业名称(SsoCorpInfo.corpName)。
qidian_enterprise_name: corp?.name ?? '',
};
}
/** 命中的是企点账号时才去拉一次企业资料卡(best-effort,失败返回 null)。 */
async function strangerCorpInfo(
bridge: BridgeInterface,
p: UserProfileInfo,
): Promise<QidianCorpInfo | null> {
if (p.qidianMasterFlag === 0 && p.qidianCrewFlag === 0) {
return null;
}
try {
return await bridge.apis.contacts.fetchQidianCorpInfo(p.uin);
} catch {
return null;
}
}
export async function getStrangerInfo(
bridge: BridgeInterface,
userId: number,
): Promise<JsonObject | null> {
const device = findDatalineDeviceByUin(userId);
if (device) {
return {
user_id: device.uin,
nickname: device.name,
remark: device.name,
sex: 'unknown',
age: 0,
long_nick: '',
};
}
try {
const p = await bridge.apis.contacts.fetchUserProfile(userId);
const corp = await strangerCorpInfo(bridge, p);
return formatStrangerInfo(p, corp);
} catch (error) {
log.warn(
'user profile refresh failed, checking cached profile: user=%d error=%s',
userId,
error instanceof Error ? error.message : String(error),
);
const p = bridge.identity.findUserProfile(userId);
if (p) {
const profile: UserProfileInfo = {
...p,
remark: p.remark || bridge.identity.findFriend(userId)?.remark || '',
};
const corp = await strangerCorpInfo(bridge, profile);
return formatStrangerInfo(profile, corp);
}
const friend = bridge.identity.findFriend(userId);
if (!friend) {
return null;
}
return {
user_id: friend.uin,
nickname: friend.nickname,
remark: friend.remark,
sex: 'unknown',
age: 0,
long_nick: '',
};
}
}
async function resolveGroupRequestUins(
bridge: BridgeInterface,
requests: readonly GroupRequestInfo[],
): Promise<Map<string, { uin: number; name: string }>> {
const resolved = new Map<string, { uin: number; name: string }>();
const unresolved = new Set<string>();
const names = new Map<string, string>();
const roles = new Map<string, string>();
const unresolvedUins = new Set<number>();
const actors = requests.flatMap((request) =>
(['target', 'invitor', 'operator'] as const).map((role) => ({
...groupRequestPerson(request, role),
groupId: request.groupId,
role,
})),
);
for (const actor of actors) {
const uid = actor.uid;
if (uid) {
roles.set(uid, actor.role === 'target' ? 'requester' : actor.role);
}
if (actor.name) {
names.set(uid || `uin:${actor.uin}`, actor.name);
}
if (actor.uin > 0) {
resolved.set(uid || `uin:${actor.uin}`, { uin: actor.uin, name: actor.name });
if (uid) {
unresolved.delete(uid);
}
if (!actor.name) {
unresolvedUins.add(actor.uin);
}
continue;
}
if (uid) {
if (resolved.has(uid) || unresolved.has(uid)) {
continue;
}
const cached =
bridge.identity.findUinByUid(uid, actor.groupId) ??
bridge.identity.findUinByUid(uid);
if (cached && cached > 0) {
resolved.set(uid, { uin: cached, name: actor.name });
if (!actor.name) {
unresolvedUins.add(cached);
}
} else {
unresolved.add(uid);
}
continue;
}
}
const profiles = await mapWithConcurrency(
[...unresolved],
REQUESTER_PROFILE_CONCURRENCY,
async (uid) => {
let profile: UserProfileInfo;
try {
profile = await bridge.apis.contacts.fetchUserProfileByUid(uid);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`failed to resolve ${roles.get(uid)} UIN for UID ${uid}: ${message}`,
{
cause: error,
},
);
}
if (!Number.isSafeInteger(profile.uin) || profile.uin <= 0) {
throw new Error(`failed to resolve ${roles.get(uid)} UIN for UID ${uid}`);
}
return [uid, profile] as const;
},
);
for (const [uid, profile] of profiles) {
resolved.set(uid, {
uin: profile.uin,
name: names.get(uid) || profile.nickname || '',
});
}
const uinProfiles = await mapWithConcurrency(
[...unresolvedUins],
REQUESTER_PROFILE_CONCURRENCY,
async (uin) => {
try {
return [uin, await bridge.apis.contacts.fetchUserProfile(uin)] as const;
} catch (error) {
log.warn(
'group request actor nickname unavailable: uin=%d error=%s',
uin,
error instanceof Error ? error.message : String(error),
);
return [uin, undefined] as const;
}
},
);
for (const [uin, profile] of uinProfiles) {
if (!profile) {
continue;
}
resolved.set(`uin:${uin}`, { uin: profile.uin, name: profile.nickname || '' });
}
return resolved;
}
function groupRequestPerson(
request: GroupRequestInfo,
role: 'target' | 'invitor' | 'operator',
): {
uid: string;
uin: number;
name: string;
} {
return {
uid: request[`${role}Uid`],
uin: request[`${role}Uin`],
name: request[`${role}Name`],
};
}
export async function getGroupSystemMessages(
bridge: BridgeInterface,
query: GroupSystemMessageQuery = {},
): Promise<JsonObject[]> {
const count = query.count ?? 50;
if (!Number.isSafeInteger(count) || count < 1 || count > 100) {
throw new Error(`invalid group-system-message count: ${count}`);
}
const [main, filtered] = await Promise.all([
bridge.apis.contacts.fetchGroupRequests(false, count),
bridge.apis.contacts.fetchGroupRequests(true, count),
]);
const unique = new Map<string, GroupRequestInfo>();
for (const request of [...main, ...filtered]) {
if (!groupRequestSubType(request)) {
continue;
}
const flag = formatGroupRequestFlag(request);
if (!unique.has(flag)) {
unique.set(flag, request);
}
}
const requests = [...unique.values()].filter((request) => {
if (query.groupId !== undefined && request.groupId !== query.groupId) {
return false;
}
if (query.onlyPending && request.state !== 1) {
return false;
}
return true;
});
log.debug(
'group request list: main=%d filtered=%d relevant=%d selected=%d group=%s onlyPending=%s',
main.length,
filtered.length,
unique.size,
requests.length,
query.groupId ?? 'all',
Boolean(query.onlyPending),
);
const actorUins = await resolveGroupRequestUins(bridge, requests);
return requests.map((request) => {
const person = (role: 'target' | 'invitor' | 'operator') => {
const actor = groupRequestPerson(request, role);
const resolved =
(actor.uid ? actorUins.get(actor.uid) : undefined) ??
(actor.uin > 0 ? actorUins.get(`uin:${actor.uin}`) : undefined);
const uin = actor.uin || resolved?.uin || 0;
const name = actor.name || actorUins.get(`uin:${uin}`)?.name || resolved?.name || '';
return { uin, name, status: uin > 0 ? 'known' : 'unknown' };
};
const target = person('target');
const invitor = person('invitor');
const operator = person('operator');
const requester = isSelfGroupInvitation(request) ? invitor : target;
return {
group_id: request.groupId,
group_name: request.groupName,
request_id: request.sequence,
requester_uin: requester.uin,
requester_nick: requester.name,
target_uin: target.uin,
target_nick: target.name,
target_status: target.status,
invitor_uin: invitor.uin,
invitor_nick: invitor.name,
invitor_status: invitor.status,
operator_uin: operator.uin,
operator_nick: operator.name,
operator_status: operator.status,
message: request.comment,
checked: request.state !== 1,
flag: formatGroupRequestFlag(request),
};
});
}
export async function getDownloadRKeys(bridge: BridgeInterface): Promise<JsonObject[]> {
try {
const rkeys = await bridge.apis.contacts.fetchDownloadRKeys();
return rkeys.map((r) => ({
rkey: r.rkey,
type: r.type,
ttl: r.ttlSeconds,
create_time: r.createTime,
}));
} catch {
return [];
}
}
function getCachedGroupMembers(bridge: BridgeInterface, groupId: number): JsonObject[] {
const g = bridge.identity.findGroup(groupId);
if (!g) {
return [];
}
const result: JsonObject[] = [];
for (const [, member] of g.members) {
result.push(formatGroupMember(groupId, member, cachedQidianProfile(bridge, member.uin)));
}
return result;
}
function formatGroupMember(
groupId: number,
member: GroupMemberInfo,
profile?: UserProfileInfo,
): JsonObject {
if (member.isRobot === undefined) {
throw new Error(
`group member robot classification unavailable: group=${groupId} member=${member.uin}`,
);
}
if (member.card === undefined) {
throw new Error(`group member card unavailable: group=${groupId} member=${member.uin}`);
}
const qage = accountAge(profile?.regTime);
return {
group_id: groupId,
user_id: member.uin,
nickname: member.nickname,
card: member.card,
is_robot: member.isRobot,
sex: 'unknown',
age: 0,
join_time: member.joinTime,
last_sent_time: member.lastSentTime,
shut_up_timestamp: member.shutUpTime,
level: String(member.level),
...(profile ? { qq_level: profile.level } : {}),
...(qage !== undefined ? { qage } : {}),
role: member.role,
title: member.title,
// OneBot v11 completeness (#197). QQ NT doesn't expose these, so they're
// fixed placeholders — matching go-cqhttp / NapCat, which also hardcode
// them: area='', unfriendly=false, title_expire_time=0, card_changeable=true.
area: '',
unfriendly: false,
title_expire_time: 0,
card_changeable: true,
// 企点标志(#404 后续 PR):群成员信息同样透传用户资料上的企点标志,
// 未查到资料时全部为 0(普通账号)。
qidian_master_flag: profile?.qidianMasterFlag ?? 0,
qidian_crew_flag: profile?.qidianCrewFlag ?? 0,
qidian_crew_flag_2: profile?.qidianCrewFlag2 ?? 0,
};
}
/** Completed UTC calendar years since registration; unavailable dates stay absent. */
function accountAge(regTime: number | undefined): number | undefined {
if (regTime === undefined || !Number.isSafeInteger(regTime) || regTime <= 0) {
return undefined;
}
const now = new Date();
const registered = new Date(regTime * 1000);
if (!Number.isFinite(registered.getTime()) || registered > now) {
return undefined;
}
const years = now.getUTCFullYear() - registered.getUTCFullYear();
registered.setUTCFullYear(now.getUTCFullYear());
return years - (registered > now ? 1 : 0);
}
/** Best-effort read of a cached user profile's qidian flags. Triggers no
* network call — only the identity cache (populated by fetchUserProfile /
* fetchUserProfileByUid). */
function cachedQidianProfile(bridge: BridgeInterface, uin: number): UserProfileInfo | undefined {
try {
if ('findUserProfile' in bridge.identity) {
return bridge.identity.findUserProfile(uin) ?? undefined;
}
} catch {
// profile cache lookup is best-effort; fall back to all-zero flags
}
return undefined;
}
@@ -0,0 +1,64 @@
import type { FriendInfo, ProfilePrivilege, PublicProfileFields } from '@snowluma/protocol/qq-info';
import type { JsonObject } from '../types';
function formatPrivilege(value: ProfilePrivilege): JsonObject {
return {
...(value.type !== undefined ? { type: value.type } : {}),
...(value.level !== undefined ? { level: value.level } : {}),
...(value.flag !== undefined ? { flag: value.flag } : {}),
...(value.isBig !== undefined ? { is_big: value.isBig } : {}),
...(value.jumpUrl !== undefined ? { jump_url: value.jumpUrl } : {}),
...(value.iconUrl !== undefined ? { icon_url: value.iconUrl } : {}),
...(value.deluxeIconUrl !== undefined ? { deluxe_icon_url: value.deluxeIconUrl } : {}),
};
}
export function formatPublicProfile(profile: PublicProfileFields = {}): JsonObject {
const result: JsonObject = {};
for (const [source, target] of [
['sign', 'long_nick'],
['sex', 'sex'],
['age', 'age'],
['level', 'level'],
['birthdayYear', 'birthday_year'],
['birthdayMonth', 'birthday_month'],
['birthdayDay', 'birthday_day'],
['phoneNum', 'phone_num'],
['email', 'email'],
['country', 'country'],
['province', 'province'],
['city', 'city'],
['college', 'college'],
['vipFlag', 'is_qq_vip'],
['svipFlag', 'is_vip'],
['yearVipFlag', 'is_years_vip'],
['vipLevel', 'vip_level'],
] as const) {
if (profile[source] !== undefined) {
result[target] = profile[source];
}
}
if (profile.privilegeIcons !== undefined) {
result.privilege_icons = {
...(profile.privilegeIcons.jumpUrl !== undefined
? { jump_url: profile.privilegeIcons.jumpUrl }
: {}),
open: profile.privilegeIcons.open.map(formatPrivilege),
closed: profile.privilegeIcons.closed.map(formatPrivilege),
};
}
return result;
}
export function formatFriend(friend: FriendInfo, categoryId = friend.categoryId): JsonObject {
return {
user_id: friend.uin,
nickname: friend.nickname,
remark: friend.remark,
...(categoryId !== undefined ? { category_id: categoryId } : {}),
...formatPublicProfile(friend.profile),
};
}
@@ -0,0 +1,301 @@
import type { BridgeInterface } from '@snowluma/core/bridge-interface';
import type { MessageElementOf } from '@snowluma/protocol/events';
import { createLogger } from '@snowluma/common/logger';
import type { ImageUrlResolver } from '../event-converter';
import type { CachedImage, MediaStore } from '../media-store';
import type { MessageStore } from '../message-store';
import type { OneBotInstanceContext } from '../instance-context';
import { resolveRemoteMediaUrl } from '../media-url-resolver';
import { imageMd5FromFile, isStoredMediaSegment } from '../message-media';
import { segmentsToRawMessage } from '../helper/cq';
import { pttTransKey, requestPttTransText } from './ptt-trans-waiter';
import type { JsonObject } from '../types';
const log = createLogger('OneBot');
export async function refreshMessageMedia(
ref: Pick<OneBotInstanceContext, 'bridge' | 'messageStore' | 'mediaStore' | 'converterCtx'>,
messageId: number,
event: JsonObject,
): Promise<void> {
if (!Array.isArray(event.message)) {
return;
}
const segments = event.message.filter(isStoredMediaSegment);
const voiceVideoSegments = segments.filter((segment) => segment.type !== 'image');
const snapshots = segments.length > 0 ? await ref.messageStore.findMedia(messageId) : null;
const meta = segments.length > 0 ? await ref.messageStore.findMeta(messageId) : null;
const media = snapshots?.filter((element) => element.type !== 'image');
const aligned =
snapshots?.length === segments.length &&
snapshots.every((element, index) => element.type === segments[index]!.type);
if (snapshots && !aligned && segments.some((segment) => segment.type === 'image')) {
log.warn('[get_msg] image metadata association inconsistent message_id=%d', messageId);
}
// Validate the ordered association once; a filename lookup can select another upload.
const voiceVideo =
meta &&
media &&
media.length === voiceVideoSegments.length &&
media.every((element, index) => element.type === voiceVideoSegments[index]!.type)
? { media, meta }
: null;
if (voiceVideoSegments.length > 0 && !voiceVideo) {
log.warn('[get_msg] media metadata missing or inconsistent message_id=%d', messageId);
}
let voiceVideoIndex = 0;
let changed = false;
for (const [index, segment] of segments.entries()) {
try {
let url: string | null;
if (segment.type === 'image') {
const snapshot = aligned ? snapshots?.[index] : undefined;
const ownUrl =
typeof segment.data.url === 'string' && /^https?:\/\//i.test(segment.data.url)
? segment.data.url
: '';
if (meta && (snapshot?.type === 'image' || ownUrl)) {
const element: MessageElementOf<'image'> =
snapshot?.type === 'image'
? snapshot
: {
type: 'image' as const,
subType:
typeof segment.data.sub_type === 'number'
? segment.data.sub_type
: 0,
};
const source = element.imageUrl || element.url || ownUrl;
if (typeof source === 'string' && /^https?:\/\//i.test(source)) {
url = ref.converterCtx.imageUrlResolver
? await ref.converterCtx.imageUrlResolver(
{ ...element, imageUrl: source },
meta.isGroup,
)
: source;
} else {
url = null;
}
} else if (!snapshots) {
const file =
typeof segment.data.file === 'string'
? segment.data.file
: segment.data.file_id;
if (typeof file !== 'string' || !file) {
continue;
}
const cached = await ref.mediaStore.findImage(file);
const md5 = imageMd5FromFile(file);
url =
cached &&
meta &&
cached.isGroup === meta.isGroup &&
cached.sessionId === meta.targetId &&
(!md5 || cached.md5Hex?.toLowerCase() === md5)
? await resolveImageUrl(cached, ref.converterCtx.imageUrlResolver)
: null;
} else {
url = null;
}
} else {
if (!voiceVideo) {
continue;
}
const element = voiceVideo.media[voiceVideoIndex++]!;
url = await resolveRemoteMediaUrl(
ref.bridge,
element.type,
element.mediaNode,
voiceVideo.meta.isGroup,
voiceVideo.meta.targetId,
);
}
if (url && url !== segment.data.url) {
segment.data.url = url;
changed = true;
}
} catch (err) {
// Refresh failure preserves this message's URL, never a different cached URL.
log.warn(
'[get_msg] media URL refresh failed message_id=%d type=%s media_index=%d: %s',
messageId,
segment.type,
index,
err instanceof Error ? err.message : String(err),
);
}
}
if (changed) {
event.raw_message = segmentsToRawMessage(event.message);
}
}
async function resolveImageUrl(
cached: CachedImage | null,
resolver?: ImageUrlResolver | null,
): Promise<string | null> {
if (!cached?.imageUrl || !resolver) {
return null;
}
return resolver(
{ type: 'image', imageUrl: cached.imageUrl, subType: cached.subType },
cached.isGroup,
);
}
/**
* Voice-to-text for a received ptt (issue #79 / NapCat `fetch_ptt_text`).
* Resolves the message's `record` segment to its cached fingerprint
* (uuid + md5 + duration/size/format), then drives QQ's native
* `pttTrans.Trans{C2C,Group}PttReq` via the bridge and returns the text.
*/
export async function fetchPttText(
messageStore: MessageStore,
mediaStore: MediaStore,
bridge: BridgeInterface,
selfId: number,
messageId: number,
): Promise<{ text: string }> {
const event = await messageStore.findEvent(messageId);
if (!event) {
throw new Error('消息不存在或已被撤回');
}
const segments = Array.isArray(event.message) ? event.message : [];
let file = '';
for (const seg of segments) {
if (typeof seg !== 'object' || seg === null || Array.isArray(seg)) {
continue;
}
const so = seg as JsonObject;
if (so.type !== 'record') {
continue;
}
const data =
typeof so.data === 'object' && so.data !== null && !Array.isArray(so.data)
? (so.data as JsonObject)
: {};
file = String(data.file ?? data.url ?? '');
break;
}
if (!file) {
throw new Error('消息中不包含语音');
}
const snapshot = (await messageStore.findMedia(messageId))?.find(
(element) => element.type === 'record',
);
const cached = snapshot
? {
...snapshot,
isGroup: event.message_type === 'group',
sessionId: Number(event.group_id) || selfId,
}
: await mediaStore.findRecord(file, 'legacy');
if (!cached) {
throw new Error('语音不在缓存中,无法转写');
}
const isGroup = event.message_type === 'group' || cached.isGroup;
const senderUin = Number(event.user_id) || 0;
// c2c: receiver is self (inbound voice); group: the group uin.
const peerUin = isGroup ? Number(event.group_id) || cached.sessionId : selfId;
const text = await requestPttTransText(pttTransKey(selfId, messageId), 20_000, () =>
bridge.apis.extras.translatePttToText({
isGroup,
msgId: messageId,
senderUin,
peerUin,
uuid: cached.fileId || '',
md5Hex: cached.md5Hex ?? '',
duration: cached.duration ?? 0,
size: cached.fileSize ?? 0,
format: cached.voiceFormat ?? 0,
}),
);
return { text };
}
export async function getImageInfo(
mediaStore: MediaStore,
file: string,
imageUrlResolver?: ImageUrlResolver | null,
): Promise<JsonObject | null> {
const cached = await mediaStore.findImage(file);
if (!cached) {
return null;
}
let url = cached.url || cached.imageUrl || '';
try {
const fresh = await resolveImageUrl(cached, imageUrlResolver);
if (fresh) {
url = fresh;
}
} catch (err) {
log.warn(
'get_image URL refresh failed: %s',
err instanceof Error ? err.message : String(err),
);
}
return {
file: url || cached.file,
url,
file_size: String(cached.fileSize ?? 0),
file_name: cached.fileName || cached.file,
};
}
export async function getRecordInfo(
bridge: BridgeInterface,
mediaStore: MediaStore,
file: string,
): Promise<JsonObject | null> {
const cached = await mediaStore.findRecord(file);
if (!cached) {
return null;
}
const url = await resolveRemoteMediaUrl(
bridge,
'record',
cached.mediaNode,
cached.isGroup,
cached.sessionId,
);
await mediaStore.updateRecordUrl(file, url);
return {
file: url,
url,
file_size: String(cached.fileSize ?? 0),
file_name: cached.fileName || cached.file,
};
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,69 @@
interface Pending {
resolve(text: string): void;
reject(error: Error): void;
}
const pending = new Map<string, Pending>();
export function pttTransKey(selfUin: number, msgId: number | bigint): string {
if (typeof msgId === 'number' && !Number.isSafeInteger(msgId)) {
throw new RangeError('PTT message id must be a safe integer or bigint');
}
return selfUin + ':' + BigInt.asIntN(64, BigInt(msgId));
}
/** Register before triggering; only this request may settle its own response or timeout. */
export function requestPttTransText(
key: string,
timeoutMs: number,
trigger: () => Promise<string>,
): Promise<string> {
pending.get(key)?.reject(new Error('语音转文字请求被新的请求取代'));
return new Promise<string>((resolve, reject) => {
const settle = (finish: () => void) => {
if (pending.get(key) !== waiter) {
return;
}
clearTimeout(timer);
pending.delete(key);
finish();
};
const waiter: Pending = {
resolve: (text) => settle(() => resolve(text)),
reject: (error) => settle(() => reject(error)),
};
const timer = setTimeout(() => {
waiter.reject(new Error('语音转文字超时(未收到结果推送)'));
}, timeoutMs);
timer.unref();
pending.set(key, waiter);
void Promise.resolve()
.then(trigger)
.then(
(text) => {
if (text) {
waiter.resolve(text);
}
},
(error: unknown) =>
waiter.reject(error instanceof Error ? error : new Error(String(error))),
);
});
}
/** QQ push results identify the account and message, not an individual trigger request. */
export function deliverPttTransText(key: string, text: string): boolean {
const waiter = pending.get(key);
if (!waiter) {
return false;
}
waiter.resolve(text);
return true;
}
@@ -0,0 +1,326 @@
import { createLogger } from '@snowluma/common/logger';
import type { BridgeInterface } from '@snowluma/core/bridge-interface';
import {
groupRequestSubType,
isSelfGroupInvitation,
type GroupRequestHandle,
type GroupRequestInfo,
} from '@snowluma/protocol/qq-info';
const log = createLogger('OneBot.Request');
type ParsedGroupRequestFlag =
| ({ kind: 'canonical' } & GroupRequestHandle)
| { kind: 'sequence'; sequence: number }
| { kind: 'legacy'; requestType: 'add' | 'invite'; groupId: number; targetUid: string };
function positiveSafeInteger(value: string, field: string): number {
if (!/^\d+$/.test(value)) {
throw new Error(`invalid ${field} in group request flag`);
}
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
throw new Error(`invalid ${field} in group request flag`);
}
return parsed;
}
export function parseGroupRequestFlag(flag: string): ParsedGroupRequestFlag {
if (/^\d+$/.test(flag)) {
return { kind: 'sequence', sequence: positiveSafeInteger(flag, 'sequence') };
}
const parts = flag.split(':');
if (parts[0] === 'slreq') {
if (parts.length !== 6 || parts[1] !== '1') {
throw new Error('unsupported canonical group request flag');
}
if (parts[5] !== '0' && parts[5] !== '1') {
throw new Error('invalid filtered value in group request flag');
}
return {
kind: 'canonical',
sequence: positiveSafeInteger(parts[2], 'sequence'),
groupId: positiveSafeInteger(parts[3], 'group_id'),
eventType: positiveSafeInteger(parts[4], 'event_type'),
filtered: parts[5] === '1',
};
}
const requestType = parts[0];
if ((requestType !== 'add' && requestType !== 'invite') || parts.length < 3) {
throw new Error('invalid group request flag');
}
const groupId = positiveSafeInteger(parts[1], 'group_id');
const targetUid = parts.slice(2).join(':');
if (!targetUid) {
throw new Error('invalid request target in flag');
}
return { kind: 'legacy', requestType, groupId, targetUid };
}
interface RequestInboxLookup {
requests: GroupRequestInfo[];
failures: unknown[];
}
async function fetchRequestInboxes(
bridge: BridgeInterface,
identityForm: 'uin' | 'uid',
): Promise<RequestInboxLookup> {
const [main, filtered] = await Promise.allSettled([
identityForm === 'uin'
? bridge.apis.contacts.fetchGroupRequests(false, 100)
: bridge.apis.contacts.fetchGroupRequestsByUid(false, 100),
identityForm === 'uin'
? bridge.apis.contacts.fetchGroupRequests(true, 100)
: bridge.apis.contacts.fetchGroupRequestsByUid(true, 100),
]);
if (main.status === 'rejected') {
log.warn(
'failed to fetch main group-request inbox: %s',
main.reason instanceof Error ? main.reason.message : String(main.reason),
);
}
if (filtered.status === 'rejected') {
log.warn(
'failed to fetch filtered group-request inbox: %s',
filtered.reason instanceof Error ? filtered.reason.message : String(filtered.reason),
);
}
const failures = [main, filtered]
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
.map((result) => result.reason);
if (failures.length === 2) {
throw new AggregateError(failures, 'failed to fetch group requests from both inboxes');
}
return {
requests: [
...(main.status === 'fulfilled' ? main.value : []),
...(filtered.status === 'fulfilled' ? filtered.value : []),
],
failures,
};
}
function throwMissingGroupRequest(message: string, failures: readonly unknown[]): never {
if (failures.length > 0) {
throw new AggregateError(failures, `group request lookup incomplete: ${message}`);
}
throw new Error(message);
}
async function resolveLiveGroupRequest(
bridge: BridgeInterface,
handle: GroupRequestHandle,
): Promise<GroupRequestHandle & { operateTransInfo?: Uint8Array }> {
const cardGroup = bridge.apis.contacts.findGroupInviteCardGroupBySequence?.(
handle.sequence,
handle.groupId,
);
// Private invite-card seq is absent from 0x10C0; keep the flag tuple (#125).
if (cardGroup === handle.groupId) {
return handle;
}
const lookup = await fetchRequestInboxes(bridge, 'uin');
const live = lookup.requests.find(
(request) => request.groupId === handle.groupId && request.sequence === handle.sequence,
);
if (!live) {
return handle;
}
return {
sequence: live.sequence,
groupId: live.groupId,
eventType: live.eventType,
filtered: live.filtered,
operateTransInfo: live.operateTransInfo,
};
}
async function applyGroupRequest(
bridge: BridgeInterface,
handle: GroupRequestHandle,
approve: boolean,
reason: string,
knownLive?: GroupRequestInfo,
): Promise<void> {
const live = knownLive ?? (await resolveLiveGroupRequest(bridge, handle));
if (!groupRequestSubType(live)) {
throw new Error(
`unsupported group request type: group=${live.groupId} sequence=${live.sequence} type=${live.eventType}`,
);
}
log.debug(
'handling group request: group=%d sequence=%d eventType=%d filtered=%s approve=%s',
live.groupId,
live.sequence,
live.eventType,
live.filtered,
approve,
);
await bridge.apis.groupAdmin.setAddRequest(
live.groupId,
live.sequence,
live.eventType,
approve,
reason,
live.filtered,
live.operateTransInfo,
);
}
export async function handleGroupAddRequest(
bridge: BridgeInterface,
flag: string,
approve: boolean,
reason: string,
): Promise<void> {
const parsed = parseGroupRequestFlag(flag);
if (parsed.kind === 'canonical') {
await applyGroupRequest(bridge, parsed, approve, reason);
return;
}
if (parsed.kind === 'sequence') {
// Private "qun.invite" cards use their Ark msgseq and never appear under
// that sequence in 0x10C0. Resolve the reverse card cache before inboxes.
const cardGroupId = bridge.apis.contacts.findGroupInviteCardGroupBySequence(
parsed.sequence,
);
if (cardGroupId) {
await applyGroupRequest(
bridge,
{
groupId: cardGroupId,
sequence: parsed.sequence,
eventType: 2,
filtered: false,
},
approve,
reason,
);
return;
}
const lookup = await fetchRequestInboxes(bridge, 'uin');
const matching = lookup.requests.find((request) => request.sequence === parsed.sequence);
if (!matching) {
throwMissingGroupRequest(
`group request sequence ${parsed.sequence} not found`,
lookup.failures,
);
}
await applyGroupRequest(bridge, matching, approve, reason, matching);
return;
}
// Legacy SnowLuma flags remain accepted for events emitted by older builds.
// A private invite card needs its msgseq rather than the 0x10C0 tuple (#125).
const notice =
parsed.requestType === 'invite'
? bridge.apis.contacts.groupInvites.getSelfInviteNotice(
parsed.groupId,
parsed.targetUid,
)
: undefined;
if (notice?.cardSequence) {
await applyGroupRequest(
bridge,
{
groupId: parsed.groupId,
sequence: notice.cardSequence,
eventType: 2,
filtered: false,
},
approve,
reason,
);
return;
}
const lookup = await fetchRequestInboxes(bridge, 'uid');
const candidates = lookup.requests.filter((request) => {
if (
request.groupId !== parsed.groupId ||
request.state !== 1 ||
groupRequestSubType(request) !== parsed.requestType
) {
return false;
}
if (
notice &&
(!isSelfGroupInvitation(request) ||
(request.targetUin > 0 && request.targetUin !== Number(bridge.identity.uin)) ||
(request.targetUid &&
bridge.identity.selfUid &&
request.targetUid !== bridge.identity.selfUid))
) {
return false;
}
return parsed.requestType === 'add'
? request.targetUid === parsed.targetUid
: request.invitorUid === parsed.targetUid;
});
const matching = candidates[0];
if (!matching) {
log.warn(
'group request match failed: group=%d kind=%s selfInvitation=%s inboxTypes=%s',
parsed.groupId,
parsed.requestType,
Boolean(notice),
lookup.requests
.filter((request) => request.groupId === parsed.groupId)
.map(
(request) =>
`${request.notifyType ?? '?'}:${request.eventType}:${request.state}`,
)
.join(','),
);
throwMissingGroupRequest('matching group request not found', lookup.failures);
}
if (
candidates.some(
(request) =>
request.sequence !== matching.sequence ||
request.eventType !== matching.eventType ||
request.filtered !== matching.filtered,
)
) {
throw new Error('multiple pending group requests match this flag');
}
await applyGroupRequest(bridge, matching, approve, reason, matching);
}
@@ -0,0 +1,95 @@
import type { JsonObject, JsonValue } from '../types';
/**
* True iff `message` matches the given trigger using exact match.
*
* Only a single text segment (or a bare string) is accepted — mixed-segment
* messages (e.g. `#sl` + image) never match.
*/
export function matchesStatusCommand(message: JsonValue | undefined, trigger: string): boolean {
if (typeof message === 'string') {
return normalize(message) === normalize(trigger);
}
if (!Array.isArray(message) || message.length !== 1) {
return false;
}
const seg = message[0];
if (!isObject(seg) || seg.type !== 'text') {
return false;
}
const data = isObject(seg.data) ? seg.data : null;
const text = data && typeof data.text === 'string' ? data.text : '';
return normalize(text) === normalize(trigger);
}
function normalize(s: string): string {
return s.trim().toLowerCase();
}
function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Whether a reply is allowed now given the last reply time + cooldown.
* `cooldownSeconds <= 0` disables the cooldown entirely.
*/
export function statusCooldownElapsed(
lastRepliedAtMs: number | undefined,
nowMs: number,
cooldownSeconds: number,
): boolean {
if (lastRepliedAtMs === undefined) {
return true;
}
if (cooldownSeconds <= 0) {
return true;
}
return nowMs - lastRepliedAtMs >= cooldownSeconds * 1000;
}
export interface StatusInfo {
version: string;
platform: string;
arch: string;
uptimeMs: number;
}
/** Render the status reply. */
export function buildStatusText(info: StatusInfo): string {
return [
'SnowLuma 状态',
`版本: ${info.version}`,
`平台: ${info.platform}-${info.arch}`,
`运行时长: ${formatUptime(info.uptimeMs)}`,
].join('\n');
}
/** Human-readable uptime (zh-CN), dropping leading zero units. */
export function formatUptime(ms: number): string {
const totalSec = Math.max(0, Math.floor(ms / 1000));
const days = Math.floor(totalSec / 86400);
const hours = Math.floor((totalSec % 86400) / 3600);
const minutes = Math.floor((totalSec % 3600) / 60);
const seconds = totalSec % 60;
if (days > 0) {
return `${days}天 ${hours}小时 ${minutes}分钟`;
}
if (hours > 0) {
return `${hours}小时 ${minutes}分钟`;
}
if (minutes > 0) {
return `${minutes}分钟 ${seconds}秒`;
}
return `${seconds}秒`;
}
+223
View File
@@ -0,0 +1,223 @@
import { errorMessage } from '@snowluma/common/errors';
import { createLogger, type Logger } from '@snowluma/common/logger';
import type { ApiHandler } from '../api-handler';
import {
shapeEventForAdapter,
type DispatchPayload,
type EventReportOptions,
} from '../event-filter';
import type { JsonObject, NetworkBase } from '../types';
export interface NetworkAdapterContext {
uin: string;
api: ApiHandler;
buildLifecycleEvent(subType: 'connect' | 'enable' | 'disable'): JsonObject;
buildHeartbeatEvent(): JsonObject;
}
export enum NetworkReloadType {
Normal = 0,
Reopened = 1,
Closed = 2,
Opened = 3,
}
export type AdapterStatusLevel = 'ok' | 'warn' | 'down' | 'disabled' | 'degraded';
/** Live runtime status of a single network adapter, surfaced to the WebUI
* dashboard (and the per-node config cards) so the gateway's own
* connection health is visible. `detail` is a short human string, e.g.
* "3 个客户端" / "重连中" / "上次推送失败 14:53:01". */
export interface AdapterStatus {
name: string;
kind: 'httpServer' | 'httpClient' | 'wsServer' | 'wsClient';
status: AdapterStatusLevel;
detail: string;
/** Present when the desired config could not be applied or the bound
* transport later failed. The adapter may still be serving its restored
* previous config; `degraded` describes reconciliation, not only liveness. */
lastError?: string;
lastErrorAt?: number;
}
export abstract class IOneBotNetworkAdapter<C extends NetworkBase> {
readonly name: string;
protected config: C;
protected readonly ctx: NetworkAdapterContext;
protected readonly log: Logger;
protected isEnabled = false;
private applyFailure: { message: string; at: number } | null = null;
constructor(name: string, config: C, ctx: NetworkAdapterContext, moduleLog?: Logger) {
this.name = name;
this.config = structuredClone(config);
this.ctx = ctx;
const base = moduleLog ?? createLogger('OneBot.Network');
const uinNum = Number.parseInt(ctx.uin, 10);
this.log = Number.isFinite(uinNum) && uinNum > 0 ? base.child({ uin: uinNum }) : base;
}
get isActive(): boolean {
return this.isEnabled;
}
get currentConfig(): Readonly<C> {
return this.config;
}
get hasApplyFailure(): boolean {
return this.applyFailure !== null;
}
abstract open(): void | Promise<void>;
abstract close(): void | Promise<void>;
/** Drop sockets and listeners without waiting for a peer handshake.
* Graceful `close()` can sit forever on a half-open connection; shutdown
* uses this so the next account session can bind the same ports. */
forceClose(): void {
/* adapters that own a listener override this */
}
abstract onEvent(event: JsonObject, payload: DispatchPayload): void | Promise<void>;
/** Report live connection health for the WebUI dashboard. */
abstract describeStatus(): AdapterStatus;
/** Status with the manager-owned desired/live mismatch overlaid. Concrete
* adapters continue to describe transport health; reconciliation failures
* are kept here so a successfully restored old binding is still visible. */
describeManagedStatus(): AdapterStatus {
const status = this.describeStatus();
if (!this.applyFailure) {
return status;
}
return {
...status,
status: 'degraded',
detail: `应用失败:${this.applyFailure.message}`,
lastError: this.applyFailure.message,
lastErrorAt: this.applyFailure.at,
};
}
/** NetworkManager lifecycle seam. A failure is cleared only after a later
* reconciliation reaches the desired state. */
markApplyFailure(error: unknown, at = Date.now()): void {
this.applyFailure = { message: errorMessage(error), at };
}
clearApplyFailure(): void {
this.applyFailure = null;
}
protected recordTransportFailure(error: unknown, at = Date.now()): void {
this.markApplyFailure(error, at);
}
// ── meta-event framing: shared by the connection-oriented adapters ─────
//
// Lifecycle (connect/enable/disable) and heartbeat meta events don't flow
// through `OneBotNetworkManager.emitEvent` (no pre-serialized DispatchPayload),
// so each connection-oriented adapter shapes them per its report options and
// serializes on the spot. The shape+serialize policy lives here once; the
// adapters only own the transport (which socket(s) to `safeSend` to).
/** Shape one meta event for `options` and serialize it to a wire frame, or
* `null` when the shaper drops it (e.g. a self-message under
* `reportSelfMessage:false`). */
protected metaFrame(event: JsonObject, options: EventReportOptions): string | null {
const shaped = shapeEventForAdapter(event, options);
return shaped === null ? null : JSON.stringify(shaped);
}
/** The bootstrap meta frames an event client receives on (re)connect:
* `connect`, `enable`, then a `heartbeat`, shaped for `options`. Frames the
* shaper drops are omitted. */
protected bootstrapMetaFrames(options: EventReportOptions): string[] {
const events = [
this.ctx.buildLifecycleEvent('connect'),
this.ctx.buildLifecycleEvent('enable'),
this.ctx.buildHeartbeatEvent(),
];
const frames: string[] = [];
for (const event of events) {
const frame = this.metaFrame(event, options);
if (frame !== null) {
frames.push(frame);
}
}
return frames;
}
// ── reload: shared hot-reload state machine (template method) ──────────
//
// All four adapters reconcile a live config swap identically: decide
// whether the binding changed, whether the adapter should be enabled, and
// then open/close to reach the target state. The skeleton lives here; the
// three things that genuinely differ per adapter are the hooks below.
//
// NOTE: the returned `NetworkReloadType` is a *test-observability* seam.
// OneBotNetworkManager awaits the transition but derives apply truth from
// success/failure rather than this label. Tests use it to assert "which
// transition did this config cause" without poking private state.
// Behaviour, not the label, is the contract.
/** Stable string identity of the bound resource (host:port / url / token …).
* A change here means the live binding must be torn down and re-opened. */
protected abstract bindingSignature(config: C): string;
/** Whether `config` should result in an enabled adapter. Defaults to the
* `enabled` flag; client adapters also require a target `url`. */
protected willEnable(config: C): boolean {
return config.enabled !== false;
}
/** Refresh derived state after `this.config` has been replaced (report
* options, role, propagation to live connections). Default: nothing. */
protected onConfigReplaced(_next: C): void {
/* no-op */
}
async reload(next: C): Promise<NetworkReloadType> {
const prevSig = this.bindingSignature(this.config);
const wasEnabled = this.isEnabled;
const willEnable = this.willEnable(next);
const sigChanged = prevSig !== this.bindingSignature(next);
// Keep currentConfig truthful while the old live resource is being
// released. Only publish the desired config after close succeeds; an
// opening adapter necessarily sees the new config, while a close failure
// leaves the previous config intact for recovery and diagnostics.
if (wasEnabled && (sigChanged || !willEnable)) {
await this.close();
this.replaceConfig(next);
if (willEnable) {
await this.open();
return NetworkReloadType.Reopened;
}
return NetworkReloadType.Closed;
}
this.replaceConfig(next);
if (!wasEnabled && willEnable) {
await this.open();
return NetworkReloadType.Opened;
}
return NetworkReloadType.Normal;
}
private replaceConfig(next: C): void {
this.config = structuredClone(next);
this.onConfigReplaced(next);
}
}
@@ -0,0 +1,194 @@
import { createLogger } from '@snowluma/common/logger';
import {
pickDispatchJson,
resolveReportOptions,
type DispatchPayload,
type EventReportOptions,
} from '../event-filter';
import type { HttpClientNetwork, JsonObject } from '../types';
import { IOneBotNetworkAdapter, type AdapterStatus, type NetworkAdapterContext } from './adapter';
import { executeQuickOperation } from './quick-operation';
const moduleLog = createLogger('OneBot.POST');
const DEFAULT_TIMEOUT_MS = 5000;
export class HttpPostAdapter extends IOneBotNetworkAdapter<HttpClientNetwork> {
private options: EventReportOptions;
private lastDelivery: { at: number; ok: boolean } | null = null;
constructor(name: string, config: HttpClientNetwork, ctx: NetworkAdapterContext) {
super(name, config, ctx, moduleLog);
this.options = resolveReportOptions(config);
}
open(): void {
if (this.config.enabled === false) {
return;
}
if (!this.config.url) {
return;
}
this.isEnabled = true;
}
close(): void {
this.isEnabled = false;
}
override describeStatus(): AdapterStatus {
if (!this.isEnabled) {
return { name: this.name, kind: 'httpClient', status: 'disabled', detail: '未启用' };
}
if (!this.lastDelivery) {
return { name: this.name, kind: 'httpClient', status: 'ok', detail: '已启用' };
}
const at = new Date(this.lastDelivery.at).toTimeString().slice(0, 8);
return this.lastDelivery.ok
? { name: this.name, kind: 'httpClient', status: 'ok', detail: `上次推送成功 ${at}` }
: { name: this.name, kind: 'httpClient', status: 'warn', detail: `上次推送失败 ${at}` };
}
protected override bindingSignature(config: HttpClientNetwork): string {
return `${config.url}#${config.accessToken ?? ''}#${config.timeoutMs ?? DEFAULT_TIMEOUT_MS}`;
}
protected override willEnable(config: HttpClientNetwork): boolean {
return config.enabled !== false && !!config.url;
}
protected override onConfigReplaced(next: HttpClientNetwork): void {
this.options = resolveReportOptions(next);
}
async onEvent(event: JsonObject, payload: DispatchPayload): Promise<void> {
if (!this.isEnabled) {
return;
}
const json = pickDispatchJson(payload, this.options);
if (json === null) {
return;
}
// A reconcile may replace this.config while HMAC computation or fetch is
// awaiting. Keep one event on one immutable config epoch so an old token
// can never sign a request sent to a new URL (or vice versa).
const config = structuredClone(this.config);
await this.postEvent(json, event, config);
}
private async postEvent(
payload: string,
event: JsonObject,
config: HttpClientNetwork,
): Promise<void> {
if (!this.isEnabled) {
return;
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': 'OneBot',
'X-Self-ID': this.ctx.uin,
};
if (config.accessToken) {
headers['X-Signature'] = await computeHmacSha1(config.accessToken, payload);
}
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const startedAt = Date.now();
let response: Response;
try {
response = await fetch(config.url, {
method: 'POST',
headers,
body: payload,
signal: AbortSignal.timeout(timeoutMs),
});
} catch (error) {
this.lastDelivery = { at: Date.now(), ok: false };
this.log.warn(
'[%s] POST %s failed: %s',
this.name,
config.url,
error instanceof Error ? error.message : String(error),
);
this.log.trace(() => [
'http_report_terminal target=%s outcome=transport_failed ms=%d error=%s',
this.name,
Date.now() - startedAt,
error instanceof Error ? (error.stack ?? error.message) : String(error),
]);
return;
}
if (!response.ok) {
this.lastDelivery = { at: Date.now(), ok: false };
this.log.warn('[%s] POST %s returned %d', this.name, config.url, response.status);
this.log.trace(
'http_report_terminal target=%s outcome=rejected status=%d ms=%d',
this.name,
response.status,
Date.now() - startedAt,
);
return;
}
this.lastDelivery = { at: Date.now(), ok: true };
this.log.trace(
'http_report_terminal target=%s outcome=accepted status=%d ms=%d',
this.name,
response.status,
Date.now() - startedAt,
);
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
return;
}
try {
const body = await response.text();
if (body.trim()) {
await this.handleQuickOperation(event, body);
}
} catch (error) {
this.log.warn(
'[%s] quick operation response read failed: %s',
this.name,
error instanceof Error ? error.message : String(error),
);
}
}
private async handleQuickOperation(event: JsonObject, responseBody: string): Promise<void> {
try {
const operation = JSON.parse(responseBody) as Record<string, unknown>;
if (!operation || typeof operation !== 'object') {
return;
}
await executeQuickOperation(event, operation, this.ctx.api);
} catch (error) {
this.log.warn(
'[%s] quick operation failed: %s',
this.name,
error instanceof Error ? error.message : String(error),
);
}
}
}
async function computeHmacSha1(secret: string, payload: string): Promise<string> {
const { createHmac } = await import('crypto');
return 'sha1=' + createHmac('sha1', secret).update(payload).digest('hex');
}
@@ -0,0 +1,770 @@
import { createLogger, runWithTraceRequest } from '@snowluma/common/logger';
import { WebSocketServer } from '@snowluma/websocket';
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http';
import type { ApiHandler } from '../api-handler';
import { resolveReportOptions, type DispatchPayload } from '../event-filter';
import type { ApiResponse, HttpServerNetwork, JsonObject, JsonValue } from '../types';
import {
StreamTransportClosedError,
type StreamSink,
wrapStreamFrame,
wrapStreamTerminal,
} from '../streaming';
import { IOneBotNetworkAdapter, type AdapterStatus, type NetworkAdapterContext } from './adapter';
import { isAuthorized, normalizePath } from './utils';
import { WsServerConnections } from './ws-server-connections';
import { httpTraceparent } from './http-trace';
import { HttpSseConnections } from './http-sse-connections';
const moduleLog = createLogger('OneBot.HTTP');
export class HttpServerAdapter extends IOneBotNetworkAdapter<HttpServerNetwork> {
private server: Server | null = null;
private webSocketServer: WebSocketServer | null = null;
private listening = false;
private releaseForced = false;
private closePromise: Promise<void> | null = null;
private acceptingActions = false;
private readonly inFlightActions = new Set<Promise<void>>();
private readonly webSocketConnections: WsServerConnections;
private readonly sseConnections: HttpSseConnections;
constructor(name: string, config: HttpServerNetwork, ctx: NetworkAdapterContext) {
super(name, config, ctx, moduleLog);
this.sseConnections = new HttpSseConnections(config, this.log, () =>
this.bootstrapMetaFrames(resolveReportOptions(this.config)),
);
this.webSocketConnections = new WsServerConnections(name, config, ctx, this.log, {
frame: (event, options) => this.metaFrame(event, options),
bootstrap: (options) => this.bootstrapMetaFrames(options),
});
}
async open(): Promise<void> {
if (this.isEnabled && this.listening) {
return;
}
if (this.config.enabled === false) {
return;
}
if (this.server) {
throw new Error(`HTTP adapter [${this.name}] still owns a previous server`);
}
if (this.webSocketServer) {
throw new Error(
`HTTP adapter [${this.name}] still owns a previous WebSocket upgrade handler`,
);
}
await this.startServer();
this.isEnabled = true;
this.clearApplyFailure();
}
async close(): Promise<void> {
if (this.closePromise) {
return this.closePromise;
}
if (
!this.isEnabled &&
!this.server &&
!this.webSocketServer &&
this.inFlightActions.size === 0 &&
!this.webSocketConnections.hasInFlightActions
) {
return;
}
const wasEnabled = this.isEnabled;
const wasListening = this.listening;
const wasAcceptingActions = this.acceptingActions;
const wasAcceptingWebSocketActions = this.webSocketConnections.isAcceptingActions;
this.acceptingActions = false;
this.webSocketConnections.stopAccepting();
this.sseConnections.close('server closing');
this.isEnabled = false;
this.listening = false;
const server = this.server;
const webSocketServer = this.webSocketServer;
const webSocketDrain = webSocketServer
? this.webSocketConnections.closeConnections()
: Promise.resolve();
const releaseHttp = server
? new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error && !isAlreadyClosedError(error)) {
reject(error);
} else {
resolve();
}
});
})
: Promise.resolve();
let httpReleased = false;
const attempt = (async () => {
// Close upgraded sockets so Node can release the owning HTTP listener,
// but keep the upgrade handler attached until that release succeeds.
// If HTTP close fails while the listener is still live, accepting can be
// restored on the same handler; no partially released resource is
// reported as healthy.
await Promise.all([releaseHttp, Promise.all(this.inFlightActions), webSocketDrain]);
httpReleased = true;
if (this.server === server) {
this.server = null;
}
if (webSocketServer) {
const releaseWebSocket = new Promise<void>((resolve, reject) => {
webSocketServer.close((error) => {
if (error && !isAlreadyClosedError(error)) {
reject(error);
} else {
resolve();
}
});
});
await Promise.all([releaseWebSocket, this.webSocketConnections.closeConnections()]);
if (this.webSocketServer === webSocketServer) {
this.webSocketServer = null;
}
}
})();
this.closePromise = attempt;
try {
await attempt;
} catch (error) {
if (this.releaseForced) {
return;
}
if (!httpReleased && server?.listening === true) {
// A failed HTTP close callback cannot prove the listener was released.
// The upgrade handler is still attached, so the old ingress can accept
// fresh connections again while a later shutdown/reconcile retries.
this.isEnabled = wasEnabled;
this.listening = wasListening;
this.acceptingActions = wasAcceptingActions;
if (wasAcceptingWebSocketActions) {
this.webSocketConnections.startAccepting();
}
}
throw error;
} finally {
this.closePromise = null;
}
}
override forceClose(): void {
this.releaseForced = true;
this.acceptingActions = false;
this.webSocketConnections.stopAccepting();
this.sseConnections.close('server closing');
this.isEnabled = false;
this.listening = false;
this.webSocketConnections.terminateAll();
const webSocketServer = this.webSocketServer;
this.webSocketServer = null;
const server = this.server;
this.server = null;
if (webSocketServer) {
try {
webSocketServer.destroy();
} catch {
/* already dropping */
}
}
if (server) {
try {
server.closeAllConnections();
} catch {
/* already closed */
}
try {
server.close(() => undefined);
} catch {
/* already closed */
}
}
}
override describeStatus(): AdapterStatus {
if (!this.isEnabled) {
return { name: this.name, kind: 'httpServer', status: 'disabled', detail: '未启用' };
}
if (!this.listening) {
return {
name: this.name,
kind: 'httpServer',
status: 'down',
detail: '未监听(端口被占用?)',
};
}
let detail =
this.config.enableWebSocket === true
? `监听中 · WebSocket ${this.webSocketConnections.connectionCount} 个客户端`
: '监听中';
if (this.config.enableSse === true) {
detail += ` · SSE ${this.sseConnections.connectionCount} 个客户端`;
}
return { name: this.name, kind: 'httpServer', status: 'ok', detail };
}
protected override bindingSignature(config: HttpServerNetwork): string {
const webSocket = config.enableWebSocket === true ? 'ws' : 'http';
// The frame cap is fixed when the WebSocketServer is constructed, so a
// change must rebind instead of silently keeping the old limit — but only
// when this adapter actually owns a WebSocket transport, otherwise the
// HTTP listener would be torn down for a value it never uses.
const maxPayload = config.enableWebSocket === true ? (config.maxPayloadBytes ?? '') : '';
return `${config.host ?? '0.0.0.0'}:${config.port}${normalizePath(config.path ?? '/')}#${webSocket}#${maxPayload}`;
}
protected override onConfigReplaced(next: HttpServerNetwork): void {
this.webSocketConnections.updateConfig(next);
this.sseConnections.updateConfig(next);
}
onEvent(_event: JsonObject, payload: DispatchPayload): void {
if (!this.isEnabled) {
return;
}
if (this.config.enableWebSocket === true) {
this.webSocketConnections.onEvent(payload);
}
if (this.config.enableSse === true) {
this.sseConnections.onEvent(payload);
}
}
private startServer(): Promise<void> {
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
this.trackInboundAction(req, res);
});
this.server = server;
let webSocketServer: WebSocketServer | null = null;
try {
if (this.config.enableWebSocket === true) {
webSocketServer = new WebSocketServer({
server,
maxPayload: this.config.maxPayloadBytes,
verifyClient: ({ req }: { req: IncomingMessage }) =>
this.webSocketConnections.authorizeUpgrade(req),
});
webSocketServer.on('connection', (socket, request) => {
this.webSocketConnections.accept(socket, request);
});
webSocketServer.on('error', (error: Error) => {
this.recordTransportFailure(error);
this.log.error(
'[%s] embedded WebSocket error: %s',
this.name,
error instanceof Error ? error.message : String(error),
);
});
this.webSocketServer = webSocketServer;
}
} catch (error) {
this.server = null;
this.recordTransportFailure(error);
reject(error);
return;
}
let opening = true;
server.once('listening', () => {
opening = false;
if (this.server !== server || this.closePromise) {
webSocketServer?.close();
server.close();
reject(new Error(`HTTP adapter [${this.name}] was closed while binding`));
return;
}
this.listening = true;
this.isEnabled = true;
this.acceptingActions = true;
if (webSocketServer) {
this.webSocketConnections.startAccepting();
}
this.log.success(
'[%s] listening %s:%d%s%s',
this.name,
this.config.host ?? '0.0.0.0',
this.config.port,
normalizePath(this.config.path ?? '/'),
webSocketServer ? ' with WebSocket' : '',
);
resolve();
});
server.on('error', (error) => {
if (this.server === server) {
this.listening = false;
this.isEnabled = false;
this.acceptingActions = false;
this.webSocketConnections.stopAccepting();
this.sseConnections.close('server closing');
this.recordTransportFailure(error);
if (opening) {
this.server = null;
if (this.webSocketServer === webSocketServer) {
this.webSocketServer = null;
}
webSocketServer?.close();
}
}
this.log.error(
'[%s] server error: %s',
this.name,
error instanceof Error ? error.message : String(error),
);
if (opening) {
opening = false;
reject(error);
}
});
try {
server.listen(this.config.port, this.config.host ?? '0.0.0.0');
} catch (error) {
opening = false;
if (this.server === server) {
this.server = null;
}
if (this.webSocketServer === webSocketServer) {
this.webSocketServer = null;
}
webSocketServer?.close();
this.recordTransportFailure(error);
reject(error);
}
});
}
private handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
return runWithTraceRequest(() => this.handleRequestInContext(req, res));
}
private async handleRequestInContext(req: IncomingMessage, res: ServerResponse): Promise<void> {
const apiQuiescedAtIngress = this.ctx.api.isAcceptingActions === false;
if (apiQuiescedAtIngress) {
writeJson(res, 503, serverClosingResponse());
}
const respond = (statusCode: number, data: unknown): void => {
if (apiQuiescedAtIngress) {
return;
}
writeJson(res, statusCode, data);
};
const expectedPath = normalizePath(this.config.path ?? '/');
const accessToken = this.config.accessToken ?? '';
const parsedUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
const incomingPath = parsedUrl.pathname;
const ep = expectedPath.endsWith('/') ? expectedPath : expectedPath + '/';
let action = '';
if (incomingPath === expectedPath || incomingPath === expectedPath + '/') {
action = '';
} else if (incomingPath.startsWith(ep)) {
action = incomingPath.substring(ep.length);
} else {
respond(404, { status: 'failed', retcode: 1404, data: null, wording: 'not found' });
return;
}
if (!isAuthorized(req, accessToken, true)) {
respond(401, { status: 'failed', retcode: 1401, data: null, wording: 'unauthorized' });
return;
}
if (action === '_events') {
if (apiQuiescedAtIngress) {
return;
}
if (this.config.enableSse !== true) {
respond(404, {
status: 'failed',
retcode: 1404,
data: null,
wording: 'SSE is disabled',
});
} else if (req.method !== 'GET') {
res.setHeader('Allow', 'GET');
respond(405, {
status: 'failed',
retcode: 1405,
data: null,
wording: 'SSE requires GET',
});
} else {
this.sseConnections.accept(req, res);
}
return;
}
if (req.headers.traceparent !== undefined) {
const traceparent = httpTraceparent(req.headers.traceparent);
if (traceparent) {
this.log.trace('http_trace traceparent=%s', traceparent);
} else {
this.log.trace('http_trace ignored invalid traceparent');
}
}
if (req.method === 'GET' && !action) {
respond(200, { status: 'ok', retcode: 0, data: { online: true } });
return;
}
try {
let params: Record<string, unknown> = {};
let echo: unknown;
if (req.method === 'GET') {
parsedUrl.searchParams.forEach((value, key) => {
try {
params[key] = JSON.parse(value);
} catch {
params[key] = value;
}
});
} else if (req.method === 'POST') {
const bodyContent = await readRequestBody(req);
if (bodyContent.trim()) {
const contentType = req.headers['content-type'] ?? '';
if (contentType.includes('application/x-www-form-urlencoded')) {
const parsed = new URLSearchParams(bodyContent);
parsed.forEach((value, key) => {
try {
params[key] = JSON.parse(value);
} catch {
params[key] = value;
}
});
if (params.action && !action) {
action = String(params.action);
delete params.action;
}
if (params.echo !== undefined) {
echo = params.echo;
delete params.echo;
}
} else if (contentType.includes('application/json') || !contentType) {
try {
const parsedBody = JSON.parse(bodyContent);
if (
typeof parsedBody === 'object' &&
parsedBody !== null &&
!Array.isArray(parsedBody)
) {
if (parsedBody.action && !action) {
action = String(parsedBody.action);
}
if (
parsedBody.params &&
typeof parsedBody.params === 'object' &&
!Array.isArray(parsedBody.params)
) {
params = parsedBody.params as Record<string, unknown>;
} else {
params = parsedBody as Record<string, unknown>;
}
echo = parsedBody.echo;
}
} catch {
if (contentType.includes('application/json')) {
respond(400, {
status: 'failed',
retcode: 1400,
data: null,
wording: 'bad request: invalid json',
});
return;
}
// 无 content-type,JSON 失败则 fallback 到 urlencoded
const parsed = new URLSearchParams(bodyContent);
parsed.forEach((value, key) => {
try {
params[key] = JSON.parse(value);
} catch {
params[key] = value;
}
});
if (params.action && !action) {
action = String(params.action);
delete params.action;
}
if (params.echo !== undefined) {
echo = params.echo;
delete params.echo;
}
}
} else {
respond(400, {
status: 'failed',
retcode: 1400,
data: null,
wording: `bad request: unsupported content-type: ${contentType}`,
});
return;
}
}
} else {
respond(405, {
status: 'failed',
retcode: 1400,
data: null,
wording: 'method not allowed',
});
return;
}
if (!action) {
respond(400, {
status: 'failed',
retcode: 1400,
data: null,
wording: 'bad request: missing action',
});
return;
}
if (apiQuiescedAtIngress) {
const response = serverClosingResponse();
this.ctx.api.traceQuiescedAction(action, params as JsonObject, response);
respond(503, response);
return;
}
// Stream API (#163): answer with chunked multi-frame output — each frame
// a JSON envelope delimited by `\r\n\r\n`, terminated by the final frame.
if (this.ctx.api.isStreamAction(action)) {
await streamHttpResponse(
res,
this.ctx.api,
action,
params as JsonObject,
echo as JsonValue | undefined,
);
return;
}
const response = await this.ctx.api.handle(action, params as JsonObject);
if (echo !== undefined) {
response.echo = echo as JsonValue;
}
respond(200, response);
} catch (error) {
const wording = error instanceof Error ? error.message : 'internal error';
const tooLarge = error instanceof RequestBodyTooLargeError;
respond(tooLarge ? 413 : 500, {
status: 'failed',
retcode: tooLarge ? 1413 : 1200,
data: null,
wording,
});
}
}
private trackInboundAction(req: IncomingMessage, res: ServerResponse): void {
if (!this.acceptingActions) {
writeJson(res, 503, serverClosingResponse());
return;
}
if (this.ctx.api.isAcceptingActions === false) {
void this.handleRequest(req, res).catch((error) => {
this.log.warn(
'[%s] quiesced inbound action trace failed: %s',
this.name,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
});
return;
}
let action: Promise<void>;
try {
action = this.handleRequest(req, res);
} catch (error) {
this.log.error(
'[%s] inbound action start failed: %s',
this.name,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
writeJson(res, 500, {
status: 'failed',
retcode: 1200,
data: null,
wording: 'internal error',
});
return;
}
const tracked = action.then(
() => undefined,
(error) => {
this.log.error(
'[%s] inbound action failed: %s',
this.name,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
},
);
this.inFlightActions.add(tracked);
void tracked.then(() => {
this.inFlightActions.delete(tracked);
});
}
}
function serverClosingResponse(): ApiResponse {
return {
status: 'failed',
retcode: 1200,
data: null,
wording: 'server closing',
};
}
function isAlreadyClosedError(error: Error): boolean {
return (error as NodeJS.ErrnoException).code === 'ERR_SERVER_NOT_RUNNING';
}
class RequestBodyTooLargeError extends Error {}
// Base64 media expands binary size by 4/3, before the JSON envelope.
function readRequestBody(req: IncomingMessage, maxBytes = 64 * 1024 * 1024): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let total = 0;
let exceeded = false;
req.on('data', (chunk: Buffer) => {
if (exceeded) {
return;
}
total += chunk.length;
if (total > maxBytes) {
exceeded = true;
chunks.length = 0;
reject(new RequestBodyTooLargeError('request body too large'));
// Drain without retaining bytes; keep the socket alive for the 413 response.
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
function writeJson(res: ServerResponse, statusCode: number, data: unknown): void {
// A streaming response may already have flushed headers (and even ended); a
// late error must not double-send and trip ERR_HTTP_HEADERS_SENT.
if (res.headersSent || res.writableEnded || res.destroyed) {
return;
}
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(data));
}
/** Stream a multi-frame Stream API response (#163): chunked transfer (no
* Content-Length → Node frames it), each frame a JSON envelope delimited by
* `\r\n\r\n`, the action's terminal response written last. Matches NapCat:
* the body is `\r\n\r\n`-joined JSON objects, NOT a single JSON document, so
* no `application/json` Content-Type is claimed. Each `res.write` is awaited
* (flush callback) for backpressure, and the sink aborts the action once the
* client disconnects so a big download stops pumping into a dead socket. */
async function streamHttpResponse(
res: ServerResponse,
api: ApiHandler,
action: string,
params: JsonObject,
echo: JsonValue | undefined,
): Promise<void> {
res.statusCode = 200;
const writeFrame = (frame: ApiResponse): Promise<void> =>
new Promise((resolve, reject) => {
if (res.writableEnded || res.destroyed) {
reject(new StreamTransportClosedError('stream client disconnected'));
return;
}
res.write(JSON.stringify(frame) + '\r\n\r\n', (error) => {
if (error) {
reject(new StreamTransportClosedError('stream client disconnected'));
return;
}
resolve();
});
});
const sink: StreamSink = {
send: async (frame) => {
await writeFrame(wrapStreamFrame(frame, echo));
},
};
const response = await api.handle(action, params, sink);
await writeFrame(wrapStreamTerminal(response, echo));
if (!res.writableEnded && !res.destroyed) {
res.end();
}
}
@@ -0,0 +1,275 @@
import { randomUUID } from 'node:crypto';
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { Logger } from '@snowluma/common/logger';
import { pickDispatchJson, resolveReportOptions, type DispatchPayload } from '../event-filter';
import type { HttpServerNetwork } from '../types';
const REPLAY_EVENTS = 256;
const REPLAY_BYTES = 8 * 1024 * 1024;
const CLIENT_BYTES = 8 * 1024 * 1024;
const HEARTBEAT_MS = 15_000;
interface Frame {
sequence: number;
text: string;
bytes: number;
}
interface Connection {
response: ServerResponse;
queue: Frame[];
bytes: number;
blocked: boolean;
dispose(): void;
}
/** Per-adapter event history and connections; never shared between accounts. */
export class HttpSseConnections {
private config: HttpServerNetwork;
private epoch = randomUUID();
private sequence = 0;
private droppedThrough = 0;
private history: Frame[] = [];
private historyBytes = 0;
private readonly connections = new Set<Connection>();
private heartbeat: ReturnType<typeof setInterval> | undefined;
constructor(
config: HttpServerNetwork,
private readonly log: Logger,
private readonly bootstrap: () => string[],
) {
this.config = structuredClone(config);
}
get connectionCount(): number {
return this.connections.size;
}
updateConfig(next: HttpServerNetwork): void {
if (
this.config.enableSse !== next.enableSse ||
(this.config.accessToken ?? '') !== (next.accessToken ?? '') ||
(this.config.messageFormat ?? 'array') !== (next.messageFormat ?? 'array') ||
(this.config.reportSelfMessage ?? false) !== (next.reportSelfMessage ?? false)
) {
// Replaying frames shaped under the previous policy could disclose
// events which the new report policy excludes.
this.close('configuration changed');
}
this.config = structuredClone(next);
}
accept(request: IncomingMessage, response: ServerResponse): void {
const cursor = request.headers['last-event-id'];
let after = this.sequence;
if (cursor !== undefined && cursor !== '') {
const match =
typeof cursor === 'string'
? /^([0-9a-f-]{36}):(0|[1-9]\d{0,15})$/.exec(cursor)
: null;
const value = match ? Number(match[2]) : NaN;
if (!match || !Number.isSafeInteger(value)) {
this.rejectCursor(response, 400, 'invalid Last-Event-ID');
return;
}
if (match[1] !== this.epoch || value < this.droppedThrough || value > this.sequence) {
this.rejectCursor(
response,
409,
'SSE replay unavailable; resync and reconnect without Last-Event-ID',
);
return;
}
after = value;
}
response.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
Connection: 'keep-alive',
});
response.flushHeaders();
request.socket.setTimeout(0);
const onDrain = (): void => {
connection.blocked = false;
this.flush(connection);
};
const onError = (error: Error): void => {
this.log.warn('[%s] SSE connection failed: %s', this.config.name, error.message);
this.drop(connection);
};
const onClose = (): void => this.drop(connection);
const connection: Connection = {
response,
queue: [],
bytes: 0,
blocked: false,
dispose: () => {
response.off('drain', onDrain);
response.off('close', onClose);
response.off('error', onError);
},
};
response.on('drain', onDrain);
response.once('close', onClose);
response.on('error', onError);
this.connections.add(connection);
this.log.debug('[%s] SSE connected (%d clients)', this.config.name, this.connectionCount);
this.enqueue(connection, this.frame('retry: 3000\n\n'));
for (const frame of this.history) {
if (frame.sequence > after) {
this.enqueue(connection, frame);
}
}
// Bootstrap supplies a cursor even when there has not been a message.
// Only broadcast events consume sequence numbers and enter history.
for (const json of this.bootstrap()) {
this.enqueue(connection, this.eventFrame(json));
}
if (!this.heartbeat && this.connections.size > 0) {
this.heartbeat = setInterval(() => {
const frame = this.frame(': ping\n\n');
for (const client of this.connections) {
this.enqueue(client, frame);
}
}, HEARTBEAT_MS);
this.heartbeat.unref();
}
}
onEvent(payload: DispatchPayload): void {
const json = pickDispatchJson(payload, resolveReportOptions(this.config));
if (json === null) {
return;
}
this.sequence += 1;
const frame = this.eventFrame(json);
this.history.push(frame);
this.historyBytes += frame.bytes;
while (this.history.length > REPLAY_EVENTS || this.historyBytes > REPLAY_BYTES) {
const removed = this.history.shift()!;
this.historyBytes -= removed.bytes;
this.droppedThrough = removed.sequence;
}
for (const connection of this.connections) {
this.enqueue(connection, frame);
}
}
close(reason: string): void {
if (this.connections.size > 0) {
this.log.debug('[%s] SSE closing: %s', this.config.name, reason);
}
for (const connection of this.connections) {
this.drop(connection);
}
this.history = [];
this.historyBytes = 0;
this.sequence = 0;
this.droppedThrough = 0;
this.epoch = randomUUID();
}
private eventFrame(json: string): Frame {
return this.frame(`id: ${this.epoch}:${this.sequence}\ndata: ${json}\n\n`);
}
private frame(text: string): Frame {
return { sequence: this.sequence, text, bytes: Buffer.byteLength(text) };
}
private enqueue(connection: Connection, frame: Frame): void {
if (!this.connections.has(connection)) {
return;
}
if (connection.bytes + connection.response.writableLength + frame.bytes > CLIENT_BYTES) {
this.log.warn(
'[%s] SSE client exceeded %d buffered bytes; disconnecting',
this.config.name,
CLIENT_BYTES,
);
this.drop(connection);
return;
}
connection.queue.push(frame);
connection.bytes += frame.bytes;
this.flush(connection);
}
private flush(connection: Connection): void {
while (!connection.blocked && connection.queue.length > 0) {
const frame = connection.queue.shift()!;
connection.bytes -= frame.bytes;
try {
connection.blocked = !connection.response.write(frame.text);
} catch (error) {
this.log.warn(
'[%s] SSE write failed: %s',
this.config.name,
error instanceof Error ? error.message : String(error),
);
this.drop(connection);
return;
}
}
}
private drop(connection: Connection): void {
if (!this.connections.delete(connection)) {
return;
}
connection.queue = [];
connection.bytes = 0;
// The response error handler remains until close, including when a
// previously queued write fails during destruction.
if (connection.response.destroyed) {
connection.dispose();
} else {
connection.response.once('close', connection.dispose);
connection.response.destroy();
}
if (this.connections.size === 0 && this.heartbeat) {
clearInterval(this.heartbeat);
this.heartbeat = undefined;
}
}
private rejectCursor(response: ServerResponse, status: number, wording: string): void {
this.log.warn('[%s] rejected SSE reconnect: %s', this.config.name, wording);
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
response.end(
JSON.stringify({
status: 'failed',
retcode: status === 400 ? 1400 : 1409,
data: null,
wording,
}),
);
}
}
+21
View File
@@ -0,0 +1,21 @@
/** Accept W3C traceparent without treating caller-supplied IDs as local request IDs. */
export function httpTraceparent(value: unknown): string | undefined {
if (typeof value !== 'string' || /[^\x21-\x7e]/.test(value)) {
return undefined;
}
const match = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})(.*)$/.exec(value);
if (
!match ||
match[1] === 'ff' ||
/^0+$/.test(match[2]!) ||
/^0+$/.test(match[3]!) ||
(match[1] === '00' ? match[5] !== '' : match[5] !== '' && !match[5]!.startsWith('-'))
) {
return undefined;
}
// Future-version fields are opaque and must not enter application logs.
return value.slice(0, 55);
}
+22
View File
@@ -0,0 +1,22 @@
export {
IOneBotNetworkAdapter,
NetworkReloadType,
type AdapterStatus,
type AdapterStatusLevel,
type NetworkAdapterContext,
} from './adapter';
export { HttpPostAdapter } from './http-post-adapter';
export { HttpServerAdapter } from './http-server-adapter';
export {
OneBotNetworkManager,
type DesiredNetworkAdapter,
type NetworkAdapterFactory,
type NetworkAdapterKind,
type NetworkApplyError,
type NetworkApplyPhase,
type NetworkReconcileResult,
type NetworkShutdownResult,
} from './network-manager';
export { executeQuickOperation } from './quick-operation';
export { WsClientAdapter } from './ws-client-adapter';
export { WsServerAdapter } from './ws-server-adapter';
@@ -0,0 +1,566 @@
import { createLogger } from '@snowluma/common/logger';
import { errorMessage } from '@snowluma/common/errors';
import { assertValidOneBotConfig } from '../config';
import { buildDispatchPayload } from '../event-filter';
import type {
HttpClientNetwork,
HttpServerNetwork,
JsonObject,
NetworkBase,
OneBotConfig,
WsClientNetwork,
WsServerNetwork,
} from '../types';
import { IOneBotNetworkAdapter, type AdapterStatus } from './adapter';
const log = createLogger('OneBot.Network');
type AnyAdapter = IOneBotNetworkAdapter<NetworkBase>;
export type NetworkAdapterKind = AdapterStatus['kind'];
export type DesiredNetworkAdapter =
| { name: string; kind: 'httpServer'; config: HttpServerNetwork }
| { name: string; kind: 'httpClient'; config: HttpClientNetwork }
| { name: string; kind: 'wsServer'; config: WsServerNetwork }
| { name: string; kind: 'wsClient'; config: WsClientNetwork };
export type NetworkApplyPhase = 'open' | 'reload' | 'close' | 'replace' | 'restore' | 'shutdown';
export interface NetworkApplyError {
name: string;
kind: NetworkAdapterKind;
phase: NetworkApplyPhase;
message: string;
at: number;
/** True when the previous live adapter/config was restored. The desired
* config remains unapplied and the adapter stays visibly degraded. */
restored?: boolean;
}
export interface NetworkReconcileResult {
applied: boolean;
errors: NetworkApplyError[];
statuses: AdapterStatus[];
}
export interface NetworkShutdownResult {
closed: boolean;
errors: NetworkApplyError[];
}
export type NetworkAdapterFactory = (desired: DesiredNetworkAdapter) => AnyAdapter;
interface ManagedAdapter {
kind: NetworkAdapterKind;
adapter: AnyAdapter;
}
/**
* Single owner of the configured network topology.
*
* Configs are first compiled into a globally-name-unique desired plan. Every
* startup/reload/shutdown operation then runs through one FIFO, so a reload can
* never race another reload or teardown. Runtime failures are isolated per
* adapter and returned as data; deterministic plan errors throw before the
* operation is queued.
*/
export class OneBotNetworkManager {
private readonly adapters = new Map<string, ManagedAdapter>();
private readonly factory: NetworkAdapterFactory | null;
private operationTail: Promise<void> = Promise.resolve();
private accepting = true;
private readonly inFlightEmits = new Set<Promise<void>>();
private shutdownPromise: Promise<NetworkShutdownResult> | null = null;
/** Debug-stream taps — notified for every emitted event regardless of adapter
* state. Attached on-demand (ref-counted) by the WebUI debug stream. */
private readonly debugSubscribers = new Set<(event: JsonObject) => void>();
constructor(factory?: NetworkAdapterFactory) {
this.factory = factory ?? null;
}
subscribeDebug(cb: (event: JsonObject) => void): () => void {
this.debugSubscribers.add(cb);
return () => {
this.debugSubscribers.delete(cb);
};
}
/** Test/embedding seam for pre-built adapters. Production topology changes
* go through reconcile(), which refuses duplicate names. */
register<C extends NetworkBase>(adapter: IOneBotNetworkAdapter<C>): void {
if (!this.accepting) {
throw new Error('network manager is shutting down');
}
if (this.adapters.has(adapter.name)) {
throw new Error(`network adapter name already registered: ${adapter.name}`);
}
const kind = adapter.describeStatus().kind;
this.adapters.set(adapter.name, { kind, adapter: adapter as AnyAdapter });
}
has(name: string): boolean {
return this.adapters.has(name);
}
get(name: string): AnyAdapter | null {
return this.adapters.get(name)?.adapter ?? null;
}
list(): AnyAdapter[] {
return [...this.adapters.values()].map((entry) => entry.adapter);
}
/** Live status of every registered adapter, including desired/live apply
* failures even when an older binding was restored and remains active. */
describeStatuses(): AdapterStatus[] {
return this.list().map((adapter) => adapter.describeManagedStatus());
}
hasActiveAdapters(): boolean {
for (const { adapter } of this.adapters.values()) {
if (adapter.isActive) {
return true;
}
}
return false;
}
/** Compile and validate outside the queue: deterministic errors never wait
* behind network I/O and can be rejected before a WebUI save. */
compileDesiredPlan(config: OneBotConfig): DesiredNetworkAdapter[] {
assertValidOneBotConfig(config);
return [
...config.networks.httpServers.map((network) => ({
name: network.name,
kind: 'httpServer' as const,
config: structuredClone(network),
})),
...config.networks.httpClients.map((network) => ({
name: network.name,
kind: 'httpClient' as const,
config: structuredClone(network),
})),
...config.networks.wsServers.map((network) => ({
name: network.name,
kind: 'wsServer' as const,
config: structuredClone(network),
})),
...config.networks.wsClients.map((network) => ({
name: network.name,
kind: 'wsClient' as const,
config: structuredClone(network),
})),
];
}
reconcile(config: OneBotConfig): Promise<NetworkReconcileResult> {
const desired = this.compileDesiredPlan(config);
if (!this.accepting) {
return Promise.reject(new Error('network manager is shutting down'));
}
return this.enqueue(() => this.reconcileNow(desired));
}
/** Open registered adapters in order and report each failure. */
openAll(): Promise<NetworkReconcileResult> {
if (!this.accepting) {
return Promise.reject(new Error('network manager is shutting down'));
}
return this.enqueue(async () => {
const errors: NetworkApplyError[] = [];
for (const entry of this.adapters.values()) {
try {
await entry.adapter.open();
entry.adapter.clearApplyFailure();
} catch (error) {
entry.adapter.markApplyFailure(error);
errors.push(this.report(entry.adapter.name, entry.kind, 'open', error));
}
}
return this.result(errors);
});
}
closeOne(name: string): Promise<void> {
if (!this.accepting) {
return Promise.reject(new Error('network manager is shutting down'));
}
return this.enqueue(async () => {
const entry = this.adapters.get(name);
if (!entry) {
return;
}
await entry.adapter.close();
this.adapters.delete(name);
});
}
/** Ordered, idempotent shutdown. Reconcile calls made before shutdown finish
* first; calls made after shutdown is requested fail immediately. */
shutdown(): Promise<NetworkShutdownResult> {
if (this.shutdownPromise) {
return this.shutdownPromise;
}
this.accepting = false;
const attempt = this.enqueue(async () => {
const errors: NetworkApplyError[] = [];
const dispatches = await Promise.allSettled([...this.inFlightEmits]);
for (const dispatch of dispatches) {
if (dispatch.status === 'rejected') {
log.error(
'in-flight event dispatch failed during shutdown: %s',
errorMessage(dispatch.reason),
);
}
}
const entries = [...this.adapters.entries()].reverse();
for (const [name, entry] of entries) {
try {
await entry.adapter.close();
this.adapters.delete(name);
} catch (error) {
entry.adapter.markApplyFailure(error);
errors.push(this.report(name, entry.kind, 'shutdown', error));
}
}
this.debugSubscribers.clear();
return { closed: errors.length === 0, errors };
});
this.shutdownPromise = attempt;
void attempt.then(
(result) => {
if (!result.closed) {
this.shutdownPromise = null;
}
},
() => {
this.shutdownPromise = null;
},
);
return attempt;
}
/** Best-effort immediate release. Does not wait for the operation queue,
* in-flight dispatches, or peer close handshakes. */
forceRelease(): void {
this.accepting = false;
for (const { adapter } of this.adapters.values()) {
try {
adapter.forceClose();
} catch (error) {
log.error('forced adapter release failed: %s', errorMessage(error));
}
}
}
emitEvent(event: JsonObject): Promise<void> {
if (!this.accepting) {
return Promise.reject(
new Error('network manager is shutting down; event dispatch rejected'),
);
}
const operation = this.emitEventNow(event);
this.inFlightEmits.add(operation);
void operation.then(
() => {
this.inFlightEmits.delete(operation);
},
() => {
this.inFlightEmits.delete(operation);
},
);
return operation;
}
private async emitEventNow(event: JsonObject): Promise<void> {
// Notify debug taps first — before the adapter check — so the debug stream
// sees every event even when no adapter is connected. A diagnostic tap is
// isolated, but its failure is always logged with the root message.
if (this.debugSubscribers.size) {
for (const cb of this.debugSubscribers) {
try {
cb(event);
} catch (error) {
log.warn('debug subscriber error: %s', errorMessage(error));
}
}
}
const active = [...this.adapters.values()]
.map(({ adapter }) => adapter)
.filter((adapter) => adapter.isActive);
if (active.length === 0) {
log.trace('report_terminal outcome=no_active_targets');
return;
}
log.trace(() => [
'report_targets targets=%s',
JSON.stringify(active.map((adapter) => adapter.name)),
]);
const payload = buildDispatchPayload(event);
const tasks = active.map((adapter) =>
Promise.resolve()
.then(() => adapter.onEvent(event, payload))
.catch((error) => {
log.warn('adapter [%s] onEvent error: %s', adapter.name, errorMessage(error));
log.trace(() => [
'report_target_failed target=%s error=%s',
adapter.name,
error instanceof Error ? (error.stack ?? error.message) : String(error),
]);
}),
);
await Promise.allSettled(tasks);
log.trace('report_terminal outcome=settled');
}
private async reconcileNow(desired: DesiredNetworkAdapter[]): Promise<NetworkReconcileResult> {
const errors: NetworkApplyError[] = [];
const desiredByName = new Map(desired.map((entry) => [entry.name, entry]));
// Retire removed adapters first so their ports/resources are available to
// newly added entries in the same plan. Close is deliberately sequential.
for (const [name, live] of [...this.adapters]) {
if (desiredByName.has(name)) {
continue;
}
try {
await live.adapter.close();
this.adapters.delete(name);
} catch (error) {
live.adapter.markApplyFailure(error);
errors.push(this.report(name, live.kind, 'close', error));
}
}
for (const next of desired) {
const live = this.adapters.get(next.name);
if (!live) {
await this.createAndOpen(next, errors);
} else if (live.kind === next.kind) {
await this.reloadSameKind(live, next, errors);
} else {
await this.replaceKind(live, next, errors);
}
}
return this.result(errors);
}
private async createAndOpen(
next: DesiredNetworkAdapter,
errors: NetworkApplyError[],
): Promise<void> {
let adapter: AnyAdapter;
try {
adapter = this.create(next);
} catch (error) {
errors.push(this.report(next.name, next.kind, 'open', error));
return;
}
this.adapters.set(next.name, { kind: next.kind, adapter });
try {
await adapter.open();
adapter.clearApplyFailure();
} catch (error) {
adapter.markApplyFailure(error);
errors.push(this.report(next.name, next.kind, 'open', error));
}
}
private async reloadSameKind(
live: ManagedAdapter,
next: DesiredNetworkAdapter,
errors: NetworkApplyError[],
): Promise<void> {
const previous = structuredClone(live.adapter.currentConfig);
try {
// A degraded adapter may still own a failed/ambiguous transport even if
// the desired binding signature is unchanged. Force a real release and
// reopen instead of letting reload() return Normal and clearing the
// evidence without recovery.
if (live.adapter.hasApplyFailure) {
await live.adapter.close();
}
await live.adapter.reload(next.config);
live.adapter.clearApplyFailure();
} catch (error) {
let restored = false;
try {
await live.adapter.reload(previous);
restored = true;
} catch (restoreError) {
errors.push(this.report(next.name, live.kind, 'restore', restoreError));
}
live.adapter.markApplyFailure(error);
errors.push(this.report(next.name, next.kind, 'reload', error, restored));
}
}
private async replaceKind(
live: ManagedAdapter,
next: DesiredNetworkAdapter,
errors: NetworkApplyError[],
): Promise<void> {
try {
await live.adapter.close();
} catch (error) {
live.adapter.markApplyFailure(error);
errors.push(this.report(next.name, live.kind, 'replace', error));
return;
}
let replacement: AnyAdapter;
try {
replacement = this.create(next);
} catch (error) {
let restored = false;
try {
await live.adapter.open();
restored = true;
} catch (restoreError) {
errors.push(this.report(next.name, live.kind, 'restore', restoreError));
}
// Whether restoration succeeds or not, the desired kind was not
// applied. Preserve that root failure on the still-managed old adapter.
live.adapter.markApplyFailure(error);
errors.push(this.report(next.name, next.kind, 'replace', error, restored));
return;
}
this.adapters.set(next.name, { kind: next.kind, adapter: replacement });
try {
await replacement.open();
replacement.clearApplyFailure();
return;
} catch (error) {
// The desired kind failed after the old resource was released. Clean up
// the partial replacement, then make a best-effort restoration of the
// previous live adapter. The original apply error remains the primary
// degraded reason even when restoration succeeds.
try {
await replacement.close();
} catch (cleanupError) {
// The replacement may still own a bound resource. Keep it in the map
// and do not start the old adapter: overwriting this reference would
// orphan a potentially-live transport and could double-bind a port.
replacement.markApplyFailure(error);
errors.push(this.report(next.name, next.kind, 'close', cleanupError));
errors.push(this.report(next.name, next.kind, 'replace', error, false));
return;
}
let restored = false;
try {
await live.adapter.open();
live.adapter.markApplyFailure(error);
this.adapters.set(next.name, live);
restored = true;
} catch (restoreError) {
replacement.markApplyFailure(error);
errors.push(this.report(next.name, live.kind, 'restore', restoreError));
}
errors.push(this.report(next.name, next.kind, 'replace', error, restored));
}
}
private create(next: DesiredNetworkAdapter): AnyAdapter {
if (!this.factory) {
throw new Error(`no network adapter factory configured for ${next.name}`);
}
const adapter = this.factory(next);
if (adapter.name !== next.name) {
throw new Error(
`network adapter factory returned name ${adapter.name} for desired ${next.name}`,
);
}
return adapter;
}
private result(errors: NetworkApplyError[]): NetworkReconcileResult {
return {
applied: errors.length === 0,
errors,
statuses: this.describeStatuses(),
};
}
private report(
name: string,
kind: NetworkAdapterKind,
phase: NetworkApplyPhase,
error: unknown,
restored?: boolean,
): NetworkApplyError {
const detail: NetworkApplyError = {
name,
kind,
phase,
message: errorMessage(error),
at: Date.now(),
...(restored === undefined ? {} : { restored }),
};
log.error(
'adapter [%s] kind=%s phase=%s failed%s: %s',
name,
kind,
phase,
restored === undefined ? '' : ` restored=${String(restored)}`,
detail.message,
);
return detail;
}
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
const result = this.operationTail.then(operation);
// Keep the FIFO usable after a rejected invariant/programming error. The
// returned promise still rejects, so no error is hidden from its caller.
this.operationTail = result.then(
() => undefined,
() => undefined,
);
return result;
}
}
@@ -0,0 +1,93 @@
import type { ApiHandler } from '../api-handler';
import type { JsonObject, JsonValue } from '../types';
export async function executeQuickOperation(
event: JsonObject,
operation: Record<string, unknown>,
api: ApiHandler,
): Promise<void> {
const postType = event.post_type as string;
if (postType === 'message') {
if (operation.reply !== undefined && operation.reply !== null && operation.reply !== '') {
const messageType = event.message_type as string;
const autoEscape = !!operation.auto_escape;
if (messageType === 'group') {
const params: JsonObject = {
group_id: event.group_id as number,
message: operation.reply as JsonValue,
auto_escape: autoEscape,
};
if (operation.at_sender !== false && event.user_id) {
const atSegment = { type: 'at', data: { qq: String(event.user_id) } };
if (typeof operation.reply === 'string') {
params.message = [
atSegment,
{ type: 'text', data: { text: operation.reply as string } },
] as JsonValue;
params.auto_escape = false;
} else if (Array.isArray(operation.reply)) {
params.message = [
atSegment,
...(operation.reply as unknown[]),
] as JsonValue;
}
}
await api.handle('send_group_msg', params);
} else if (messageType === 'private') {
await api.handle('send_private_msg', {
user_id: event.user_id as number,
message: operation.reply as JsonValue,
auto_escape: autoEscape,
});
}
}
if (operation.delete) {
await api.handle('delete_msg', { message_id: event.message_id as number });
}
if (operation.ban && event.message_type === 'group') {
const duration =
typeof operation.ban_duration === 'number' ? operation.ban_duration : 1800;
await api.handle('set_group_ban', {
group_id: event.group_id as number,
user_id: event.user_id as number,
duration: duration as JsonValue,
});
}
if (operation.kick && event.message_type === 'group') {
await api.handle('set_group_kick', {
group_id: event.group_id as number,
user_id: event.user_id as number,
reject_add_request: !!operation.reject_add_request,
});
}
}
if (postType === 'request') {
if (operation.approve !== undefined) {
const requestType = event.request_type as string;
if (requestType === 'friend') {
await api.handle('set_friend_add_request', {
flag: event.flag as string,
approve: operation.approve as JsonValue,
remark: (operation.remark ?? '') as JsonValue,
});
} else if (requestType === 'group') {
await api.handle('set_group_add_request', {
flag: event.flag as string,
sub_type: event.sub_type as string,
approve: operation.approve as JsonValue,
reason: (operation.reason ?? '') as JsonValue,
});
}
}
}
}
+195
View File
@@ -0,0 +1,195 @@
import { StreamTransportClosedError } from '../streaming';
import type { WebSocket } from '@snowluma/websocket';
import type { IncomingMessage } from 'http';
/**
* Appended to a 1009 (frame-over-cap) log line. Shared so the client and server
* adapters cannot drift apart on the advice they give.
*/
export const MAX_PAYLOAD_HINT =
"Raise this adapter's maxPayloadBytes, or have the peer send a smaller frame (a URL/file reference or SILK instead of inline PCM)";
export function isAuthorized(
request: IncomingMessage,
token: string,
allowRawToken = false,
): boolean {
if (!token) {
return true;
}
const auth = request.headers.authorization ?? '';
if (auth === `Bearer ${token}` || (allowRawToken && auth === token)) {
return true;
}
try {
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
if (url.searchParams.get('access_token') === token) {
return true;
}
} catch {
/* ignore malformed URLs */
}
return false;
}
/** Convert anything ws can hand us into a UTF-8 string. */
export function rawDataToString(
raw: Buffer | string | ArrayBuffer | ArrayBufferView | Buffer[],
): string {
if (typeof raw === 'string') {
return raw;
}
if (Buffer.isBuffer(raw)) {
return raw.toString('utf8');
}
if (Array.isArray(raw)) {
return Buffer.concat(raw).toString('utf8');
}
if (raw instanceof ArrayBuffer) {
return Buffer.from(new Uint8Array(raw)).toString('utf8');
}
return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString('utf8');
}
export function safeSend(socket: WebSocket, payload: string, onError?: (err: Error) => void): void {
if (socket.readyState !== 1 /* WebSocket.OPEN */) {
return;
}
socket.send(payload, (error?: Error | null) => {
if (error && onError) {
onError(error);
}
});
}
/** Backpressure-aware send: resolves only once `ws` has flushed `payload` to
* the socket (the `send` callback fires post-write), so a streaming producer
* that awaits this won't outrun a slow client and balloon the send buffer.
* A closed socket or failed write rejects with the shared transport sentinel,
* allowing the action boundary to classify an in-progress stream cancellation. */
export function safeSendAsync(socket: WebSocket, payload: string): Promise<void> {
return new Promise((resolve, reject) => {
if (socket.readyState !== 1 /* WebSocket.OPEN */) {
reject(new StreamTransportClosedError('stream client disconnected'));
return;
}
try {
socket.send(payload, (error?: Error | null) => {
if (error) {
reject(new StreamTransportClosedError('stream client disconnected'));
return;
}
resolve();
});
} catch {
reject(new StreamTransportClosedError('stream client disconnected'));
}
});
}
export function safeClose(socket: WebSocket, code = 1000, reason = 'normal'): void {
if (socket.readyState === 3 /* CLOSED */ || socket.readyState === 2 /* CLOSING */) {
return;
}
socket.close(code, reason);
}
/** RFC6455 transport-level keepalive for a reverse-WS connection.
*
* The OneBot app-level `meta_event` heartbeat is a one-way outbound push, so it
* cannot detect a connection that is writable but no longer readable — a
* half-open link left behind when the peer restarts behind a proxy/NAT. In that
* state events keep flowing out while inbound action frames silently never
* arrive, and only a full restart recovers it (issue #208).
*
* This pings every `intervalMs` and counts consecutive silent intervals. ANY
* inbound activity — a pong OR a message — resets the counter, so a connection
* carrying real traffic is never reaped. Death is declared once `maxMissed`
* pings have gone unanswered, i.e. after roughly `(maxMissed + 1) * intervalMs`
* of total silence (the counter is checked at the top of each tick, so the
* tick that observes the threshold is one interval past the last ping). The
* tolerance plus a conservative interval keeps the false-positive rate near
* zero: a single GC pause, event-loop stall, or transient latency spike is
* absorbed (Node fires an overdue interval once, not N catch-up times), and
* death is declared only on genuine sustained silence.
*
* Returns an idempotent stop function; the timer is `unref`'d so it never keeps
* the process alive. */
export function startHeartbeat(
socket: WebSocket,
opts: { intervalMs: number; maxMissed: number },
onDead: () => void,
): () => void {
let missed = 0;
let stopped = false;
const onActivity = (): void => {
missed = 0;
};
socket.on('pong', onActivity);
socket.on('message', onActivity);
const timer = setInterval(() => {
if (missed >= opts.maxMissed) {
// `maxMissed` consecutive intervals with zero inbound traffic — the read
// direction is dead. Stop first so onDead()'s terminate→close→reconnect
// can't re-enter this tick.
stop();
onDead();
return;
}
missed += 1;
try {
socket.ping();
} catch {
// Ping on an already-dead socket — treat as dead immediately.
stop();
onDead();
}
}, opts.intervalMs);
timer.unref();
function stop(): void {
if (stopped) {
return;
}
stopped = true;
clearInterval(timer);
socket.off('pong', onActivity);
socket.off('message', onActivity);
}
return stop;
}
export function normalizePath(pathValue: string | undefined): string {
const path = (pathValue ?? '/').trim() || '/';
if (path === '/') {
return '/';
}
return path.endsWith('/') ? path.slice(0, -1) : path;
}
export function parseRequestPath(urlValue: string): string {
const queryIndex = urlValue.indexOf('?');
return queryIndex === -1 ? urlValue : urlValue.slice(0, queryIndex);
}
@@ -0,0 +1,383 @@
import { WebSocket } from '@snowluma/websocket';
import { createLogger } from '@snowluma/common/logger';
import {
pickDispatchJson,
resolveReportOptions,
type DispatchPayload,
type EventReportOptions,
} from '../event-filter';
import type { JsonObject, WsClientNetwork, WsRole } from '../types';
import { IOneBotNetworkAdapter, type AdapterStatus, type NetworkAdapterContext } from './adapter';
import {
MAX_PAYLOAD_HINT,
rawDataToString,
safeClose,
safeSend,
safeSendAsync,
startHeartbeat,
} from './utils';
const moduleLog = createLogger('OneBot.WS-Client');
const DEFAULT_RECONNECT_INTERVAL_MS = 5000;
// Transport-level keepalive: ping every 30s, declare the link dead only after 2
// consecutive pings go unanswered — ~90s of total silence (see startHeartbeat
// for the +1-interval timing). Conservative on purpose so transient jitter/GC
// never reaps a healthy connection. See issue #208.
const HEARTBEAT_INTERVAL_MS = 30_000;
const HEARTBEAT_MAX_MISSED = 2;
const HEARTBEAT_DEAD_AFTER_S = (HEARTBEAT_INTERVAL_MS * (HEARTBEAT_MAX_MISSED + 1)) / 1000;
export class WsClientAdapter extends IOneBotNetworkAdapter<WsClientNetwork> {
private socket: WebSocket | null = null;
private connected = false;
private reconnectTimer: NodeJS.Timeout | null = null;
private options: EventReportOptions;
private role: WsRole;
private readonly inFlightActions = new Set<Promise<void>>();
// Stop fn for the CURRENT socket's keepalive, so close()/reload can halt it
// immediately instead of waiting for the deferred 'close' event. The per-socket
// closure in connect() still owns the #97 stale-socket case; this is idempotent
// with it (both call the same stop).
private heartbeatStop: (() => void) | null = null;
constructor(name: string, config: WsClientNetwork, ctx: NetworkAdapterContext) {
super(name, config, ctx, moduleLog);
this.options = resolveReportOptions(config);
this.role = config.role ?? 'Universal';
}
open(): void {
if (this.isEnabled || !this.willEnable(this.config)) {
return;
}
this.isEnabled = true;
try {
this.connect();
this.clearApplyFailure();
} catch (error) {
this.isEnabled = false;
this.recordTransportFailure(error);
throw error;
}
}
override forceClose(): void {
this.isEnabled = false;
this.connected = false;
this.cancelReconnect();
this.heartbeatStop?.();
this.heartbeatStop = null;
const socket = this.socket;
this.socket = null;
if (socket) {
try {
socket.terminate();
} catch {
/* already gone */
}
}
}
async close(): Promise<void> {
this.isEnabled = false;
this.connected = false;
this.cancelReconnect();
this.heartbeatStop?.();
this.heartbeatStop = null;
if (this.socket) {
safeClose(this.socket);
this.socket = null;
}
await Promise.all(this.inFlightActions);
}
override describeStatus(): AdapterStatus {
if (!this.isEnabled) {
return { name: this.name, kind: 'wsClient', status: 'disabled', detail: '未启用' };
}
if (this.connected) {
return { name: this.name, kind: 'wsClient', status: 'ok', detail: '已连接' };
}
return {
name: this.name,
kind: 'wsClient',
status: 'warn',
detail: this.reconnectTimer ? '重连中' : '连接中',
};
}
protected override bindingSignature(config: WsClientNetwork): string {
// The frame cap is baked into the socket when it is created, so a change
// must reconnect instead of silently keeping the old limit.
return `${config.url}#${config.role ?? 'Universal'}#${Math.max(1000, config.reconnectIntervalMs ?? DEFAULT_RECONNECT_INTERVAL_MS)}#${config.accessToken ?? ''}#${config.maxPayloadBytes ?? ''}`;
}
protected override willEnable(config: WsClientNetwork): boolean {
return config.enabled !== false && !!config.url;
}
protected override onConfigReplaced(next: WsClientNetwork): void {
this.options = resolveReportOptions(next);
this.role = next.role ?? 'Universal';
}
onEvent(_event: JsonObject, payload: DispatchPayload): void {
if (!this.isEnabled || !this.socket) {
return;
}
if (this.role !== 'Event' && this.role !== 'Universal') {
return;
}
const json = pickDispatchJson(payload, this.options);
if (json === null) {
return;
}
safeSend(this.socket, json);
}
private connect(): void {
if (!this.isEnabled) {
return;
}
if (!this.config.url) {
return;
}
if (this.socket) {
return;
}
const headers: Record<string, string> = {
'User-Agent': 'OneBot/11',
'X-Self-ID': this.ctx.uin,
'X-Client-Role': this.role,
};
if (this.config.accessToken) {
headers.Authorization = `Bearer ${this.config.accessToken}`;
}
const socket = new WebSocket(this.config.url, {
headers,
maxPayload: this.config.maxPayloadBytes,
});
this.socket = socket;
// Per-socket so a hot-reload overlap (old close firing after the new socket
// is assigned, see #97) can only ever stop its own keepalive.
let stopHeartbeat: (() => void) | null = null;
socket.on('open', () => {
// Symmetric with the 'close' guard (#97): if this socket was already
// replaced, a late 'open' must not flip `connected` or install a stale
// heartbeat onto the current connection's slot.
if (this.socket !== socket) {
return;
}
this.connected = true;
this.log.info('[%s] connected %s', this.name, this.config.url);
this.sendBootstrapMetaEvents(socket);
// Only meaningful once OPEN — ping() no-ops before the handshake completes.
stopHeartbeat = startHeartbeat(
socket,
{ intervalMs: HEARTBEAT_INTERVAL_MS, maxMissed: HEARTBEAT_MAX_MISSED },
() => {
if (!this.isEnabled) {
return;
}
this.log.warn(
'[%s] no inbound response for ~%ds, reconnecting half-open connection %s',
this.name,
HEARTBEAT_DEAD_AFTER_S,
this.config.url,
);
// terminate → 'close' → scheduleReconnect, reusing the normal path.
socket.terminate();
},
);
this.heartbeatStop = stopHeartbeat;
});
socket.on('message', (raw: Buffer) => {
if (this.socket !== socket) {
this.log.warn('[%s] rejected inbound action from stale socket', this.name);
return;
}
if (this.ctx.api.isAcceptingActions === false) {
this.log.warn('[%s] rejected inbound action after instance quiesce', this.name);
if (this.role === 'Api' || this.role === 'Universal') {
const text = rawDataToString(raw);
if (text) {
this.ctx.api.traceQuiescedStreamRequest(text);
}
}
return;
}
this.trackInboundAction(() => this.handleApiMessage(socket, raw));
});
socket.on('close', () => {
stopHeartbeat?.();
// Drop the instance handle only if it still points at THIS socket's stop
// (a newer socket may already own it after a hot-reload overlap).
if (this.heartbeatStop === stopHeartbeat) {
this.heartbeatStop = null;
}
stopHeartbeat = null;
// Ignore close events from a socket that is no longer the current
// connection. A hot reload (signature change) calls close() then open()
// back-to-back; the old socket's `'close'` event fires AFTER the new
// one is already assigned to `this.socket`, and without this guard it
// would null out `this.socket`, drop `connected`, and schedule an
// unwanted reconnect — observable as a reconnect storm against a
// single-connection backend that kicks duplicates. See issue #97.
if (this.socket !== socket) {
return;
}
this.socket = null;
this.connected = false;
if (!this.isEnabled) {
return;
}
this.scheduleReconnect();
});
socket.on('error', (err: Error) => {
// Keep the established `error <url>:` line intact (deployments grep
// it); the actionable hint is appended, never substituted.
const message = err instanceof Error ? err.message : String(err);
this.log.warn(
'[%s] error %s: %s',
this.name,
this.config.url,
(err as Error & { code?: number }).code === 1009
? `${message}. ${MAX_PAYLOAD_HINT}`
: message,
);
});
}
private scheduleReconnect(): void {
if (this.reconnectTimer) {
return;
}
if (!this.isEnabled) {
return;
}
const interval = Math.max(
1000,
this.config.reconnectIntervalMs ?? DEFAULT_RECONNECT_INTERVAL_MS,
);
const timer = setTimeout(() => {
this.reconnectTimer = null;
if (!this.isEnabled) {
return;
}
this.connect();
}, interval);
timer.unref();
this.reconnectTimer = timer;
}
private cancelReconnect(): void {
if (!this.reconnectTimer) {
return;
}
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
private async handleApiMessage(socket: WebSocket, raw: Buffer | string): Promise<void> {
if (this.role !== 'Api' && this.role !== 'Universal') {
return;
}
const text = rawDataToString(raw);
if (!text) {
return;
}
// Stream API (#163): one frame for a normal action, N for a streaming one.
// Async send = backpressure; liveness check aborts on disconnect.
await this.ctx.api.processStreamRequest(
text,
(frame) => safeSendAsync(socket, frame),
() => socket.readyState === 1,
);
}
private trackInboundAction(start: () => Promise<void>): void {
if (!this.isEnabled || this.ctx.api.isAcceptingActions === false) {
this.log.warn('[%s] rejected inbound action while adapter is closing', this.name);
return;
}
let action: Promise<void>;
try {
action = start();
} catch (error) {
this.log.error(
'[%s] inbound action start failed: %s',
this.name,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
return;
}
const tracked = action.then(
() => undefined,
(error) => {
this.log.error(
'[%s] inbound action failed: %s',
this.name,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
},
);
this.inFlightActions.add(tracked);
void tracked.then(() => {
this.inFlightActions.delete(tracked);
});
}
private sendBootstrapMetaEvents(socket: WebSocket): void {
if (this.role !== 'Event' && this.role !== 'Universal') {
return;
}
for (const frame of this.bootstrapMetaFrames(this.options)) {
safeSend(socket, frame);
}
}
}
@@ -0,0 +1,283 @@
import { WebSocketServer } from '@snowluma/websocket';
import { createLogger } from '@snowluma/common/logger';
import type { IncomingMessage } from 'node:http';
import type { DispatchPayload } from '../event-filter';
import type { JsonObject, WsServerNetwork } from '../types';
import { IOneBotNetworkAdapter, type AdapterStatus, type NetworkAdapterContext } from './adapter';
import { normalizePath, parseRequestPath } from './utils';
import { WsServerConnections, type WsServerConnectionConfig } from './ws-server-connections';
const moduleLog = createLogger('OneBot.WS-Server');
export class WsServerAdapter extends IOneBotNetworkAdapter<WsServerNetwork> {
private wss: WebSocketServer | null = null;
private listening = false;
private releaseForced = false;
private closePromise: Promise<void> | null = null;
private readonly connections: WsServerConnections;
constructor(name: string, config: WsServerNetwork, ctx: NetworkAdapterContext) {
super(name, config, ctx, moduleLog);
this.connections = new WsServerConnections(
name,
standaloneConnectionConfig(config),
ctx,
this.log,
{
frame: (event, options) => this.metaFrame(event, options),
bootstrap: (options) => this.bootstrapMetaFrames(options),
},
);
}
async open(): Promise<void> {
if (this.isEnabled && this.listening) {
return;
}
if (this.config.enabled === false) {
return;
}
if (this.wss) {
throw new Error(`WebSocket adapter [${this.name}] still owns a previous server`);
}
await this.startServer();
this.isEnabled = true;
this.clearApplyFailure();
}
async close(): Promise<void> {
if (this.closePromise) {
return this.closePromise;
}
if (
!this.isEnabled &&
this.connections.connectionCount === 0 &&
!this.wss &&
!this.connections.hasInFlightActions
) {
return;
}
const wasEnabled = this.isEnabled;
const wasListening = this.listening;
const wasAcceptingActions = this.connections.isAcceptingActions;
this.isEnabled = false;
this.listening = false;
const connectionDrain = this.connections.closeConnections();
const wss = this.wss;
const releaseResult: Promise<{ error?: Error }> = wss
? new Promise<{ error?: Error }>((resolve) => {
wss.close((error) =>
resolve(error && !isAlreadyClosedError(error) ? { error } : {}),
);
})
: Promise.resolve({});
const attempt = (async () => {
await connectionDrain;
const release = await releaseResult;
if (release.error) {
throw release.error;
}
})();
this.closePromise = attempt;
try {
await attempt;
if (wss && this.wss === wss) {
this.wss = null;
}
} catch (error) {
if (this.releaseForced) {
return;
}
// A failed close callback leaves release ambiguous. Retain the server
// reference and active binding state so a later shutdown can retry.
this.isEnabled = wasEnabled;
this.listening = wasListening;
if (wasAcceptingActions) {
this.connections.startAccepting();
}
throw error;
} finally {
this.closePromise = null;
}
}
override forceClose(): void {
this.releaseForced = true;
this.isEnabled = false;
this.listening = false;
this.connections.stopAccepting();
this.connections.terminateAll();
const wss = this.wss;
this.wss = null;
if (wss) {
try {
wss.destroy();
} catch {
/* already dropping */
}
}
}
override describeStatus(): AdapterStatus {
if (!this.isEnabled) {
return { name: this.name, kind: 'wsServer', status: 'disabled', detail: '未启用' };
}
if (!this.listening) {
return {
name: this.name,
kind: 'wsServer',
status: 'down',
detail: '未监听(端口被占用?)',
};
}
return {
name: this.name,
kind: 'wsServer',
status: 'ok',
detail: `${this.connections.connectionCount} 个客户端`,
};
}
protected override bindingSignature(config: WsServerNetwork): string {
// The frame cap is fixed when the WebSocketServer is constructed, so a
// change must rebind instead of silently keeping the old limit.
return `${config.host ?? '0.0.0.0'}:${config.port}${normalizePath(config.path)}#${config.role ?? 'auto'}#${config.accessToken ?? ''}#${config.maxPayloadBytes ?? ''}`;
}
protected override onConfigReplaced(next: WsServerNetwork): void {
this.connections.updateConfig(standaloneConnectionConfig(next));
}
onEvent(_event: JsonObject, payload: DispatchPayload): void {
if (!this.isEnabled) {
return;
}
this.connections.onEvent(payload);
}
private startServer(): Promise<void> {
return new Promise((resolve, reject) => {
let wss: WebSocketServer;
try {
wss = new WebSocketServer({
host: this.config.host ?? '0.0.0.0',
port: this.config.port,
maxPayload: this.config.maxPayloadBytes,
verifyClient: (
{ req }: { req: IncomingMessage },
done: (allow: boolean, code?: number, message?: string) => void,
) => {
const requestPath = parseRequestPath(req.url ?? '/');
if (!this.connections.acceptsUpgradePath(requestPath)) {
this.log.debug(
'[%s] rejected unknown WebSocket path %s',
this.name,
requestPath,
);
done(false, 400, 'Bad path for WebSocket');
return;
}
if (!this.connections.authorizeUpgrade(req)) {
done(false, 401, 'Unauthorized');
return;
}
done(true);
},
});
} catch (error) {
this.recordTransportFailure(error);
reject(error);
return;
}
this.wss = wss;
let opening = true;
wss.once('listening', () => {
opening = false;
if (this.wss !== wss || this.closePromise) {
wss.close();
reject(new Error(`WebSocket adapter [${this.name}] was closed while binding`));
return;
}
this.listening = true;
this.isEnabled = true;
this.connections.startAccepting();
this.log.success(
'[%s] listening %s:%d%s',
this.name,
this.config.host ?? '0.0.0.0',
this.config.port,
this.config.path ?? '/',
);
resolve();
});
wss.on('error', (error: Error) => {
if (this.wss === wss) {
this.listening = false;
this.isEnabled = false;
this.connections.stopAccepting();
this.recordTransportFailure(error);
if (opening) {
this.wss = null;
}
}
this.log.error(
'[%s] server error: %s',
this.name,
error instanceof Error ? error.message : String(error),
);
if (opening) {
opening = false;
reject(error);
}
});
wss.on('connection', (socket, request) => this.connections.accept(socket, request));
});
}
/** Kept as the adapter-level lifecycle test seam. */
protected get acceptingActions(): boolean {
return this.connections.isAcceptingActions;
}
/** Kept as the adapter-level lifecycle test seam. */
protected trackInboundAction(start: () => Promise<void>): void {
this.connections.trackInboundAction(start);
}
}
function isAlreadyClosedError(error: Error): boolean {
return (error as NodeJS.ErrnoException).code === 'ERR_SERVER_NOT_RUNNING';
}
function standaloneConnectionConfig(config: WsServerNetwork): WsServerConnectionConfig {
return { ...config, role: config.role ?? 'Universal' };
}
@@ -0,0 +1,386 @@
import type { Logger } from '@snowluma/common/logger';
import type { WebSocket } from '@snowluma/websocket';
import type { IncomingMessage } from 'node:http';
import {
pickDispatchJson,
resolveReportOptions,
type DispatchPayload,
type EventReportOptions,
} from '../event-filter';
import type { JsonObject, NetworkBase, WsRole } from '../types';
import type { NetworkAdapterContext } from './adapter';
import {
MAX_PAYLOAD_HINT,
isAuthorized,
normalizePath,
parseRequestPath,
rawDataToString,
safeClose,
safeSend,
safeSendAsync,
startHeartbeat,
} from './utils';
// Transport keepalive for each attached client, symmetric with the ws-client
// adapter: ping every 30s, reap a client only after 2 consecutive pings go
// unanswered — ~90s of total silence (see startHeartbeat for the +1-interval
// timing). Any inbound frame resets the counter — see issue #208.
const HEARTBEAT_INTERVAL_MS = 30_000;
const HEARTBEAT_MAX_MISSED = 2;
const HEARTBEAT_DEAD_AFTER_S = (HEARTBEAT_INTERVAL_MS * (HEARTBEAT_MAX_MISSED + 1)) / 1000;
export type WsServerConnectionConfig = NetworkBase & { path?: string; role?: WsRole };
interface ForwardConnection {
socket: WebSocket;
role: WsRole;
options: EventReportOptions;
stopHeartbeat: () => void;
}
interface MetaFrames {
frame(event: JsonObject, options: EventReportOptions): string | null;
bootstrap(options: EventReportOptions): string[];
}
/**
* Shared OneBot WebSocket session owner.
*
* Listener ownership deliberately stays in the network adapter: a standalone
* WS adapter owns its TCP listener, while an HTTP adapter owns the shared
* listener and only attaches an upgrade handler. This class owns everything
* after a WebSocket connection is accepted so both transports keep identical
* auth, role, event, heartbeat, stream, and shutdown semantics.
*/
export class WsServerConnections {
private acceptingActions = false;
private readonly inFlightActions = new Set<Promise<void>>();
private readonly connections = new Map<WebSocket, ForwardConnection>();
private config: WsServerConnectionConfig;
private options: EventReportOptions;
constructor(
private readonly name: string,
config: WsServerConnectionConfig,
private readonly ctx: NetworkAdapterContext,
private readonly log: Logger,
private readonly meta: MetaFrames,
) {
this.config = structuredClone(config);
this.options = resolveReportOptions(config);
}
get connectionCount(): number {
return this.connections.size;
}
get isAcceptingActions(): boolean {
return this.acceptingActions;
}
get hasInFlightActions(): boolean {
return this.inFlightActions.size > 0;
}
startAccepting(): void {
this.acceptingActions = true;
}
stopAccepting(): void {
this.acceptingActions = false;
}
updateConfig(next: WsServerConnectionConfig): void {
const tokenChanged = (this.config.accessToken ?? '') !== (next.accessToken ?? '');
this.config = structuredClone(next);
this.options = resolveReportOptions(next);
for (const connection of this.connections.values()) {
connection.options = this.options;
}
// An already-authorized socket must not retain access after token
// rotation. Drop it immediately; the client can reconnect with the new
// credential without forcing the shared HTTP listener to rebind.
if (tokenChanged) {
this.disconnectAll(1008, 'access token changed');
}
}
acceptsUpgradePath(requestPath: string): boolean {
return resolveForwardRole(requestPath, this.config) !== null;
}
onEvent(payload: DispatchPayload): void {
if (!this.acceptingActions || this.connections.size === 0) {
return;
}
for (const connection of this.connections.values()) {
if (connection.role !== 'Event' && connection.role !== 'Universal') {
continue;
}
const json = pickDispatchJson(payload, connection.options);
if (json === null) {
continue;
}
safeSend(connection.socket, json);
}
}
/** Authenticate the HTTP upgrade before a WebSocket object is allocated. */
authorizeUpgrade(request: IncomingMessage): boolean {
const authorized = isAuthorized(request, this.config.accessToken ?? '');
if (!authorized) {
this.log.debug('[%s] rejected unauthorized WebSocket upgrade', this.name);
}
return authorized;
}
accept(socket: WebSocket, request: IncomingMessage): void {
if (!this.acceptingActions || this.ctx.api.isAcceptingActions === false) {
safeClose(socket, 1012, 'server closing');
return;
}
// Defence in depth for custom/future listeners that bypass verifyClient.
if (!this.authorizeUpgrade(request)) {
safeClose(socket, 1008, 'invalid access token');
return;
}
const requestPath = parseRequestPath(request.url ?? '/');
const role = resolveForwardRole(requestPath, this.config);
if (role === null) {
this.log.warn('[%s] rejected WebSocket path after upgrade', this.name);
safeClose(socket, 1008, 'invalid path');
return;
}
const stopHeartbeat = startHeartbeat(
socket,
{ intervalMs: HEARTBEAT_INTERVAL_MS, maxMissed: HEARTBEAT_MAX_MISSED },
() => {
this.log.warn(
'[%s] client silent for ~%ds, terminating half-open connection',
this.name,
HEARTBEAT_DEAD_AFTER_S,
);
socket.terminate();
},
);
const connection: ForwardConnection = {
socket,
role,
options: this.options,
stopHeartbeat,
};
this.connections.set(socket, connection);
socket.on('message', (raw: Buffer) => {
// Token rotation removes the connection synchronously, before the close
// handshake necessarily reaches the peer. Ignore any frame arriving in
// that interval so old credentials cannot start another Action.
if (!this.connections.has(socket)) {
return;
}
if (this.ctx.api.isAcceptingActions === false) {
this.log.warn('[%s] rejected inbound action after instance quiesce', this.name);
if (role === 'Api' || role === 'Universal') {
const text = rawDataToString(raw);
if (text) {
this.ctx.api.traceQuiescedStreamRequest(text);
}
}
return;
}
this.trackInboundAction(() => this.handleApiMessage(socket, role, raw));
});
socket.on('close', () => {
stopHeartbeat();
this.connections.delete(socket);
});
socket.on('error', (error: Error) => {
// Keep the established `socket error:` line intact (deployments grep
// it); the actionable hint is appended, never substituted.
const message = error instanceof Error ? error.message : String(error);
this.log.warn(
'[%s] socket error: %s',
this.name,
(error as Error & { code?: number }).code === 1009
? `${message}. ${MAX_PAYLOAD_HINT}`
: message,
);
});
if (role === 'Event' || role === 'Universal') {
for (const frame of this.meta.bootstrap(this.options)) {
safeSend(socket, frame);
}
}
}
async closeConnections(): Promise<void> {
this.stopAccepting();
const lifecycle = this.ctx.buildLifecycleEvent('disable');
for (const connection of this.connections.values()) {
if (connection.role === 'Api') {
continue;
}
const frame = this.meta.frame(lifecycle, connection.options);
if (frame) {
safeSend(connection.socket, frame);
}
}
this.disconnectAll();
await Promise.all(this.inFlightActions);
}
private disconnectAll(code = 1000, reason = 'normal'): void {
for (const connection of this.connections.values()) {
connection.stopHeartbeat();
safeClose(connection.socket, code, reason);
}
this.connections.clear();
}
/** Destroy sockets immediately. A graceful close frame can leave the
* listener waiting on a peer that will never answer. */
terminateAll(): void {
this.stopAccepting();
for (const connection of this.connections.values()) {
connection.stopHeartbeat();
try {
connection.socket.terminate();
} catch {
/* already gone */
}
}
this.connections.clear();
}
private async handleApiMessage(
socket: WebSocket,
role: WsRole,
raw: Buffer | string,
): Promise<void> {
const text = rawDataToString(raw);
if (!text) {
this.log.debug('[%s] ignored empty WebSocket frame', this.name);
return;
}
if (role !== 'Api' && role !== 'Universal') {
this.log.warn('[%s] rejected Action frame from Event-only connection', this.name);
return;
}
await this.ctx.api.processStreamRequest(
text,
(frame) => safeSendAsync(socket, frame),
() => socket.readyState === 1 && this.connections.has(socket),
);
}
trackInboundAction(start: () => Promise<void>): void {
if (!this.acceptingActions || this.ctx.api.isAcceptingActions === false) {
this.log.warn('[%s] rejected inbound action while adapter is closing', this.name);
return;
}
let action: Promise<void>;
try {
action = start();
} catch (error) {
this.log.error(
'[%s] inbound action start failed: %s',
this.name,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
return;
}
const tracked = action.then(
() => undefined,
(error) => {
this.log.error(
'[%s] inbound action failed: %s',
this.name,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
},
);
this.inFlightActions.add(tracked);
void tracked.then(() => {
this.inFlightActions.delete(tracked);
});
}
}
function resolveForwardRole(requestPath: string, config: WsServerConnectionConfig): WsRole | null {
if (
config.role !== undefined &&
(!requestPath.startsWith('/') || requestPath.startsWith('//'))
) {
return null;
}
const path = normalizePath(requestPath);
if (config.role === undefined) {
return classifyForwardRole(path);
}
const basePath = normalizePath(config.path);
if (path === basePath) {
return config.role;
}
if (config.role !== 'Universal' || basePath !== '/') {
return null;
}
if (path === '/api') {
return 'Api';
}
if (path === '/event') {
return 'Event';
}
return null;
}
function classifyForwardRole(path: string): WsRole {
if (path.endsWith('/api')) {
return 'Api';
}
if (path.endsWith('/event')) {
return 'Event';
}
return 'Universal';
}
+194
View File
@@ -0,0 +1,194 @@
import type { MessageElement } from '@snowluma/protocol/events';
import type { JsonObject } from './types';
import { cqUnescape } from './helper/cq';
// Send receipts and quoted timestamps can differ; never search outside the
// existing bounded drift window when resolving a quoted message.
export const PRIVATE_QUOTE_MAX_DRIFT_SECONDS = 30;
const TIME_WINDOWS = [0, 5, PRIVATE_QUOTE_MAX_DRIFT_SECONDS] as const;
interface QuotePreview {
text: string;
plain: boolean;
}
export interface PrivateQuoteCandidate {
id?: number;
sequence: number;
clientSequence: number;
timestamp: number;
preview: QuotePreview | null;
}
/** Identity of the containing message, which can never be its own quote target. */
export interface PrivateQuoteSource {
id: number;
sequence: number;
clientSequence: number;
timestamp: number;
sentBySelf: boolean;
}
export interface PrivateQuoteKey {
sequence: number;
sentBySelf: boolean;
timestamp?: number;
elements?: readonly MessageElement[];
exclude?: PrivateQuoteSource;
}
export function isUsableQuoteTime(timestamp: number | undefined): timestamp is number {
return (
timestamp !== undefined &&
Number.isSafeInteger(timestamp) &&
timestamp > 0 &&
timestamp < Number.MAX_SAFE_INTEGER
);
}
/** Sequence namespaces may overlap. Only a unique, consistent candidate wins. */
export function selectPrivateQuote<T extends PrivateQuoteCandidate>(
candidates: readonly T[],
quote: PrivateQuoteKey,
): { candidate: T | null; reason: 'sequence' | 'time' | 'ambiguous' | 'missing' } {
const time = isUsableQuoteTime(quote.timestamp) ? quote.timestamp : null;
const text =
quote.elements?.length && quote.elements.every((element) => element.type === 'text')
? quote.elements.map((element) => element.text ?? '').join('')
: '';
const nearby = candidates.filter((candidate) => {
const source = quote.exclude;
if (
source &&
(candidate.id === source.id ||
(source.sentBySelf === quote.sentBySelf &&
((source.sequence > 0 && candidate.sequence === source.sequence) ||
(source.clientSequence > 0 &&
candidate.clientSequence === source.clientSequence &&
candidate.timestamp === source.timestamp))))
) {
return false;
}
return (
time === null || Math.abs(candidate.timestamp - time) <= PRIVATE_QUOTE_MAX_DRIFT_SECONDS
);
});
// QQ can truncate the quote. Unknown and prefix-only previews remain
// competing candidates; only contradictory known content is excluded.
const compatible = nearby.filter(
(candidate) => !text || !candidate.preview || candidate.preview.text.startsWith(text),
);
const bySequence = compatible.filter(
(candidate) =>
quote.sequence > 0 &&
(candidate.clientSequence === quote.sequence || candidate.sequence === quote.sequence),
);
if (bySequence.length > 0) {
return {
candidate: bySequence.length === 1 ? bySequence[0]! : null,
reason: bySequence.length === 1 ? 'sequence' : 'ambiguous',
};
}
if (quote.sentBySelf && time !== null) {
for (const window of TIME_WINDOWS) {
const matches = compatible.filter(
(candidate) => Math.abs(candidate.timestamp - time) <= window,
);
if (matches.length > 0) {
const match = matches.length === 1 ? matches[0]! : null;
const windowCount = nearby.filter(
(candidate) => Math.abs(candidate.timestamp - time) <= window,
).length;
// A missing body cannot confirm quoted text. When content is the
// only discriminator, a truncated/media preview cannot prove it.
const confirmed =
match &&
(!text ||
(match.preview &&
(windowCount === 1 ||
(match.preview.plain && match.preview.text === text))));
return {
candidate: confirmed ? match : null,
reason: confirmed ? 'time' : 'ambiguous',
};
}
}
}
return { candidate: null, reason: 'missing' };
}
const labels: Readonly<Record<string, string>> = {
image: '[图片]',
record: '[语音]',
video: '[视频]',
file: '[文件]',
face: '[表情]',
mface: '[表情]',
forward: '[聊天记录]',
json: '[卡片]',
xml: '[卡片]',
};
export function cachedQuotePreview(data: string | null): QuotePreview | null {
if (!data) {
return null;
}
const event = JSON.parse(data) as JsonObject;
if (typeof event.message === 'string') {
return event.message.includes('[CQ:')
? null
: { text: cqUnescape(event.message), plain: true };
}
if (!Array.isArray(event.message) || event.message.length === 0) {
return null;
}
const parts: string[] = [];
let plain = true;
for (const segment of event.message as JsonObject[]) {
const body = segment?.data as JsonObject | undefined;
if (segment?.type === 'text' && typeof body?.text === 'string') {
parts.push(body.text);
} else if (typeof segment?.type === 'string' && Object.hasOwn(labels, segment.type)) {
parts.push(typeof body?.summary === 'string' ? body.summary : labels[segment.type]!);
plain = false;
} else {
return null;
}
}
return { text: parts.join(''), plain };
}
export function messageQuotePreview(elements: readonly MessageElement[]): QuotePreview | null {
const parts: string[] = [];
let plain = true;
for (const element of elements) {
if (element.type === 'text') {
parts.push(element.text ?? '');
} else if (Object.hasOwn(labels, element.type)) {
parts.push(
'summary' in element && typeof element.summary === 'string'
? element.summary
: labels[element.type]!,
);
plain = false;
} else {
return null;
}
}
return parts.length > 0 ? { text: parts.join(''), plain } : null;
}
+178
View File
@@ -0,0 +1,178 @@
import { SqliteDatabase, type SqliteStatement } from '@snowluma/sqlite';
import { openSqliteDb } from './sqlite-open';
export class ReactionStore {
private readonly db: SqliteDatabase;
private stmtUpsert!: SqliteStatement;
private stmtRemove!: SqliteStatement;
private stmtList!: SqliteStatement;
private stmtCount!: SqliteStatement;
private stmtCountByEmoji!: SqliteStatement;
readonly initialization: Promise<void>;
constructor(dbPath: string) {
this.db = openSqliteDb(dbPath);
this.initialization = this.db.serial(async () => {
await this.initSchema();
this.stmtUpsert = this.db.prepare(
`INSERT INTO reactions
(group_id, msg_seq, emoji_id, emoji_type, operator_uin, operator_uid, set_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(group_id, msg_seq, emoji_id, operator_uin) DO UPDATE SET
emoji_type = excluded.emoji_type,
operator_uid = excluded.operator_uid,
set_at = excluded.set_at`,
);
this.stmtRemove = this.db.prepare(
`DELETE FROM reactions
WHERE group_id = ? AND msg_seq = ? AND emoji_id = ? AND operator_uin = ?`,
);
this.stmtList = this.db.prepare(
`SELECT operator_uin, operator_uid, set_at
FROM reactions
WHERE group_id = ? AND msg_seq = ? AND emoji_id = ?
ORDER BY set_at ASC
LIMIT ? OFFSET ?`,
);
this.stmtCount = this.db.prepare(
`SELECT COUNT(*) AS n FROM reactions
WHERE group_id = ? AND msg_seq = ? AND emoji_id = ?`,
);
this.stmtCountByEmoji = this.db.prepare(
`SELECT emoji_id, emoji_type, COUNT(*) AS n, MAX(set_at) AS last_at
FROM reactions
WHERE group_id = ? AND msg_seq = ?
GROUP BY emoji_id`,
);
});
void this.initialization.catch(() => undefined);
}
private async initSchema(): Promise<void> {
await this.db.exec(`
CREATE TABLE IF NOT EXISTS reactions (
group_id INTEGER NOT NULL,
msg_seq INTEGER NOT NULL,
emoji_id TEXT NOT NULL,
emoji_type INTEGER NOT NULL DEFAULT 1,
operator_uin INTEGER NOT NULL,
operator_uid TEXT NOT NULL DEFAULT '',
set_at INTEGER NOT NULL,
PRIMARY KEY (group_id, msg_seq, emoji_id, operator_uin)
);
CREATE INDEX IF NOT EXISTS idx_reactions_msg_emoji
ON reactions (group_id, msg_seq, emoji_id, set_at);
`);
}
async recordAdd(
groupId: number,
msgSeq: number,
emojiId: string,
emojiType: number,
operatorUin: number,
operatorUid: string,
setAt: number,
): Promise<void> {
return this.db.serial(async () => {
await this.initialization;
if (!groupId || !msgSeq || !emojiId || !operatorUin) {
return;
}
await this.stmtUpsert.run(
groupId,
msgSeq,
emojiId,
emojiType,
operatorUin,
operatorUid,
setAt,
);
});
}
async recordRemove(
groupId: number,
msgSeq: number,
emojiId: string,
operatorUin: number,
): Promise<void> {
return this.db.serial(async () => {
await this.initialization;
if (!groupId || !msgSeq || !emojiId || !operatorUin) {
return;
}
await this.stmtRemove.run(groupId, msgSeq, emojiId, operatorUin);
});
}
async listUsers(
groupId: number,
msgSeq: number,
emojiId: string,
limit = 20,
offset = 0,
): Promise<Array<{ operatorUin: number; operatorUid: string; setAt: number }>> {
return this.db.serial(async () => {
await this.initialization;
const rows = (await this.stmtList.all(
groupId,
msgSeq,
emojiId,
Math.max(1, Math.min(limit, 500)),
Math.max(0, offset),
)) as Array<{ operator_uin: number; operator_uid: string; set_at: number }>;
return rows.map((r) => ({
operatorUin: r.operator_uin,
operatorUid: r.operator_uid,
setAt: r.set_at,
}));
});
}
async countUsers(groupId: number, msgSeq: number, emojiId: string): Promise<number> {
return this.db.serial(async () => {
await this.initialization;
const row = (await this.stmtCount.get(groupId, msgSeq, emojiId)) as
{ n: number } | undefined;
return row?.n ?? 0;
});
}
/** Summary of all cached reactions on a message, grouped by emoji. */
async summarizeMessage(
groupId: number,
msgSeq: number,
): Promise<Array<{ emojiId: string; emojiType: number; count: number; lastSetAt: number }>> {
return this.db.serial(async () => {
await this.initialization;
const rows = (await this.stmtCountByEmoji.all(groupId, msgSeq)) as Array<{
emoji_id: string;
emoji_type: number;
n: number;
last_at: number;
}>;
return rows.map((r) => ({
emojiId: r.emoji_id,
emojiType: r.emoji_type,
count: r.n,
lastSetAt: r.last_at,
}));
});
}
async close(): Promise<void> {
await this.initialization.catch(() => undefined);
await this.db.close();
}
}
+82
View File
@@ -0,0 +1,82 @@
import type { MessageElement } from '@snowluma/protocol/events';
import { MessageElementValidationError } from '@snowluma/protocol/element-manifest';
const labels: Readonly<Record<string, string>> = {
image: '[图片]',
record: '[语音]',
video: '[视频]',
file: '[文件]',
face: '[表情]',
mface: '[表情]',
forward: '[聊天记录]',
json: '[卡片]',
xml: '[卡片]',
markdown: '[Markdown]',
};
function previewLabel(type: string): string {
if (!Object.hasOwn(labels, type)) {
throw new MessageElementValidationError(
'UNSENDABLE_TYPE',
`message segment "${type}" is not supported in a reply preview`,
type,
);
}
return labels[type]!;
}
export function assertReplyPreviewType(type: string): void {
if (type !== 'text' && type !== 'at') {
previewLabel(type);
}
}
/** Quotes carry display text; media must not be uploaded again for a preview. */
export function elementsToReplyPreview(elements: readonly MessageElement[]): MessageElement[] {
const text = elements
.map((element) => {
if (element.type === 'text') {
return element.text;
}
if (element.type === 'at') {
return element.targetUin === 0 ? '@全体成员' : `@${element.targetUin}`;
}
return previewLabel(element.type);
})
.join('');
return [{ type: 'text', text }];
}
export function replyPreviewElements(message: unknown): MessageElement[] {
if (!Array.isArray(message)) {
return [];
}
const parts: string[] = [];
for (const segment of message) {
if (!segment || typeof segment !== 'object') {
continue;
}
const data = segment.data;
if (!data || typeof data !== 'object') {
continue;
}
if (segment.type === 'text' && typeof data.text === 'string') {
parts.push(data.text);
} else if (segment.type === 'at') {
parts.push(data.qq === 'all' ? '@全体成员' : `@${data.qq ?? ''}`);
} else if (typeof segment.type === 'string' && Object.hasOwn(labels, segment.type)) {
parts.push(labels[segment.type]!);
}
}
const text = parts.join('');
return text ? [{ type: 'text', text }] : [];
}
+66
View File
@@ -0,0 +1,66 @@
import type { JsonObject } from './types';
export const SELF_SENT_ECHO_WINDOW_MS = 120_000;
/**
* Match a reported self send with a later QQ echo, including repeated
* pushes. The message hash alone is insufficient because it is a signed 32-bit
* value and can collide; sequence, conversation and a bounded time window are
* part of the identity as well.
*/
export function sameSelfSentMessage(left: JsonObject, right: JsonObject): boolean {
const leftKey = selfSentEventKey(left);
const rightKey = selfSentEventKey(right);
if (leftKey === null || leftKey !== rightKey) {
return false;
}
const leftTime = toInt(left.time);
const rightTime = toInt(right.time);
return (
leftTime > 0 &&
rightTime > 0 &&
Math.abs(leftTime - rightTime) * 1000 <= SELF_SENT_ECHO_WINDOW_MS
);
}
/** Stable identity for a self-sent event, excluding its bounded timestamp. */
export function selfSentEventKey(event: JsonObject): string | null {
if (event.post_type !== 'message_sent') {
return null;
}
if (event.message_type !== 'private' && event.message_type !== 'group') {
return null;
}
const messageId = toInt(event.message_id);
const sequence = toInt(event.message_seq);
const session =
event.message_type === 'group'
? toInt(event.group_id)
: toInt(event.target_id) || toInt(event.user_id);
if (messageId === 0 || sequence <= 0 || session <= 0) {
return null;
}
return `${event.message_type}:${session}:${sequence}:${messageId}`;
}
function toInt(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.trunc(value);
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return Math.trunc(parsed);
}
}
return 0;
}
+6
View File
@@ -0,0 +1,6 @@
import { SqliteDatabase } from '@snowluma/sqlite';
/** Opens asynchronously with the shared WAL, durability and busy-timeout defaults. */
export function openSqliteDb(dbPath: string): SqliteDatabase {
return new SqliteDatabase(dbPath);
}
+368
View File
@@ -0,0 +1,368 @@
import { errorMessage } from '@snowluma/common/errors';
import { createLogger } from '@snowluma/common/logger';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const log = createLogger('OneBot.Stream');
export interface StreamStorageSnapshot {
totalBytes: number;
fileCount: number;
activeItemCount: number;
}
export interface StreamStorageCleanupResult {
deletedFiles: number;
freedBytes: number;
skippedActiveItems: number;
failures: Array<{ item: string; message: string }>;
}
export class StreamStorage {
readonly root: string;
readonly uploadDir: string;
readonly downloadDir: string;
private readonly activeItems = new Map<number, string[]>();
private nextActiveItemId = 1;
constructor(root: string) {
this.root = resolveManagedRoot(root);
this.uploadDir = path.join(this.root, 'upload');
this.downloadDir = path.join(this.root, 'download');
}
/**
* Create a directory inside the managed root without following a substituted
* directory link. Existing directories remain usable when they belong to the
* current account and cannot be modified by other local accounts.
*/
ensureDirectory(directory: string): void {
const resolved = path.resolve(directory);
if (resolved !== this.root && !isStrictDescendant(this.root, resolved)) {
throw new Error(`stream directory is outside the managed root: ${directory}`);
}
let current = this.root;
ensureManagedDirectory(current);
const relative = path.relative(this.root, resolved);
if (!relative) {
return;
}
for (const segment of relative.split(path.sep)) {
current = path.join(current, segment);
ensureManagedDirectory(current);
}
}
registerActiveItem(paths: string[]): () => void {
const normalized = paths.map((item) => {
const resolved = path.resolve(item);
if (!isStrictDescendant(this.root, resolved)) {
throw new Error(`active stream path is outside the managed root: ${item}`);
}
return resolved;
});
const id = this.nextActiveItemId++;
this.activeItems.set(id, normalized);
let released = false;
return () => {
if (released) {
return;
}
released = true;
this.activeItems.delete(id);
};
}
snapshot(): StreamStorageSnapshot {
if (!this.assertRootDirectoryOrMissing()) {
return {
totalBytes: 0,
fileCount: 0,
activeItemCount: this.activeItems.size,
};
}
let totalBytes = 0;
let fileCount = 0;
this.walkFiles(this.root, (stat) => {
totalBytes += stat.size;
fileCount += 1;
});
return { totalBytes, fileCount, activeItemCount: this.activeItems.size };
}
clearInactive(): StreamStorageCleanupResult {
const result: StreamStorageCleanupResult = {
deletedFiles: 0,
freedBytes: 0,
skippedActiveItems: this.activeItems.size,
failures: [],
};
if (!this.assertRootDirectoryOrMissing()) {
return result;
}
const activePaths = [...this.activeItems.values()].flat();
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(this.root, { withFileTypes: true });
} catch (error) {
result.failures.push({ item: '.', message: this.publicErrorMessage(error) });
return result;
}
for (const entry of entries) {
this.clearNode(path.join(this.root, entry.name), activePaths, result);
}
return result;
}
private assertRootDirectoryOrMissing(): boolean {
try {
const stat = fs.lstatSync(this.root);
assertManagedDirectory(this.root, stat);
return true;
} catch (error) {
if (isMissing(error)) {
return false;
}
throw error;
}
}
private walkFiles(dir: string, visit: (stat: fs.Stats) => void): void {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const item = path.join(dir, entry.name);
const stat = fs.lstatSync(item);
if (stat.isDirectory()) {
this.walkFiles(item, visit);
} else {
visit(stat);
}
}
}
private clearNode(
item: string,
activePaths: string[],
result: StreamStorageCleanupResult,
): void {
const resolved = path.resolve(item);
if (activePaths.some((active) => isSameOrDescendant(active, resolved))) {
return;
}
let stat: fs.Stats;
try {
stat = fs.lstatSync(resolved);
} catch (error) {
if (!isMissing(error)) {
result.failures.push({
item: this.displayPath(resolved),
message: this.publicErrorMessage(error),
});
}
return;
}
if (!stat.isDirectory()) {
try {
fs.unlinkSync(resolved);
result.deletedFiles += 1;
result.freedBytes += stat.size;
} catch (error) {
if (!isMissing(error)) {
result.failures.push({
item: this.displayPath(resolved),
message: this.publicErrorMessage(error),
});
}
}
return;
}
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(resolved, { withFileTypes: true });
} catch (error) {
result.failures.push({
item: this.displayPath(resolved),
message: this.publicErrorMessage(error),
});
return;
}
for (const entry of entries) {
this.clearNode(path.join(resolved, entry.name), activePaths, result);
}
try {
if (fs.readdirSync(resolved).length === 0) {
fs.rmdirSync(resolved);
}
} catch (error) {
if (!isMissing(error)) {
result.failures.push({
item: this.displayPath(resolved),
message: this.publicErrorMessage(error),
});
}
}
}
private displayPath(item: string): string {
const relative = path.relative(this.root, item);
return relative || '.';
}
private publicErrorMessage(error: unknown): string {
return errorMessage(error).split(this.root).join('[临时目录]');
}
}
function isStrictDescendant(root: string, candidate: string): boolean {
const relative = path.relative(root, candidate);
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
}
function isSameOrDescendant(root: string, candidate: string): boolean {
return root === candidate || isStrictDescendant(root, candidate);
}
function isMissing(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
}
function assertManagedDirectory(directory: string, stat: fs.Stats): void {
if (stat.isSymbolicLink()) {
throw new Error(`managed stream directory must not be a symbolic link: ${directory}`);
}
if (!stat.isDirectory()) {
throw new Error(`managed stream path is not a directory: ${directory}`);
}
const currentUid = typeof process.getuid === 'function' ? process.getuid() : undefined;
if (currentUid !== undefined && stat.uid !== currentUid) {
throw new Error(`managed stream directory is owned by another account: ${directory}`);
}
if (process.platform !== 'win32' && (stat.mode & 0o022) !== 0) {
throw new Error(`managed stream directory is writable by other accounts: ${directory}`);
}
}
function ensureManagedDirectory(directory: string): void {
let stat: fs.Stats;
try {
stat = fs.lstatSync(directory);
} catch (error) {
if (!isMissing(error)) {
throw error;
}
try {
fs.mkdirSync(directory, { mode: 0o700 });
} catch (mkdirError) {
if (!(
mkdirError instanceof Error &&
'code' in mkdirError &&
mkdirError.code === 'EEXIST'
)) {
throw mkdirError;
}
}
stat = fs.lstatSync(directory);
}
assertManagedDirectory(directory, stat);
}
function resolveManagedRoot(input: string): string {
const absolute = path.resolve(input);
try {
const stat = fs.lstatSync(absolute);
assertManagedDirectory(absolute, stat);
return fs.realpathSync(absolute);
} catch (error) {
if (!isMissing(error)) {
throw error;
}
}
const parent = path.dirname(absolute);
const parentStat = fs.statSync(parent);
if (!parentStat.isDirectory()) {
throw new Error(`managed stream parent is not a directory: ${parent}`);
}
return path.join(fs.realpathSync(parent), path.basename(absolute));
}
export function createStreamStorage(env: NodeJS.ProcessEnv = process.env): StreamStorage {
const fallback = path.join(os.tmpdir(), 'onebot-stream');
const raw = env.SNOWLUMA_STREAM_DIR?.trim();
if (!raw) {
return new StreamStorage(fallback);
}
try {
return new StreamStorage(raw);
} catch (error) {
log.warn(
'SNOWLUMA_STREAM_DIR=%j is ignored: %s',
raw,
error instanceof Error ? error.message : String(error),
);
return new StreamStorage(fallback);
}
}
export const streamStorage = createStreamStorage();
export const STREAM_ROOT = streamStorage.root;
export const STREAM_UPLOAD_DIR = streamStorage.uploadDir;
export const STREAM_DOWNLOAD_DIR = streamStorage.downloadDir;
export function registerActiveStreamItem(paths: string[]): () => void {
return streamStorage.registerActiveItem(paths);
}
export function ensureStreamDirectory(directory: string): void {
streamStorage.ensureDirectory(directory);
}
export function snapshotStreamStorage(): StreamStorageSnapshot {
return streamStorage.snapshot();
}
export function clearInactiveStreamStorage(): StreamStorageCleanupResult {
return streamStorage.clearInactive();
}
+109
View File
@@ -0,0 +1,109 @@
// streaming — Stream API plumbing (#163, NapCat-compatible).
//
// A handful of OneBot actions ("Stream API") answer a single request with
// MANY response frames instead of one: an upload acks each chunk, a download
// pushes the file out chunk-by-chunk. SnowLuma's normal dispatch is strictly
// one-request → one-response, so streaming needs a small seam:
//
// • a `StreamSink` the action calls to emit intermediate frames, and
// • per-adapter wiring that writes each frame on the wire (HTTP chunked /
// repeated WS messages) instead of closing after the first.
//
// Wire format mirrors NapCat exactly so its client scripts work unchanged:
// every frame is an OB11 envelope carrying `stream:'stream-action'`, and the
// frame `data` is a StreamPacket whose `type` is one of stream/response/error.
import type { ApiResponse, JsonValue } from './types';
/** Marks an OB11 envelope as one frame of a streaming response. */
export const STREAM_MARK = 'stream-action';
/** Frame `type` discriminator carried inside each frame's `data`. */
export enum StreamStatus {
/** An intermediate chunk frame (transfer in progress). */
Stream = 'stream',
/** The terminal frame — the stream finished successfully. */
Response = 'response',
/** A stream was reset / aborted by the caller. */
Reset = 'reset',
/** The terminal frame — the stream failed. */
Error = 'error',
}
/** A sink an action uses to push intermediate stream frames. Each `send`
* carries the raw frame DATA (a StreamPacket: `{ type, data_type, ... }`);
* the network adapter wraps it in an OB11 envelope + the stream marker and
* flushes it on the wire. The action's eventual return value is the terminal
* frame, so do NOT `send` the final result through the sink. */
export interface StreamSink {
send(frame: JsonValue): Promise<void>;
}
export class StreamTransportClosedError extends Error {
constructor(message = 'stream transport closed') {
super(message);
this.name = 'StreamTransportClosedError';
}
}
/** Fallback sink for when a stream action is invoked over a transport that
* can't stream (or by a plain client): intermediate frames are dropped, the
* terminal response is still returned normally. */
export const NOOP_SINK: StreamSink = {
send: async () => {
/* dropped */
},
};
/** Wrap a raw stream-frame `data` payload into an OB11 envelope carrying the
* stream marker (mirrors NapCat's `OB11Response.ok(data, echo, true)`). The
* empty `message`/`wording` pair matches NapCat — clients reading either key
* on an intermediate frame get `''`, never `undefined`. */
export function wrapStreamFrame(data: JsonValue, echo: JsonValue | undefined): ApiResponse {
const frame: ApiResponse = {
status: 'ok',
retcode: 0,
data,
message: '',
wording: '',
stream: STREAM_MARK,
};
if (echo !== undefined) {
frame.echo = echo;
}
return frame;
}
/** Turn an action's terminal `ApiResponse` into the final streaming frame:
* attach the stream marker + echo, mirror `wording` into `message` (NapCat
* sets both), and — on failure — normalise `data` to an error packet
* (`{ type:'error' }`) so NapCat clients see the error `type` while the
* human-readable reason stays in `message`/`wording`.
*
* Contract: on success the action's `data` MUST carry `type:'response'` (the
* client treats that frame as the stream terminator). Stream actions are
* responsible for that, mirroring NapCat's `BaseDownloadStream`. */
export function wrapStreamTerminal(
response: ApiResponse,
echo: JsonValue | undefined,
): ApiResponse {
const frame: ApiResponse = { ...response, stream: STREAM_MARK };
if (echo !== undefined) {
frame.echo = echo;
}
if (response.status === 'failed') {
frame.data = { type: StreamStatus.Error, data_type: 'error' };
}
frame.message = frame.wording ?? '';
if (frame.wording === undefined) {
frame.wording = '';
}
return frame;
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Records the group temp sessions the bot has received a message from, so a
* reply can be limited to sessions the peer opened. Written only on the receive
* path, read only on the send path. Keyed by `${userUin}:${groupUin}` — a user
* may hold sessions from several groups. Entries expire after
* {@link TEMP_SESSION_TTL_MS}.
*/
/** Reply window: 7 days after the last inbound temp message. */
export const TEMP_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export class TempSessionStore {
/** key → expiry epoch ms. */
private readonly sessions = new Map<string, number>();
private static key(userUin: number, groupUin: number): string {
return `${userUin}:${groupUin}`;
}
/**
* Record (or refresh) an inbound temp session. Called from the receive path.
* No-op without a source group (groupUin<=0) — the session stays receive-only.
*/
record(userUin: number, groupUin: number, now: number = Date.now()): void {
if (userUin <= 0 || groupUin <= 0) {
return;
}
// Opportunistically prune expired entries here — `has` only removes keys it
// reads, so a stream of never-replied sessions would otherwise grow the map
// unbounded.
this.sweep(now);
this.sessions.set(TempSessionStore.key(userUin, groupUin), now + TEMP_SESSION_TTL_MS);
}
/** True iff a reply is allowed for this exact (user, group). Prunes on read. */
has(userUin: number, groupUin: number, now: number = Date.now()): boolean {
const key = TempSessionStore.key(userUin, groupUin);
const expiresAt = this.sessions.get(key);
if (expiresAt === undefined) {
return false;
}
if (expiresAt <= now) {
this.sessions.delete(key);
return false;
}
return true;
}
/** Drop all expired entries. `has` also prunes lazily. */
sweep(now: number = Date.now()): void {
for (const [key, expiresAt] of this.sessions) {
if (expiresAt <= now) {
this.sessions.delete(key);
}
}
}
}
+200
View File
@@ -0,0 +1,200 @@
import type { JsonObject, JsonValue } from '@snowluma/common/json';
export interface ApiResponse {
status: 'ok' | 'failed';
retcode: number;
data: JsonValue;
echo?: JsonValue;
wording?: string;
/** Mirror of `wording` carried on Stream API frames — NapCat puts the
* message text in BOTH `message` and `wording`, so clients reading either
* key work. Only set on stream frames. */
message?: string;
/** Only set on Stream API frames — `'stream-action'`. Marks an envelope as
* one frame of a multi-frame streaming response (NapCat Stream API wire). */
stream?: string;
}
export interface OneBotRequest {
action: string;
params?: JsonObject;
echo?: JsonValue;
}
export type WsRole = 'Api' | 'Event' | 'Universal';
export type MessageFormat = 'array' | 'string';
export type NetworkKind = 'httpServers' | 'httpClients' | 'wsServers' | 'wsClients';
export interface NetworkBase {
name: string;
/** When `false`, the adapter is configured but inactive. Defaults to `true`. */
enabled?: boolean;
accessToken?: string;
/** Output format for this adapter. */
messageFormat: MessageFormat;
/** When `true`, this adapter receives `post_type='message_sent'` self events. */
reportSelfMessage: boolean;
}
/**
* Inbound WebSocket frame cap, shared by the three adapters that read WebSocket
* frames: `wsServers`, `wsClients`, and `httpServers` with `enableWebSocket`.
* It is deliberately not part of {@link NetworkBase}: `httpClients` has no
* WebSocket transport, and a saved-but-ignored option is worse than none.
*/
export interface WsFrameLimitConfig {
/**
* Cap on one inbound WebSocket message, in bytes (all fragments included).
* Omitted means the transport default of 100 MiB
* (`DEFAULT_MAX_PAYLOAD_BYTES`).
*
* Frames larger than the cap close the connection with 1009. A OneBot peer
* that sends media inline has to fit the whole payload into one frame: at
* 44.1 kHz stereo 16-bit PCM (176 400 B/s), 100 MiB is ~445 s (7:26), so
* longer audio must arrive as a URL/file reference or be compressed (SILK).
* Raising this value raises peak memory by the frame size.
*/
maxPayloadBytes?: number;
}
export interface HttpServerNetwork extends NetworkBase, WsFrameLimitConfig {
host?: string;
port: number;
path?: string;
/** Accept OneBot WebSocket clients on the same HTTP listener. */
enableWebSocket?: boolean;
/** Serve OneBot events at <path>/_events, with bounded reconnect replay. */
enableSse?: boolean;
}
export interface HttpClientNetwork extends NetworkBase {
url: string;
timeoutMs?: number;
}
export interface WsServerNetwork extends NetworkBase, WsFrameLimitConfig {
host?: string;
port: number;
path?: string;
role?: WsRole;
}
export interface WsClientNetwork extends NetworkBase, WsFrameLimitConfig {
url: string;
role?: WsRole;
reconnectIntervalMs?: number;
}
export interface OneBotNetworks {
httpServers: HttpServerNetwork[];
httpClients: HttpClientNetwork[];
wsServers: WsServerNetwork[];
wsClients: WsClientNetwork[];
}
/**
* Built-in status command settings. The trigger word is configurable;
* defaults to `#sl` with exact case-insensitive matching.
*/
export interface StatusCommandConfig {
/** Master on/off. Default `true`. */
enabled: boolean;
/**
* When `true`, a matched status command is NOT forwarded to downstream
* adapters (it is still cached, logged, and replied to). Default `false`.
*/
swallow: boolean;
/** Per-conversation reply cooldown in seconds. `0` disables it. Default `5`. */
cooldownSeconds: number;
/** Trigger word. Default `'#sl'`. Non-empty, max 32 chars. */
trigger: string;
}
/**
* Remote rkey fallback. QQ-NT image/file download URLs need a short-lived,
* server-issued `rkey`; SnowLuma fetches it via OIDB 0x9067_202. On accounts
* where that native fetch persistently returns nothing, every image would be
* served as a bare URL the CDN rejects with `invalid rkey` (#156). When
* `fallbackServers` is non-empty, SnowLuma asks those HTTP endpoints for an
* rkey instead. OFF by default (empty list): no third-party server is ever
* contacted unless you opt in by configuring your own endpoint.
*/
export interface RKeyConfig {
/**
* HTTP(S) endpoints returning `{ group_rkey, private_rkey, expired_time }`
* (NapCat rkey-server format; an OneBot `{ retcode, data }` wrapper is also
* accepted). Tried in order until one yields a usable rkey. Default `[]`.
*/
fallbackServers: string[];
}
/** Conservative, login-scoped cloud history backfill. */
export interface HistorySyncConfig {
/** Disabled by default. Changes take effect on the next account lifecycle. */
enabled: boolean;
}
/** Per-UIN OneBot configuration. */
export interface OneBotConfig {
networks: OneBotNetworks;
/** Built-in `#sl` status command settings. Always present after normalization. */
statusCommand: StatusCommandConfig;
/** Silent login-time history backfill. Always present after normalization. */
historySync: HistorySyncConfig;
/** Which GLOBAL notification channels this account opts into (channel ids are
* validated slugs; channels themselves live in config/notifications.json).
* Always present after normalization. */
notifications?: { channelIds: string[] };
}
export interface MessageMeta {
isGroup: boolean;
targetId: number;
sequence: number;
/** Whether `sequence` was confirmed by QQ and is safe for protocol actions. */
sequenceAuthoritative: boolean;
eventName: string;
clientSequence: number;
/** Sender direction for friend messages; absent for groups/temp/legacy rows. */
privateDirection?: 'incoming' | 'outgoing';
random: number;
timestamp: number;
}
/** True only when a stored sequence can safely be sent back to QQ. */
export function hasAuthoritativeSequence(
meta: MessageMeta | null | undefined,
): meta is MessageMeta {
return (
meta?.sequenceAuthoritative === true && Number.isInteger(meta.sequence) && meta.sequence > 0
);
}
export const RETCODE = {
ACTION_FAILED: 100,
INTERNAL_ERROR: 1200,
BAD_REQUEST: 1400,
UNKNOWN_ACTION: 1404,
} as const;
export function okResponse(data: JsonValue = null): ApiResponse {
return {
status: 'ok',
retcode: 0,
data,
};
}
export function failedResponse(retcode: number, wording: string): ApiResponse {
return {
status: 'failed',
retcode,
data: null,
wording,
};
}
export type { JsonArray, JsonObject, JsonPrimitive, JsonValue } from '@snowluma/common/json';