Files
DrSmoothl dafe2082f1 fix(onebot): preserve public Qzone input semantics
Keep comment and reply text unchanged and support structured input in the debug console. Report malformed input before sending a request.

Refs #587
2026-10-25 23:26:55 +08:00

965 lines
34 KiB
TypeScript

// 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>>);
}