chore: initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user