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, validate_world_payload} from '../../utils/forward_protocol.ts'; import {WorldPreview, type WorldBot} from './world.ts'; import type {WorldSnapshot, WorldVoxelChunk} from '../../types/forward.ts'; export interface StreamContext { readonly bot_status: bot_status; readonly bot_instance: ({ chat(message: string): unknown; players?: Record; tabComplete?(text: string, assumeCommand?: boolean, sendBlockInSight?: boolean, timeout?: number): Promise; _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; }; } & Partial> & {game?: Partial}) | null; readonly logger?: {read_recent_log(count: number): Promise}; readonly info_manager?: Pick | null; readonly function_manager?: Pick | null; readonly workflow_manager?: Pick & Partial> | 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; } const default_source: StreamContextSource = {get: get_bot_context, observe: observe_bot_context}; const generations = new WeakMap(); const completion_pending = new WeakSet(); let completion_transaction = 0; const statuses = new Set(['offline', 'ready', 'connecting', 'online', 'disconnecting', 'reconnecting']); const styles = new Set(['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 { 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(); 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; 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) : ``; 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['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; 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; private world_preview?: WorldPreview; 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() { this.world_preview?.dispose(); this.world_preview = undefined; 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() { if (this.context?.bot_status !== 'online' || this.world_preview?.bot !== this.context?.bot_instance) { this.world_preview?.dispose(); this.world_preview = undefined; } this.publish('status_update', this.state()); } private publish(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(), }; } private world_context(deadline_at: number, check_transport: () => void, existing = false) { this.active(); check_transport(); if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'World 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) { this.world_preview?.dispose(); this.world_preview = undefined; throw new BotStreamError('BOT_NOT_ONLINE', 'Bot is not online'); } if (!instance.world || !instance.game || !instance.entity || !instance.registry || !instance.version || typeof instance.on !== 'function' || typeof instance.off !== 'function') throw new BotStreamError('INTERNAL_ERROR', 'Bot world is unavailable'); if (this.world_preview?.bot !== instance || this.world_preview?.is_disposed) { this.world_preview?.dispose(); this.world_preview = undefined; } if (existing && !this.world_preview) throw new BotStreamError('CANCELLED', 'World watch is unavailable'); const service = this.world_preview ??= new WorldPreview(instance as WorldBot, generation => { if (!this.disposed && this.source.get(this.bot_id) === context && context.bot_instance === instance && context.bot_status === 'online') this.publish('info_update', {name: 'world_preview', value: {world_generation: generation}}); }, this.now); const check = () => { check_transport(); if (deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'World request deadline expired'); if (this.disposed || this.source.get(this.bot_id) !== context || context.bot_instance !== instance || this.world_preview !== service) { service.dispose(); throw new BotStreamError('CANCELLED', 'Bot context changed'); } if (context.bot_status !== 'online') { service.dispose(); throw new BotStreamError('BOT_NOT_ONLINE', 'Bot is not online'); } }; check(); return {service, check}; } async world_snapshot(payload: unknown, deadline_at: number, check_transport: () => void = () => {}): Promise { const input = structuredClone(validate_world_payload('world.snapshot', payload)); const {service, check} = this.world_context(deadline_at, check_transport); const result = await service.snapshot(input, check); check(); return {...result, context_generation: this.generation!, stream_id: this.stream_id}; } async world_chunk(payload: unknown, deadline_at: number, check_transport: () => void = () => {}): Promise { const input = structuredClone(validate_world_payload('world.chunk', payload)); const {service, check} = this.world_context(deadline_at, check_transport, true); const result = await service.chunk(input, check); check(); return result; } world_unwatch(payload: unknown, deadline_at: number, check_transport: () => void = () => {}): {released: true} { const input = validate_world_payload('world.unwatch', payload); this.active(); check_transport(); if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'World request deadline expired'); return this.world_preview?.unwatch(input.watch_id) ?? {released: true}; } 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(); 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 { 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((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 { 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) | 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; } }