SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,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';
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -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();
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -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);
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -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,
|
||||
),
|
||||
});
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -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([]);
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -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);
|
||||
@@ -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 });
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -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();
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -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)),
|
||||
}),
|
||||
];
|
||||
@@ -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);
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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),
|
||||
}),
|
||||
}),
|
||||
];
|
||||
@@ -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
Vendored
+1
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/\[/g, '[')
|
||||
.replace(/\]/g, ']')
|
||||
.replace(/,/g, ',');
|
||||
}
|
||||
|
||||
export function cqUnescape(text: string): string {
|
||||
// Order matters: `&` MUST be last so that, e.g., `&#91;` keeps
|
||||
// the literal `[` instead of decoding twice into `[`.
|
||||
return text
|
||||
.replace(/[/g, '[')
|
||||
.replace(/]/g, ']')
|
||||
.replace(/,/g, ',')
|
||||
.replace(/&/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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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('[', '[')
|
||||
.replaceAll(']', ']')
|
||||
.replaceAll(',', ',')
|
||||
.replaceAll('&', '&');
|
||||
}
|
||||
}
|
||||
|
||||
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}秒`;
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 }] : [];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
Reference in new issue
Block a user