chore: initial commit

This commit is contained in:
2026-10-22 17:07:08 +08:00
commit 0acef64f48
200 changed files with 35546 additions and 0 deletions
+820
View File
@@ -0,0 +1,820 @@
import {randomUUID} from 'node:crypto';
import type {log} from '../../types/utils/logger.ts';
import {Logger, type LogHistoryRecord} from '../../utils/logger.ts';
import {get_project_root_path} from '../../utils/path_utils.ts';
import * as path from 'node:path';
import {local_command_completion} from '../../utils/command_completion.ts';
import {get_bot_context, observe_bot_context} from '../instance/index.ts';
import type {bot_status} from '../../types/bot/instance/bot_runtime.ts';
import type {InfoManager, InfoSnapshot, InfoUpdate} from '../info/init.ts';
import type {FunctionManager} from '../function/init.ts';
import type {RunSnapshot, StartResult, WorkflowManager, WorkflowCommand, WorkflowRuntimeSnapshot} from '../../types/bot/workflow/runtime.ts';
import type {WorkflowCatalog} from '../../types/bot/workflow/metadata.ts';
import {get_workflow_catalog} from '../workflow/catalog.ts';
import {summarize_run, type RunSummary} from '../workflow/runtime/summary.ts';
import {object, validate_workflow_payload} from '../../utils/forward_protocol.ts';
export interface StreamContext {
readonly bot_status: bot_status;
readonly bot_instance: {
chat(message: string): unknown;
players?: Record<string, unknown>;
tabComplete?(text: string, assumeCommand?: boolean, sendBlockInSight?: boolean, timeout?: number): Promise<unknown[]>;
_client?: {
protocolVersion?: number;
state?: string;
_mcBundle?: Array<{data?: {params?: {transactionId?: number}}; metadata?: {name?: string}}>;
write(name: string, payload: unknown): void;
on(name: string, listener: (packet: any) => void): unknown;
off(name: string, listener: (packet: any) => void): unknown;
};
} | null;
readonly logger?: {read_recent_log(count: number): Promise<LogHistoryRecord[]>};
readonly info_manager?: Pick<InfoManager, 'get_all_info'> | null;
readonly function_manager?: Pick<FunctionManager, 'run'> | null;
readonly workflow_manager?: Pick<WorkflowManager, 'start' | 'stop' | 'get_snapshot' | 'get_snapshots'>
& Partial<Pick<WorkflowManager, 'control' | 'get_command' | 'get_runtime_snapshot'>> | null;
log?(segments: log[]): void;
on(event: any, callback: (...args: any[]) => void): unknown;
off(event: any, callback: (...args: any[]) => void): unknown;
}
export interface StreamContextSource {
get(bot_id: number): StreamContext | undefined;
observe(bot_id: number, callback: (context: StreamContext | undefined) => void): () => void;
}
export interface LogSegment {
message: string;
font_color: string;
font_style: Array<'normal' | 'italic' | 'bold' | 'light' | 'underline' | 'url'>;
hoverEvent?: log['hoverEvent'];
clickEvent?: log['clickEvent'];
insertion?: string;
}
export interface BotLogRecord {
id: string;
timestamp: number;
context_generation: string | null;
seq: number;
segments: LogSegment[];
}
export interface BotSnapshot {
bot_id: number;
bot_status: bot_status;
context_exists: boolean;
context_generation: string | null;
stream_id: string;
seq: number;
captured_at: number;
}
export interface BotLogsResult {
stream_id: string;
seq: number;
records: BotLogRecord[];
truncated: boolean;
}
interface StreamEventPayloads {
status_update: {status: bot_status; context_exists: boolean};
log: {records: BotLogRecord[]};
message: {text: string; position: string};
bot_error: {message: string};
info_update: InfoUpdate;
}
export type BotStreamEvent = {[K in keyof StreamEventPayloads]: {
bot_id: number;
context_generation: string | null;
stream_id: string;
seq: number;
emitted_at: number;
name: K;
payload: StreamEventPayloads[K];
}}[keyof StreamEventPayloads];
export type BotStreamErrorCode = 'INVALID_REQUEST' | 'TIMEOUT' | 'NO_CONTEXT'
| 'BOT_NOT_ONLINE' | 'INTERNAL_ERROR' | 'CANCELLED' | 'RESYNC_REQUIRED' | 'RATE_LIMITED';
export class BotStreamError extends Error {
readonly retryable = false;
constructor(readonly code: BotStreamErrorCode, message: string) {
super(message);
this.name = 'BotStreamError';
}
}
export interface BotStreamOptions {
source?: StreamContextSource;
capacity?: number;
now?: () => number;
max_chat_length?: number;
history_reader?: (count: number) => Promise<LogHistoryRecord[]>;
}
const default_source: StreamContextSource = {get: get_bot_context, observe: observe_bot_context};
const generations = new WeakMap<StreamContext, string>();
const completion_pending = new WeakSet<object>();
let completion_transaction = 0;
const statuses = new Set(['offline', 'ready', 'connecting', 'online', 'disconnecting', 'reconnecting']);
const styles = new Set<LogSegment['font_style'][number]>(['normal', 'italic', 'bold', 'light', 'underline', 'url']);
function own(value: unknown, key: string): unknown {
if (!value || typeof value !== 'object') return undefined;
const descriptor = Object.getOwnPropertyDescriptor(value, key);
return descriptor && 'value' in descriptor ? descriptor.value : undefined;
}
function exact(value: unknown, required: string[], optional: string[] = []): value is Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const keys = Reflect.ownKeys(value);
return required.every(key => Object.hasOwn(value, key)) && keys.every(key => {
if (typeof key !== 'string' || !required.includes(key) && !optional.includes(key)) return false;
const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
return 'value' in descriptor && descriptor.enumerable;
});
}
function text(value: string, bytes: number): string {
if (Buffer.byteLength(value) <= bytes) return value;
let result = '';
let size = 0;
for (const character of value) {
size += Buffer.byteLength(character);
if (size > bytes) break;
result += character;
}
return result;
}
// Project module values to bounded JSON without invoking getters or toJSON methods.
class InfoDataError extends BotStreamError {
constructor(readonly reason: string, readonly field_path: string) {
super('INTERNAL_ERROR', 'Invalid info data');
}
}
function clone_info_data(value: unknown): unknown {
let entries = 0;
let bytes = 0;
const ancestors = new Set<object>();
const max_bytes = 4 * 1024 * 1024;
const visit = (item: unknown, depth: number, field_path: string): unknown => {
const invalid = (reason: string): never => { throw new InfoDataError(reason, field_path); };
if (depth > 64) invalid('max_depth');
if (++entries > 250_000) invalid('max_entries');
let result: unknown;
if (item === null || typeof item === 'boolean' || typeof item === 'string' || typeof item === 'number') {
if (typeof item === 'number' && !Number.isFinite(item)) invalid('non_finite_number');
if (typeof item === 'string' && Buffer.byteLength(item) > max_bytes) invalid('max_bytes');
bytes += Buffer.byteLength(JSON.stringify(item));
result = item;
} else {
if (!Array.isArray(item) && !object(item)) invalid('non_json_value');
const container = item as object;
if (ancestors.has(container)) invalid('cyclic_reference');
ancestors.add(container);
const keys = Reflect.ownKeys(container);
bytes += 2;
if (Array.isArray(item)) {
if (item.length > 65_536) invalid('max_array_length');
if (keys.length !== item.length + 1) invalid('non_dense_array');
const values: unknown[] = [];
for (let index = 0; index < item.length; index++) {
const descriptor = Object.getOwnPropertyDescriptor(item, String(index));
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) invalid('non_data_property');
if (index) bytes++;
values.push(visit(descriptor!.value, depth + 1, `${field_path}[${index}]`));
}
result = values;
} else {
if (keys.length > 65_536) invalid('max_keys');
const values = Object.create(null) as Record<string, unknown>;
for (let index = 0; index < keys.length; index++) {
const key = keys[index];
if (typeof key !== 'string') invalid('non_string_key');
if (Buffer.byteLength(key as string) > max_bytes) invalid('max_bytes');
const descriptor = Object.getOwnPropertyDescriptor(container, key)!;
if (!descriptor.enumerable || !('value' in descriptor)) invalid('non_data_property');
bytes += Buffer.byteLength(JSON.stringify(key)) + 1 + (index ? 1 : 0);
// Paths contain property indexes rather than server-provided keys or item content.
const path_key = ['name', 'value', 'window', 'slots', 'nbt', 'components', 'special', 'offers', 'data', 'pages'].includes(key as string)
? String(key) : `<field:${index}>`;
values[key as string] = visit(descriptor.value, depth + 1, `${field_path}.${path_key}`);
}
result = values;
}
ancestors.delete(container);
}
if (bytes > max_bytes) invalid('max_bytes');
return result;
};
return visit(value, 0, '$');
}
function valid_info_name(value: unknown): value is string {
return typeof value === 'string' && value.length <= 128 && /^[a-zA-Z0-9_-]+$/.test(value);
}
function valid_info_text(value: unknown, max: number): boolean {
return typeof value === 'string' && Buffer.byteLength(value) <= max;
}
function valid_info_i18n(value: unknown): boolean {
if (!exact(value, ['lang', 'name', 'description'], ['fields', 'values'])
|| !valid_info_text(value.lang, 128) || !valid_info_text(value.name, 256)
|| !valid_info_text(value.description, 4096)) return false;
return ['fields', 'values'].every(key => !Object.hasOwn(value, key)
|| object(value[key]) && Object.entries(value[key]).every(([name, metadata]) =>
valid_info_text(name, 128) && valid_info_i18n(metadata)));
}
function project_segments(value: unknown): LogSegment[] {
if (!Array.isArray(value)) return [];
const segments: LogSegment[] = [];
let bytes = 2;
for (let index = 0; index < Math.min(value.length, 256); index++) {
const item = own(value, String(index));
const message = own(item, 'message');
if (typeof message !== 'string') continue;
const color = own(item, 'font_color');
const font_style = own(item, 'font_style');
const safe_styles: LogSegment['font_style'] = [];
if (Array.isArray(font_style)) {
for (let i = 0; i < Math.min(font_style.length, styles.size); i++) {
const style = own(font_style, String(i));
if (typeof style === 'string' && styles.has(style as any) && !safe_styles.includes(style as any)) {
safe_styles.push(style as LogSegment['font_style'][number]);
}
}
}
const segment: LogSegment = {
message: text(message, 16_000),
font_color: typeof color === 'string' && /^#[0-9a-f]{6}$/i.test(color) ? color : '#ffffff',
font_style: safe_styles.length ? safe_styles : ['normal'],
};
for (const key of ['hoverEvent', 'clickEvent'] as const) {
const event = own(item, key);
const action = own(event, 'action');
const value = own(event, 'value');
if (typeof action !== 'string' || typeof value !== 'string') continue;
if (key === 'hoverEvent') {
if (['show_text', 'show_item', 'show_entity'].includes(action)) {
segment.hoverEvent = {action: action as NonNullable<log['hoverEvent']>['action'], value: text(value, 4096)};
const parts = own(event, 'segments');
if (action === 'show_text' && Array.isArray(parts)) {
let remaining = 4096;
segment.hoverEvent.segments = project_segments(parts.slice(0, 64).map(part => ({
message: own(part, 'message'), font_color: own(part, 'font_color'), font_style: own(part, 'font_style'),
}))).map(({message, font_color, font_style}) => {
const clipped = message.slice(0, remaining);
remaining -= clipped.length;
return {message: clipped, font_color, font_style};
}).filter(part => part.message.length > 0);
}
}
} else if (['open_url', 'open_file', 'run_command', 'suggest_command', 'change_page',
'copy_to_clipboard', 'show_dialog', 'custom'].includes(action)) {
segment.clickEvent = {action, value: text(value, 4096)};
}
}
const insertion = own(item, 'insertion');
if (typeof insertion === 'string') segment.insertion = text(insertion, 4096);
const size = Buffer.byteLength(JSON.stringify(segment)) + (segments.length ? 1 : 0);
if (bytes + size > 16_384) break;
segments.push(segment);
bytes += size;
}
return segments;
}
function message_text(value: unknown, depth = 0): string {
if (typeof value === 'string') return text(value, 4096);
if (depth >= 4) return '';
const json = own(value, 'json');
if (json !== undefined) return message_text(json, depth + 1);
const content = own(value, 'text');
let result = typeof content === 'string' ? text(content, 4096) : '';
const extra = own(value, 'extra');
if (Array.isArray(extra)) {
for (let index = 0; index < Math.min(extra.length, 16); index++) {
result = text(result + message_text(own(extra, String(index)), depth + 1), 4096);
}
}
return result;
}
export class BotStream {
readonly stream_id = randomUUID();
private readonly source: StreamContextSource;
private readonly capacity: number;
private readonly now: () => number;
private readonly max_chat_length: number;
private readonly history_reader: (count: number) => Promise<LogHistoryRecord[]>;
private readonly history_stream_id = randomUUID();
private context: StreamContext | undefined;
private generation: string | null = null;
private sequence = 0;
private dropped_through = -1;
private readonly records: BotLogRecord[] = [];
private readonly listeners = new Set<(event: BotStreamEvent) => void>();
private readonly pending_events: BotStreamEvent[] = [];
private publishing = false;
private context_listeners: Array<[string, (...args: any[]) => void]> = [];
private unobserve: (() => void) | undefined;
private disposed = false;
constructor(readonly bot_id: number, options: BotStreamOptions = {}) {
if (!Number.isSafeInteger(bot_id) || bot_id < 1) throw new BotStreamError('INVALID_REQUEST', 'Invalid bot ID');
this.capacity = options.capacity ?? 200;
this.max_chat_length = options.max_chat_length ?? 256;
if (!Number.isSafeInteger(this.capacity) || this.capacity < 1 || this.capacity > 1000
|| !Number.isSafeInteger(this.max_chat_length) || this.max_chat_length < 1) {
throw new BotStreamError('INVALID_REQUEST', 'Invalid stream limits');
}
this.source = options.source ?? default_source;
this.now = options.now ?? Date.now;
this.history_reader = options.history_reader ?? (count => this.context?.logger
? this.context.logger.read_recent_log(count)
: Logger.read_recent_log(path.join(get_project_root_path(), 'data/bot', String(bot_id), 'log'), count));
this.unobserve = this.source.observe(bot_id, context => this.bind(context));
this.bind(this.source.get(bot_id));
}
private bind(context: StreamContext | undefined) {
if (this.disposed || this.context === context) return;
this.detach();
this.context = context;
this.generation = context ? generations.get(context) ?? randomUUID() : null;
if (context) {
generations.set(context, this.generation!);
const listen = (event: string, callback: (...args: any[]) => void) => {
const guarded = (...args: any[]) => {
if (!this.disposed && this.context === context && this.source.get(this.bot_id) === context) {
callback(...args);
}
};
this.context_listeners.push([event, guarded]);
context.on(event, guarded);
};
listen('status_update', () => this.status_event());
listen('log', input => {
const segments = project_segments(input);
if (!segments.length) return;
const record: BotLogRecord = {
id: randomUUID(), timestamp: this.now(), context_generation: this.generation,
seq: ++this.sequence, segments,
};
this.records.push(record);
if (this.records.length > this.capacity) this.dropped_through = this.records.shift()!.seq;
this.publish('log', {records: [record]}, record.seq);
});
listen('message', (input, position) => {
const projected = message_text(input);
if (projected) this.publish('message', {
text: projected,
position: typeof position === 'string' && position.trim() ? text(position, 128) : 'chat',
});
});
listen('error', () => this.publish('bot_error', {message: 'Bot connection error'}));
listen('info_update', input => {
if (context.bot_status !== 'online' || !context.bot_instance || !context.info_manager) return;
try {
const data = clone_info_data(input);
if (!exact(data, ['name', 'value']) || !valid_info_name(data.name)) {
throw new BotStreamError('INTERNAL_ERROR', 'Invalid info update');
}
this.publish('info_update', data as unknown as InfoUpdate);
} catch (error) {
const name = own(input, 'name');
console.error('Invalid bot info update was skipped', {
module: valid_info_name(name) ? name : 'unknown',
reason: error instanceof InfoDataError ? error.reason : 'invalid_update',
path: error instanceof InfoDataError ? error.field_path : '$',
});
}
});
}
this.status_event();
}
private detach() {
for (const [event, listener] of this.context_listeners) this.context?.off(event, listener);
this.context_listeners = [];
}
private state() {
return {
status: this.context && statuses.has(this.context.bot_status) ? this.context.bot_status : 'offline' as bot_status,
context_exists: !!this.context,
};
}
private status_event() {
this.publish('status_update', this.state());
}
private publish<K extends keyof StreamEventPayloads>(name: K, payload: StreamEventPayloads[K], seq = ++this.sequence) {
const event = {
bot_id: this.bot_id, context_generation: this.generation, stream_id: this.stream_id,
seq, emitted_at: this.now(), name, payload,
} as BotStreamEvent;
// Serialize reentrant callbacks so every subscriber sees increasing sequence numbers.
if (this.pending_events.length === 128) this.pending_events.shift();
this.pending_events.push(event);
if (this.publishing) return;
this.publishing = true;
try {
let dispatched = 0;
while (this.pending_events.length && !this.disposed && dispatched++ < 1024) {
const pending = this.pending_events.shift()!;
for (const listener of Array.from(this.listeners)) {
if (this.disposed) break;
try { listener(structuredClone(pending)); } catch { /* Isolate consumer failures from the bot lifecycle. */ }
}
}
} finally {
this.pending_events.length = 0;
this.publishing = false;
}
}
private active() {
if (this.disposed) throw new BotStreamError('CANCELLED', 'Bot stream is disposed');
this.bind(this.source.get(this.bot_id));
}
resync(): void {
this.active();
this.status_event();
}
snapshot(payload: unknown = {}): BotSnapshot {
this.active();
if (!exact(payload, [])) throw new BotStreamError('INVALID_REQUEST', 'Invalid snapshot request');
const state = this.state();
return {
bot_id: this.bot_id, bot_status: state.status, context_exists: state.context_exists,
context_generation: this.generation, stream_id: this.stream_id,
seq: this.sequence, captured_at: this.now(),
};
}
get_context_info(payload: unknown, deadline_at: number): InfoSnapshot[] {
this.active();
if (!exact(payload, [])) throw new BotStreamError('INVALID_REQUEST', 'Invalid info request');
if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now())
throw new BotStreamError('TIMEOUT', 'Info request deadline expired');
const context = this.context;
if (!context) throw new BotStreamError('NO_CONTEXT', 'Bot context does not exist');
const instance = context.bot_instance;
if (context.bot_status !== 'online' || !instance)
throw new BotStreamError('BOT_NOT_ONLINE', 'Bot is not online');
const manager = context.info_manager;
if (!manager) throw new BotStreamError('INTERNAL_ERROR', 'Bot info manager is unavailable');
let data: unknown;
try { data = clone_info_data(manager.get_all_info()); }
catch { throw new BotStreamError('INTERNAL_ERROR', 'Could not read bot info'); }
const names = new Set<string>();
if (!Array.isArray(data) || data.length > 256 || !data.every(info => {
if (!exact(info, ['name', 'i18n', 'value']) || !valid_info_name(info.name) || names.has(info.name)
|| !valid_info_i18n(info.i18n)) return false;
names.add(info.name);
return true;
})) throw new BotStreamError('INTERNAL_ERROR', 'Invalid bot info result');
if (deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'Info request deadline expired');
if (this.disposed || this.source.get(this.bot_id) !== context || context.bot_instance !== instance
|| context.info_manager !== manager || context.bot_status !== 'online')
throw new BotStreamError('CANCELLED', 'Bot context changed');
return data as InfoSnapshot[];
}
logs(payload: unknown): BotLogsResult {
this.active();
if (!exact(payload, ['limit'], ['stream_id', 'after_seq'])
|| !Number.isSafeInteger(payload.limit) || (payload.limit as number) < 1 || (payload.limit as number) > 200
|| Object.hasOwn(payload, 'stream_id') && (typeof payload.stream_id !== 'string'
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(payload.stream_id))
|| Object.hasOwn(payload, 'after_seq') && (!Number.isSafeInteger(payload.after_seq)
|| (payload.after_seq as number) < 0 || typeof payload.stream_id !== 'string')) {
throw new BotStreamError('INVALID_REQUEST', 'Invalid logs request');
}
const mismatch = payload.stream_id !== undefined && payload.stream_id !== this.stream_id;
const cursor = !mismatch && typeof payload.after_seq === 'number' ? payload.after_seq : undefined;
if (cursor !== undefined && cursor > this.sequence) {
throw new BotStreamError('RESYNC_REQUIRED', 'Log cursor exceeds stream sequence');
}
const available = this.records.filter(record => cursor === undefined || record.seq > cursor);
const candidates = cursor === undefined ? available.slice(-(payload.limit as number)).reverse()
: available.slice(0, payload.limit as number);
const records: BotLogRecord[] = [];
let bytes = 0;
for (const record of candidates) {
const size = Buffer.byteLength(JSON.stringify(record)) + 1;
if (bytes + size > 196_608) break;
records.push(record);
bytes += size;
}
if (cursor === undefined) records.reverse();
return {
stream_id: this.stream_id, seq: this.sequence, records: structuredClone(records),
truncated: mismatch || available.length > records.length
|| (cursor === undefined ? this.dropped_through >= 0 : cursor < this.dropped_through),
};
}
async get_log_history(): Promise<BotLogsResult> {
this.active();
const history = await this.history_reader(20);
this.active();
const records: BotLogRecord[] = [];
let bytes = 0;
for (const entry of history.slice(-20).reverse()) {
const segments = project_segments(entry.log);
if (!segments.length) continue;
const record: BotLogRecord = {id: entry.id, timestamp: entry.timestamp,
context_generation: null, seq: 0, segments};
const size = Buffer.byteLength(JSON.stringify(record)) + 1;
if (bytes + size > 196_608) break;
bytes += size;
records.push(record);
}
// History has its own sequence namespace so it cannot advance live log cursors.
records.reverse();
records.forEach((record, index) => { record.seq = index + 1; });
return {stream_id: this.history_stream_id, seq: records.length, records,
truncated: history.length >= 20 || records.length < history.length};
}
async tab_complete(payload: unknown, deadline_at: number): Promise<{matches: string[]; start: number; length: number}> {
this.active();
if (!exact(payload, ['text']) || typeof payload.text !== 'string' || payload.text.length > 256
|| /[\r\n\u0000]/.test(payload.text)) throw new BotStreamError('INVALID_REQUEST', 'Invalid completion text');
if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now())
throw new BotStreamError('TIMEOUT', 'Completion deadline expired');
const context = this.context;
if (!context) throw new BotStreamError('NO_CONTEXT', 'Bot context does not exist');
const instance = context.bot_instance;
if (context.bot_status !== 'online' || !instance || typeof instance.tabComplete !== 'function')
throw new BotStreamError('BOT_NOT_ONLINE', 'Bot is not online');
if (completion_pending.has(instance)) throw new BotStreamError('RATE_LIMITED', 'Completion request is already pending');
completion_pending.add(instance);
// Leave time for Node -> Forward -> Web delivery before the relay deadline.
const timeout = Math.max(1, Math.min(4500, deadline_at - this.now() - 250));
const input = payload.text;
let start = input.search(/\S*$/);
if (start === 0 && input.startsWith('/')) start = 1;
let length = input.length - start;
try {
const client = instance._client;
const modern = (client?.protocolVersion ?? 0) >= 393;
const local = modern ? local_command_completion(instance, input, Object.keys(instance.players ?? {})) : undefined;
const values = await new Promise<unknown[]>((resolve, reject) => {
let received = 0;
let settled = false;
const transactionId = completion_transaction = (completion_transaction + 1) % 2_147_483_647;
const finish = (error?: BotStreamError, values?: unknown[]) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (modern) client!.off('tab_complete', receive);
client?.off('end', ended);
if (error) reject(error); else resolve(values ?? []);
};
const receive = (packet: {transactionId?: number; matches?: unknown[]; start?: number; length?: number}) => {
received++;
if (packet.transactionId !== transactionId) return;
if (!Number.isSafeInteger(packet.start) || !Number.isSafeInteger(packet.length)
|| packet.start! < 0 || packet.length! < 0 || packet.start! + packet.length! > input.length) {
finish(new BotStreamError('INTERNAL_ERROR', 'Invalid completion range'));
return;
}
start = packet.start!;
length = packet.length!;
finish(undefined, packet.matches);
};
const ended = () => finish(new BotStreamError('CANCELLED', 'Bot connection ended'));
const timer = setTimeout(() => {
if (local?.matches.length) { start = local.start; length = local.length; finish(undefined, []); }
else {
try {
context.log?.([{message: `[Tab补全超时] protocol=${client?.protocolVersion ?? 'unknown'} state=${client?.state ?? 'unknown'} transactionId=${transactionId} responses=${received} bundledPackets=${client?._mcBundle?.length ?? 0}`,
font_color: '#d1b478', font_style: ['normal']}]);
} catch { /* Diagnostics cannot prevent request completion. */ }
finish(new BotStreamError('TIMEOUT', 'Completion timed out'));
}
}, local?.matches.length ? Math.min(timeout, 200) : timeout);
client?.on('end', ended);
try {
if (modern) {
client!.on('tab_complete', receive);
client!.write('tab_complete', {transactionId, text: payload.text});
} else {
instance.tabComplete!(payload.text as string, false, false, timeout).then(
values => finish(undefined, values),
() => finish(new BotStreamError('INTERNAL_ERROR', 'Completion failed')));
}
} catch { finish(new BotStreamError('INTERNAL_ERROR', 'Completion failed')); }
});
if (deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'Completion deadline expired');
if (this.disposed || this.source.get(this.bot_id) !== context || context.bot_instance !== instance
|| context.bot_status !== 'online') throw new BotStreamError('CANCELLED', 'Bot context changed');
const matches: string[] = [];
if (!Array.isArray(values)) throw new BotStreamError('INTERNAL_ERROR', 'Invalid completion result');
for (const value of values.slice(0, 1000)) {
let match = typeof value === 'string' ? value : own(value, 'match');
if (!modern && start === 1 && input.startsWith('/') && typeof match === 'string' && match.startsWith('/'))
match = match.slice(1);
if (typeof match === 'string' && match.length > 0 && match.length <= 256 && !matches.includes(match))
matches.push(match);
if (matches.length === 100) break;
}
if (local?.matches.length && (!matches.length || start === local.start && length === local.length)) {
if (!matches.length) { start = local.start; length = local.length; }
for (const match of local.matches) {
if (matches.length >= 100) break;
if (!matches.includes(match)) matches.push(match);
}
}
// Legacy protocols only return candidates; replace the last token, preserving the command slash.
return {matches, start, length};
} finally { completion_pending.delete(instance); }
}
async execute_function(payload: {function: string; name: string; args: unknown[]}, deadline_at: number,
check_transport: () => void = () => {}): Promise<boolean> {
this.active();
const context = this.context;
const instance = context?.bot_instance;
const manager = context?.function_manager;
const check = () => {
check_transport();
if (deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'Function deadline expired');
if (this.disposed || !context || this.source.get(this.bot_id) !== context || context.bot_instance !== instance)
throw new BotStreamError('CANCELLED', 'Bot context changed');
};
check();
if (!instance || context!.bot_status !== 'online' || !manager) return false;
try {
return await manager.run(payload.function, payload.name, payload.args, check) === true;
} catch (error) {
check();
if (error instanceof BotStreamError) throw error;
return false;
}
}
private workflow_context(deadline_at: number, check_transport: () => void, online = false) {
this.active();
check_transport();
if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now())
throw new BotStreamError('TIMEOUT', 'Workflow request deadline expired');
const context = this.context;
if (!context) throw new BotStreamError('NO_CONTEXT', 'Bot context does not exist');
const instance = context.bot_instance;
const manager = context.workflow_manager;
if (!manager) throw new BotStreamError('INTERNAL_ERROR', 'Workflow manager is unavailable');
const check = () => {
check_transport();
if (deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'Workflow request deadline expired');
if (this.disposed || this.source.get(this.bot_id) !== context || context.bot_instance !== instance
|| context.workflow_manager !== manager) throw new BotStreamError('CANCELLED', 'Bot context changed');
if (online && (context.bot_status !== 'online' || !instance))
throw new BotStreamError('BOT_NOT_ONLINE', 'Bot is not online');
};
check();
return {manager, check};
}
workflow_start(payload: unknown, deadline_at: number, check_transport: () => void = () => {}): StartResult {
const input = validate_workflow_payload('workflow.start', payload);
const {manager, check} = this.workflow_context(deadline_at, check_transport, true);
const trigger = input.trigger === undefined ? undefined : structuredClone(input.trigger);
check();
const result = structuredClone(manager.start(input.workflow_id, trigger));
check();
return result;
}
async workflow_stop(payload: unknown, deadline_at: number,
check_transport: () => void = () => {}): Promise<{stopped: true}> {
const input = validate_workflow_payload('workflow.stop', payload);
const {manager, check} = this.workflow_context(deadline_at, check_transport);
check();
await manager.stop(input.run_id);
check();
return {stopped: true};
}
workflow_snapshot(payload: unknown, deadline_at: number,
check_transport: () => void = () => {}): RunSnapshot | RunSummary[] | (RunSummary & Pick<RunSnapshot, 'trigger'>) | null {
const input = validate_workflow_payload('workflow.snapshot', payload);
const {manager, check} = this.workflow_context(deadline_at, check_transport);
const snapshot = input.run_id === undefined ? null : manager.get_snapshot(input.run_id) ?? null;
const result = structuredClone(input.run_id === undefined ? manager.get_snapshots().map(summarize_run)
: snapshot && input.include_branches === false ? {...summarize_run(snapshot), ...(snapshot.trigger === undefined ? {} : {trigger: snapshot.trigger})} : snapshot);
check();
return result;
}
workflow_catalog(payload: unknown, deadline_at: number,
check_transport: () => void = () => {}): WorkflowCatalog {
this.active();
validate_workflow_payload('workflow.catalog', payload);
const check = () => {
check_transport();
if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now())
throw new BotStreamError('TIMEOUT', 'Workflow request deadline expired');
if (this.disposed) throw new BotStreamError('CANCELLED', 'Bot stream is disposed');
};
check();
const result = structuredClone(get_workflow_catalog());
check();
return result;
}
workflow_runtime(payload: unknown, deadline_at: number, check_transport: () => void = () => {}): WorkflowRuntimeSnapshot {
validate_workflow_payload('workflow.runtime', payload);
const {manager, check} = this.workflow_context(deadline_at, check_transport);
if (!manager.get_runtime_snapshot) throw new BotStreamError('INTERNAL_ERROR', 'Workflow controls unavailable');
const result = structuredClone(manager.get_runtime_snapshot());
check();
return result;
}
workflow_control(payload: unknown, deadline_at: number, check_transport: () => void = () => {}): WorkflowCommand {
const input = validate_workflow_payload('workflow.control', payload);
const {manager, check} = this.workflow_context(deadline_at, check_transport);
if (this.snapshot().context_generation !== input.context_generation) throw new BotStreamError('CANCELLED', 'Bot context changed');
if (!manager.control) throw new BotStreamError('INTERNAL_ERROR', 'Workflow controls unavailable');
const {context_generation, ...request} = structuredClone(input);
check();
try {
const result = structuredClone(manager.control(request));
check();
return result;
} catch (error) {
check();
if (error instanceof BotStreamError) throw error;
const code = (error as {code?: string})?.code;
throw new BotStreamError(code === 'RATE_LIMITED' ? 'RATE_LIMITED' : 'INVALID_REQUEST', 'Workflow control rejected');
}
}
workflow_command(payload: unknown, deadline_at: number, check_transport: () => void = () => {}): WorkflowCommand | null {
const input = validate_workflow_payload('workflow.command', payload);
const {manager, check} = this.workflow_context(deadline_at, check_transport);
if (this.snapshot().context_generation !== input.context_generation) throw new BotStreamError('CANCELLED', 'Bot context changed');
if (!manager.get_command) throw new BotStreamError('INTERNAL_ERROR', 'Workflow controls unavailable');
const result = structuredClone(manager.get_command(input.command_id) ?? null);
check();
return result;
}
chat(payload: unknown, deadline_at: number): {accepted: true} {
this.active();
if (!exact(payload, ['message']) || typeof payload.message !== 'string'
|| !payload.message.trim() || payload.message.length > this.max_chat_length) {
throw new BotStreamError('INVALID_REQUEST', 'Invalid chat request');
}
if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now()) {
throw new BotStreamError('TIMEOUT', 'Chat deadline expired');
}
const context = this.context;
if (!context) throw new BotStreamError('NO_CONTEXT', 'Bot context does not exist');
const instance = context.bot_instance;
if (context.bot_status !== 'online' || !instance || typeof instance.chat !== 'function') {
throw new BotStreamError('BOT_NOT_ONLINE', 'Bot is not online');
}
if (deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'Chat deadline expired');
if (this.source.get(this.bot_id) !== context || context.bot_instance !== instance || context.bot_status !== 'online') {
throw new BotStreamError('CANCELLED', 'Bot context changed');
}
try { instance.chat(payload.message); } catch {
throw new BotStreamError('INTERNAL_ERROR', 'Bot chat failed');
}
return {accepted: true};
}
subscribe(listener: (event: BotStreamEvent) => void): () => void {
this.active();
this.listeners.add(listener);
return () => { this.listeners.delete(listener); };
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.unobserve?.();
this.unobserve = undefined;
this.detach();
this.context = undefined;
this.generation = null;
this.listeners.clear();
this.records.length = 0;
this.pending_events.length = 0;
}
}