// 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 = { 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 = Ok | Err; const ok = (value: T): Ok => ({ 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; /** 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 { readonly doc: FieldDoc; /** Coerce the raw value at this field's key (MISSING when the key is absent). */ coerce(raw: Raw, field: string): CoerceResult; /** Absent ⇒ value is `undefined`; a present value still must coerce. */ optional(): Field; /** 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; describe(text: string): Field; /** 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; /** 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 = (raw: JsonValue, field: string) => CoerceResult; class FieldImpl implements Field { constructor( private readonly core: Core, readonly doc: FieldDoc, ) {} coerce(raw: Raw, field: string): CoerceResult { 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 { return new FieldImpl(this.core as Core, { ...this.doc, required: false, default: undefined, }); } default(value: T): Field { return new FieldImpl(this.core, { ...this.doc, required: false, default: value as JsonValue, }); } describe(text: string): Field { return new FieldImpl(this.core, { ...this.doc, desc: text }); } role(role: FieldRole): Field { return new FieldImpl(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 { 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 { 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( el: Field, mustBeNonEmpty: boolean, ): Field & { nonEmpty(): Field } { const core: Core = (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(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 { return new FieldImpl(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 { return new FieldImpl(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 { return new FieldImpl(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 { return new FieldImpl(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 { return new FieldImpl(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 { return new FieldImpl(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 { return new FieldImpl(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 { return new FieldImpl(stringCore({ allowEmpty: false }), { type: 'string', required: true, role: 'file', schema: strSchema({ allowEmpty: false }), }); }, /** Image source (path / URL / base64) → FileSource control. */ image(): Field { return new FieldImpl(stringCore({ allowEmpty: false }), { type: 'string', required: true, role: 'image', schema: strSchema({ allowEmpty: false }), }); }, /** Voice/record source → FileSource control. */ record(): Field { return new FieldImpl(stringCore({ allowEmpty: false }), { type: 'string', required: true, role: 'record', schema: strSchema({ allowEmpty: false }), }); }, /** Video source → FileSource control. */ video(): Field { return new FieldImpl(stringCore({ allowEmpty: false }), { type: 'string', required: true, role: 'video', schema: strSchema({ allowEmpty: false }), }); }, /** Seconds (int ≥ 0) → duration control. */ duration(): Field { return new FieldImpl(intCore('an integer', { min: 0 }), { type: 'int', required: true, role: 'duration', schema: intSchema({ min: 0 }), }); }, /** Unix timestamp (int ≥ 0) → datetime control. */ timestamp(): Field { return new FieldImpl(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 { return new FieldImpl(intCore('a non-negative integer', { min: 0 }), { type: 'int', required: true, role: 'face_id', schema: intSchema({ min: 0 }), }); }, /** Finite number (fractions allowed). */ number(): Field { return new FieldImpl( (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 { return new FieldImpl(stringCore(opts), { type: 'string', required: true, schema: strSchema(opts), }); }, /** true/1/yes/on ⇒ true; false/0/no/off ⇒ false (matches legacy `asBoolean`). */ bool(): Field { return new FieldImpl( (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 { return new FieldImpl( (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 { return new FieldImpl( (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 { return new FieldImpl( (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(el: Field): Field & { nonEmpty(): Field } { return arrayField(el, false); }, /** Constrained literal set. */ enum(...values: V): Field { return new FieldImpl( (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 { return new FieldImpl((raw) => ok(raw), { type: 'raw', required: false, default: undefined, schema: {}, }); }, }; // ─────────────────────────── type inference ─────────────────────────── type Spec = Record>; type FieldType = F extends Field ? T : never; /** Post-validation every key is present; optional/raw fields carry `undefined` * in their value type. */ export type InferParams = { [K in keyof S]: FieldType }; // ─────────────────────────── cross-field rules ─────────────────────────── export interface CrossFieldRule { readonly doc: string; check(params: Record): Err | null; } type Key

= Extract; type Group

= Key

| Key

[]; export interface RuleBuilders

{ /** 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

[]): CrossFieldRule; atLeastOneOf(...keys: Key

[]): CrossFieldRule; requiredTogether(...keys: Key

[]): CrossFieldRule; mutuallyExclusive(...keys: Key

[]): CrossFieldRule; /** Arbitrary predicate; fails with `doc` as the reason. */ rule(doc: string, ok: (p: P) => boolean): CrossFieldRule; } const present = (p: Record, key: string): boolean => p[key] !== undefined; const groupOk = (p: Record, 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> = { 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; /** * 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 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>; /** Bind ctx and produce the handler ActionRegistry.bind installs. */ toHandler(ctx: ApiActionContext): Handler; } interface ActionDef { 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>) => readonly CrossFieldRule[]; run: (p: InferParams, ctx: ApiActionContext) => Promise | ApiResponse; } const wording = (e: Err): string => (e.field ? `${e.field}: ${e.reason}` : e.reason); export function defineAction(def: ActionDef): ActionSpec { 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>) : []; const parse = (raw: JsonObject): CoerceResult> => { const out: Record = {}; 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); }; 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 = {}; 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 { name: string | readonly [string, ...string[]]; summary?: string; returns?: string; returnsSchema?: JsonSchema; examples?: readonly ActionExample[]; readOnly?: boolean; params: S; rules?: (r: RuleBuilders>) => readonly CrossFieldRule[]; run: ( p: InferParams, ctx: ApiActionContext, sink: StreamSink, ) => Promise | ApiResponse; } export interface StreamActionSpec extends RegisteredActionSpec { readonly kind: 'stream'; readonly names: readonly string[]; readonly params: S; parse(raw: JsonObject): CoerceResult>; toHandler(ctx: ApiActionContext): Handler; } export function defineStreamAction(def: StreamActionDef): StreamActionSpec { // Borrow defineAction's parse + describe (run is never invoked — register // below installs the sink-aware handler instead). const base = defineAction({ 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 & { group_id: Field }; type WithGroupUser = S & { group_id: Field; user_id: Field }; interface PresetDef { name: string | readonly [string, ...string[]]; summary?: string; returns?: string; returnsSchema?: JsonSchema; examples?: readonly ActionExample[]; readOnly?: boolean; params?: S; rules?: (r: RuleBuilders & Extra>) => readonly CrossFieldRule[]; run: (p: InferParams & Extra, ctx: ApiActionContext) => Promise | ApiResponse; } /** Pre-seeds `group_id` (uint, required). */ export function groupAction( def: PresetDef, ): ActionSpec> { const params = { group_id: f.groupId().describe('群号'), ...(def.params ?? {}), } as WithGroup; return defineAction({ ...def, params } as unknown as ActionDef>); } /** Pre-seeds `group_id` + `user_id` (both uint, required). */ export function groupUserAction( def: PresetDef, ): ActionSpec> { 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; return defineAction({ ...def, params } as unknown as ActionDef>); }