chore: initial commit
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {constants} from 'node:fs';
|
||||
import {lstat, open, opendir, realpath} from 'node:fs/promises';
|
||||
import type {BigIntStats} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {performance} from 'node:perf_hooks';
|
||||
import {get_project_root_path} from '../../utils/path_utils.ts';
|
||||
import {exact, ForwardFailure, integer, uuid} from '../../utils/forward_protocol.ts';
|
||||
|
||||
export type DataFileEntry = {type: 'directory'; name: string; children: DataFileEntry[]}
|
||||
| {type: 'file'; name: string; id: string; size: number; modified_at: number};
|
||||
export interface DataFileList {
|
||||
listing_id: string;
|
||||
expires_at: number;
|
||||
partial: boolean;
|
||||
children: DataFileEntry[];
|
||||
}
|
||||
export interface FileData {
|
||||
id: string;
|
||||
name: string;
|
||||
encoding: 'base64';
|
||||
offset: number;
|
||||
next_offset: number;
|
||||
total_bytes: number;
|
||||
eof: boolean;
|
||||
data: string;
|
||||
}
|
||||
export interface BotFileServiceOptions {
|
||||
/** Trusted server-side override of the bot data root, primarily for tests. */
|
||||
root?: string;
|
||||
now?: () => number;
|
||||
}
|
||||
type FileRecord = {parts: string[]; stat: BigIntStats; expires_at: number; append: boolean};
|
||||
const TTL = 300_000;
|
||||
const CHUNK = 262_144;
|
||||
const MAX_IDS = 8000;
|
||||
const MAX_ENTRIES = 2000;
|
||||
const MAX_BYTES = 512 * 1024;
|
||||
const identity = (a: BigIntStats, b: BigIntStats) => a.dev === b.dev && a.ino === b.ino
|
||||
&& a.birthtimeNs === b.birthtimeNs;
|
||||
const missing = (error: unknown) => ['ENOENT', 'ENOTDIR', 'ELOOP'].includes((error as NodeJS.ErrnoException)?.code ?? '');
|
||||
|
||||
export class BotFileService {
|
||||
private readonly root: string;
|
||||
private readonly now: () => number;
|
||||
private readonly files = new Map<string, FileRecord>();
|
||||
private disposed = false;
|
||||
private listing = false;
|
||||
private reads = 0;
|
||||
|
||||
constructor(bot_id: number, options: BotFileServiceOptions = {}) {
|
||||
if (!integer(bot_id, 1)) throw new ForwardFailure('INVALID_REQUEST');
|
||||
this.root = path.resolve(options.root ?? path.join(get_project_root_path(), 'data', 'bot', String(bot_id)));
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.files.clear();
|
||||
}
|
||||
|
||||
private check(deadline: number): void {
|
||||
if (this.disposed) throw new ForwardFailure('CANCELLED');
|
||||
if (!integer(deadline, 1)) throw new ForwardFailure('INVALID_REQUEST');
|
||||
if (this.now() >= deadline) throw new ForwardFailure('TIMEOUT');
|
||||
}
|
||||
|
||||
private prune(): void {
|
||||
for (const [id, record] of this.files) {
|
||||
if (record.expires_at <= this.now()) this.files.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Check every ancestor, including ancestors above the configured root. These
|
||||
// checks narrow parent replacement races; they do not make path lookup atomic.
|
||||
private async safe(parts: string[], deadline: number): Promise<BigIntStats> {
|
||||
const target = path.join(this.root, ...parts);
|
||||
const relative = path.relative(this.root, target);
|
||||
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||
throw new ForwardFailure('RESYNC_REQUIRED');
|
||||
}
|
||||
const parsed = path.parse(target);
|
||||
let current = parsed.root;
|
||||
let stat = await lstat(current, {bigint: true});
|
||||
for (const component of target.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
|
||||
this.check(deadline);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new ForwardFailure('RESYNC_REQUIRED');
|
||||
current = path.join(current, component);
|
||||
stat = await lstat(current, {bigint: true});
|
||||
this.check(deadline);
|
||||
if (stat.isSymbolicLink()) throw new ForwardFailure('RESYNC_REQUIRED');
|
||||
}
|
||||
const resolved = await realpath(target);
|
||||
this.check(deadline);
|
||||
if (resolved !== target) throw new ForwardFailure('RESYNC_REQUIRED');
|
||||
return stat;
|
||||
}
|
||||
|
||||
async list(deadline_at: number, expires_at?: number): Promise<DataFileList> {
|
||||
this.check(deadline_at);
|
||||
if (expires_at !== undefined && (!integer(expires_at, 1) || expires_at <= this.now()))
|
||||
throw new ForwardFailure('TOKEN_EXPIRED');
|
||||
if (this.listing) throw new ForwardFailure('RATE_LIMITED');
|
||||
this.listing = true;
|
||||
const pending = new Map<string, FileRecord>();
|
||||
const result: DataFileList = {listing_id: randomUUID(), expires_at: Math.min(this.now() + TTL, expires_at ?? Infinity),
|
||||
partial: false, children: []};
|
||||
let entries = 0;
|
||||
let bytes = Buffer.byteLength(JSON.stringify(result));
|
||||
const started = performance.now();
|
||||
const budget = () => {
|
||||
this.check(deadline_at);
|
||||
if (performance.now() - started >= 2000) result.partial = true;
|
||||
return !result.partial;
|
||||
};
|
||||
try {
|
||||
this.prune();
|
||||
const rootStat = await this.safe([], deadline_at);
|
||||
if (!rootStat.isDirectory()) throw new ForwardFailure('RESYNC_REQUIRED');
|
||||
const visit = async (parts: string[], children: DataFileEntry[], depth: number): Promise<void> => {
|
||||
if (!budget()) return;
|
||||
const before = await this.safe(parts, deadline_at);
|
||||
if (!before.isDirectory()) throw new ForwardFailure('RESYNC_REQUIRED');
|
||||
const names: string[] = [];
|
||||
const directory = await opendir(path.join(this.root, ...parts));
|
||||
try {
|
||||
while (budget()) {
|
||||
const entry = await directory.read();
|
||||
this.check(deadline_at);
|
||||
if (!entry) break;
|
||||
if (names.length >= MAX_ENTRIES) { result.partial = true; break; }
|
||||
names.push(entry.name);
|
||||
}
|
||||
} finally { await directory.close(); }
|
||||
const after = await this.safe(parts, deadline_at);
|
||||
if (!identity(before, after)) throw new ForwardFailure('RESYNC_REQUIRED');
|
||||
// A bounded directory read may have hit its limit; still emit its
|
||||
// collected entries, then stop further traversal.
|
||||
const stopped = result.partial;
|
||||
result.partial = false;
|
||||
for (const name of names.sort()) {
|
||||
if (!budget()) break;
|
||||
if (entries >= MAX_ENTRIES) { result.partial = true; break; }
|
||||
if (depth >= 32) { result.partial = true; break; }
|
||||
const childParts = [...parts, name];
|
||||
let stat: BigIntStats;
|
||||
try { stat = await lstat(path.join(this.root, ...childParts), {bigint: true}); }
|
||||
catch {
|
||||
this.check(deadline_at);
|
||||
result.partial = true;
|
||||
break;
|
||||
}
|
||||
this.check(deadline_at);
|
||||
if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) continue;
|
||||
if (stat.isFile() && (stat.size > BigInt(Number.MAX_SAFE_INTEGER)
|
||||
|| this.files.size + pending.size >= MAX_IDS)) { result.partial = true; break; }
|
||||
const entry: DataFileEntry = stat.isDirectory()
|
||||
? {type: 'directory', name, children: []}
|
||||
: {type: 'file', name, id: randomUUID(), size: Number(stat.size), modified_at: Number(stat.mtimeMs)};
|
||||
const cost = Buffer.byteLength(JSON.stringify(entry)) + 1;
|
||||
if (bytes + cost > MAX_BYTES) { result.partial = true; break; }
|
||||
bytes += cost;
|
||||
entries++;
|
||||
children.push(entry);
|
||||
if (entry.type === 'file') pending.set(entry.id, {parts: childParts, stat,
|
||||
expires_at: result.expires_at, append: childParts.length === 2
|
||||
&& childParts[0] === 'log' && name.endsWith('.log')});
|
||||
else {
|
||||
try { await visit(childParts, entry.children, depth + 1); }
|
||||
catch (error) {
|
||||
this.check(deadline_at);
|
||||
if (error instanceof ForwardFailure) throw error;
|
||||
result.partial = true;
|
||||
}
|
||||
}
|
||||
if (result.partial) break;
|
||||
}
|
||||
result.partial ||= stopped;
|
||||
};
|
||||
await visit([], result.children, 0);
|
||||
const finalRoot = await this.safe([], deadline_at);
|
||||
if (!identity(rootStat, finalRoot)) throw new ForwardFailure('RESYNC_REQUIRED');
|
||||
this.check(deadline_at);
|
||||
for (const [id, record] of pending) this.files.set(id, record);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.check(deadline_at);
|
||||
if (missing(error) && result.children.length === 0 && pending.size === 0) {
|
||||
// Only a genuinely absent root is an empty listing.
|
||||
try { await lstat(this.root); }
|
||||
catch (rootError) {
|
||||
this.check(deadline_at);
|
||||
if ((rootError as NodeJS.ErrnoException).code === 'ENOENT') return result;
|
||||
}
|
||||
}
|
||||
throw error instanceof ForwardFailure ? error : new ForwardFailure('INTERNAL_ERROR');
|
||||
} finally { this.listing = false; }
|
||||
}
|
||||
|
||||
async read(payload: {id: string; offset?: number; length?: number}, deadline_at: number): Promise<FileData> {
|
||||
this.check(deadline_at);
|
||||
if (!exact(payload, ['id'], ['offset', 'length']) || !uuid(payload.id)
|
||||
|| (payload.offset !== undefined && !integer(payload.offset))
|
||||
|| (payload.length !== undefined && (!integer(payload.length, 1) || payload.length > CHUNK))) {
|
||||
throw new ForwardFailure('INVALID_REQUEST');
|
||||
}
|
||||
this.prune();
|
||||
const record = this.files.get(payload.id);
|
||||
if (!record) throw new ForwardFailure('INVALID_REQUEST');
|
||||
const offset = payload.offset ?? 0;
|
||||
const size = Number(record.stat.size);
|
||||
if (offset > size) throw new ForwardFailure('INVALID_REQUEST');
|
||||
if (this.reads >= 4) throw new ForwardFailure('RATE_LIMITED');
|
||||
this.reads++;
|
||||
const validate = (stat: BigIntStats) => {
|
||||
if (!stat.isFile() || !identity(record.stat, stat) || (record.append
|
||||
? stat.size < record.stat.size
|
||||
: stat.size !== record.stat.size || stat.mtimeNs !== record.stat.mtimeNs
|
||||
|| stat.ctimeNs !== record.stat.ctimeNs)) throw new ForwardFailure('RESYNC_REQUIRED');
|
||||
};
|
||||
try {
|
||||
validate(await this.safe(record.parts, deadline_at));
|
||||
const handle = await open(path.join(this.root, ...record.parts), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
try {
|
||||
this.check(deadline_at);
|
||||
validate(await handle.stat({bigint: true}));
|
||||
validate(await this.safe(record.parts, deadline_at));
|
||||
const buffer = Buffer.alloc(Math.min(payload.length ?? CHUNK, size - offset));
|
||||
let received = 0;
|
||||
while (received < buffer.length) {
|
||||
const {bytesRead} = await handle.read(buffer, received, buffer.length - received, offset + received);
|
||||
this.check(deadline_at);
|
||||
if (bytesRead === 0) throw new ForwardFailure('RESYNC_REQUIRED');
|
||||
received += bytesRead;
|
||||
}
|
||||
validate(await handle.stat({bigint: true}));
|
||||
validate(await this.safe(record.parts, deadline_at));
|
||||
this.check(deadline_at);
|
||||
if (record.expires_at <= this.now()) throw new ForwardFailure('INVALID_REQUEST');
|
||||
return {id: payload.id, name: record.parts.at(-1)!, encoding: 'base64', offset,
|
||||
next_offset: offset + received, total_bytes: size, eof: offset + received === size,
|
||||
data: buffer.toString('base64')};
|
||||
} finally { await handle.close(); }
|
||||
} catch (error) {
|
||||
this.check(deadline_at);
|
||||
throw error instanceof ForwardFailure ? error
|
||||
: new ForwardFailure(missing(error) ? 'RESYNC_REQUIRED' : 'INTERNAL_ERROR');
|
||||
} finally { this.reads--; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import {createHash} from 'node:crypto';
|
||||
import {io, type Socket} from 'socket.io-client';
|
||||
import {BotStream} from './stream.ts';
|
||||
import {BotFileService} from './files.ts';
|
||||
import type {EnsureForward, ForwardAck, ForwardReady, ForwardScope} from '../../types/forward.ts';
|
||||
import {error_ack, exact, failure_code, ForwardFailure, success_data, validate_command, validate_join,
|
||||
validate_rpc} from '../../utils/forward_protocol.ts';
|
||||
|
||||
type Entry = {
|
||||
command: EnsureForward;
|
||||
chain: Promise<unknown>;
|
||||
pending: number;
|
||||
socket?: Socket;
|
||||
detach?: () => void;
|
||||
ready: boolean;
|
||||
terminal: boolean;
|
||||
epoch: number;
|
||||
lease: number;
|
||||
retain_until: number;
|
||||
expiry?: ReturnType<typeof setTimeout>;
|
||||
stream?: BotStream;
|
||||
files?: Pick<BotFileService, 'list' | 'read' | 'dispose'>;
|
||||
unsubscribe?: () => void;
|
||||
cancel: Set<() => void>;
|
||||
used_tokens: Map<string, number>;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
socket_factory?: typeof io;
|
||||
stream_factory?: (bot_id: number) => BotStream;
|
||||
file_factory?: (bot_id: number) => Pick<BotFileService, 'list' | 'read' | 'dispose'>;
|
||||
};
|
||||
|
||||
const scope_keys = ['forward_id', 'forward_session_id', 'node_session_id', 'node_id', 'bot_id'] as const;
|
||||
const fingerprint = (token: string) => createHash('sha256').update(token).digest('hex');
|
||||
const managers = new WeakMap<Socket, NodeForwardManager>();
|
||||
|
||||
export class NodeForwardManager {
|
||||
private readonly entries = new Map<string, Entry>();
|
||||
private readonly streams = new Map<number, {stream: BotStream; refs: number}>();
|
||||
private readonly socket_factory: typeof io;
|
||||
private readonly stream_factory: (bot_id: number) => BotStream;
|
||||
private readonly file_factory: (bot_id: number) => Pick<BotFileService, 'list' | 'read' | 'dispose'>;
|
||||
private stopped = false;
|
||||
|
||||
constructor(readonly node_id: number, readonly node_session_id: string, options: Options = {}) {
|
||||
this.socket_factory = options.socket_factory ?? io;
|
||||
this.stream_factory = options.stream_factory ?? (bot_id => new BotStream(bot_id));
|
||||
this.file_factory = options.file_factory ?? (bot_id => new BotFileService(bot_id));
|
||||
}
|
||||
|
||||
async ensure(raw: unknown): Promise<ForwardAck<ForwardReady>> {
|
||||
let entry: Entry | undefined;
|
||||
try {
|
||||
const command = validate_command(raw);
|
||||
this.check_command(command);
|
||||
for (const [id, previous] of this.entries) {
|
||||
if (previous.terminal && previous.pending === 0
|
||||
&& previous.retain_until + 5000 <= Date.now()) this.entries.delete(id);
|
||||
}
|
||||
entry = this.entries.get(command.forward_session_id);
|
||||
if (entry) {
|
||||
if (scope_keys.some(key => entry!.command[key] !== command[key])
|
||||
|| (['url', 'path', 'namespace'] as const).some(key => entry!.command[key] !== command[key])) {
|
||||
throw new ForwardFailure('SESSION_STALE');
|
||||
}
|
||||
if (entry.terminal) throw new ForwardFailure('SESSION_STALE');
|
||||
if (entry.pending >= 16) throw new ForwardFailure('RATE_LIMITED');
|
||||
} else {
|
||||
if (this.entries.size >= 4096 || [...this.entries.values()].filter(item => !item.terminal).length >= 500) {
|
||||
throw new ForwardFailure('RATE_LIMITED');
|
||||
}
|
||||
// Retain closed identities past all known credentials, separately from active capacity.
|
||||
entry = {command: {...command, token: ''}, chain: Promise.resolve(), pending: 0, ready: false, terminal: false,
|
||||
epoch: 0, lease: 0, retain_until: command.expires_at, cancel: new Set(), used_tokens: new Map()};
|
||||
this.entries.set(command.forward_session_id, entry);
|
||||
this.arm_expiry(entry);
|
||||
}
|
||||
const current = entry;
|
||||
current.retain_until = Math.max(current.retain_until, command.expires_at);
|
||||
current.pending++;
|
||||
let running = false;
|
||||
const run = current.chain.then(() => {
|
||||
running = true;
|
||||
return this.establish(current, command);
|
||||
});
|
||||
current.chain = run.then(() => { current.pending--; }, () => { current.pending--; });
|
||||
return await this.until_deadline(run, command.deadline_at, () => {
|
||||
if (running) this.close_transport(current);
|
||||
});
|
||||
} catch (error) {
|
||||
return error_ack(failure_code(error));
|
||||
}
|
||||
}
|
||||
|
||||
private check_command(command: EnsureForward) {
|
||||
if (this.stopped || command.node_session_id !== this.node_session_id) throw new ForwardFailure('SESSION_STALE');
|
||||
if (command.node_id !== this.node_id) throw new ForwardFailure('FORBIDDEN');
|
||||
if (command.deadline_at <= Date.now()) throw new ForwardFailure('TIMEOUT');
|
||||
if (command.expires_at <= Date.now()) throw new ForwardFailure('TOKEN_EXPIRED');
|
||||
}
|
||||
|
||||
private scope(command: EnsureForward): ForwardScope {
|
||||
const {forward_id, forward_session_id, node_session_id, node_id, bot_id} = command;
|
||||
return {forward_id, forward_session_id, node_session_id, node_id, bot_id};
|
||||
}
|
||||
|
||||
private async establish(entry: Entry, command: EnsureForward): Promise<ForwardAck<ForwardReady>> {
|
||||
this.check_command(command);
|
||||
if (entry.terminal) throw new ForwardFailure('SESSION_STALE');
|
||||
if (command.expires_at < entry.lease) throw new ForwardFailure('SESSION_STALE');
|
||||
const digest = fingerprint(command.token);
|
||||
for (const [token, expiry] of entry.used_tokens) if (expiry <= Date.now()) entry.used_tokens.delete(token);
|
||||
if (!entry.used_tokens.has(digest) && entry.used_tokens.size >= 256) throw new ForwardFailure('RATE_LIMITED');
|
||||
let socket = entry.socket;
|
||||
try {
|
||||
if (socket?.connected && entry.ready) {
|
||||
entry.used_tokens.set(digest, command.expires_at);
|
||||
const joined = validate_join(await this.ack(entry, socket, 'reauth', {token: command.token}, command), command, false);
|
||||
this.check_command(command);
|
||||
if (!joined.node_ready) {
|
||||
entry.ready = false;
|
||||
await this.ready(entry, socket, command);
|
||||
}
|
||||
} else {
|
||||
if (entry.used_tokens.has(digest)) throw new ForwardFailure('TOKEN_EXPIRED');
|
||||
this.close_transport(entry);
|
||||
// No autonomous reconnect: a join window can expire before the lease does.
|
||||
socket = this.socket_factory(`${command.url.replace(/\/$/, '')}${command.namespace === '/' ? '' : command.namespace}`, {
|
||||
path: command.path, autoConnect: false, reconnection: false, forceNew: true,
|
||||
timeout: Math.min(10_000, this.remaining(command)), auth: {token: command.token},
|
||||
});
|
||||
entry.socket = socket;
|
||||
this.install_transport(entry, socket);
|
||||
await this.connect(entry, socket, command);
|
||||
entry.used_tokens.set(digest, command.expires_at);
|
||||
validate_join(await this.ack(entry, socket, 'join_room', {token: command.token}, command), command, true);
|
||||
this.check_command(command);
|
||||
if (!entry.stream) {
|
||||
let shared = this.streams.get(command.bot_id);
|
||||
if (!shared) {
|
||||
shared = {stream: this.stream_factory(command.bot_id), refs: 0};
|
||||
this.streams.set(command.bot_id, shared);
|
||||
}
|
||||
shared.refs++;
|
||||
entry.stream = shared.stream;
|
||||
}
|
||||
this.observe_updates(entry, socket);
|
||||
await this.ready(entry, socket, command);
|
||||
}
|
||||
this.check_command(command);
|
||||
if (entry.socket !== socket || !socket.connected || entry.terminal || !entry.ready) throw new ForwardFailure('SESSION_STALE');
|
||||
entry.command = {...command, token: ''};
|
||||
entry.lease = command.expires_at;
|
||||
entry.used_tokens.set(digest, command.expires_at);
|
||||
this.arm_expiry(entry);
|
||||
entry.stream?.resync();
|
||||
this.check_command(command);
|
||||
return {status: 'success', data: {...this.scope(command), node_ready: true, lease_expires_at: command.expires_at}};
|
||||
} catch (error) {
|
||||
this.close_transport(entry);
|
||||
if (!entry.lease) this.release_stream(entry);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private remaining(command: EnsureForward) {
|
||||
const remaining = Math.min(command.deadline_at, command.expires_at) - Date.now();
|
||||
if (remaining <= 0) throw new ForwardFailure('TIMEOUT');
|
||||
return Math.min(remaining, 2_147_483_647);
|
||||
}
|
||||
|
||||
private until_deadline<T>(task: Promise<T>, deadline: number, timeout: () => void): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
timeout();
|
||||
reject(new ForwardFailure('TIMEOUT'));
|
||||
}, Math.max(0, Math.min(deadline - Date.now(), 2_147_483_647)));
|
||||
task.then(resolve, reject).finally(() => clearTimeout(timer));
|
||||
});
|
||||
}
|
||||
|
||||
private connect(entry: Entry, socket: Socket, command: EnsureForward): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const done = (error?: ForwardFailure) => {
|
||||
clearTimeout(timer);
|
||||
socket.off('connect', connected);
|
||||
socket.off('connect_error', failed);
|
||||
entry.cancel.delete(cancel);
|
||||
if (error) reject(error); else resolve();
|
||||
};
|
||||
const connected = () => done();
|
||||
const failed = () => done(new ForwardFailure('RELAY_UNAVAILABLE'));
|
||||
const cancel = () => done(new ForwardFailure('CANCELLED'));
|
||||
const timer = setTimeout(() => done(new ForwardFailure('TIMEOUT')), this.remaining(command));
|
||||
entry.cancel.add(cancel);
|
||||
socket.once('connect', connected);
|
||||
socket.once('connect_error', failed);
|
||||
socket.connect();
|
||||
});
|
||||
}
|
||||
|
||||
private async ack(entry: Entry, socket: Socket, event: string, payload: unknown, command: EnsureForward): Promise<unknown> {
|
||||
if (entry.terminal || entry.socket !== socket || !socket.connected || this.stopped) throw new ForwardFailure('SESSION_STALE');
|
||||
const epoch = entry.epoch;
|
||||
const result = await new Promise<unknown>((resolve, reject) => {
|
||||
const cancel = () => { entry.cancel.delete(cancel); reject(new ForwardFailure('CANCELLED')); };
|
||||
entry.cancel.add(cancel);
|
||||
socket.timeout(this.remaining(command)).emit(event, payload, (error: unknown, response: unknown) => {
|
||||
entry.cancel.delete(cancel);
|
||||
if (error) reject(new ForwardFailure('TIMEOUT')); else resolve(response);
|
||||
});
|
||||
});
|
||||
if (entry.terminal || entry.epoch !== epoch || entry.socket !== socket || !socket.connected || this.stopped) {
|
||||
throw new ForwardFailure('SESSION_STALE');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async ready(entry: Entry, socket: Socket, command: EnsureForward) {
|
||||
const data = success_data(await this.ack(entry, socket, 'forward_node_ready', {
|
||||
forward_session_id: command.forward_session_id,
|
||||
}, command));
|
||||
if (!exact(data, ['node_ready']) || data.node_ready !== true) throw new ForwardFailure('RELAY_UNAVAILABLE');
|
||||
entry.ready = true;
|
||||
}
|
||||
|
||||
private install_transport(entry: Entry, socket: Socket) {
|
||||
const seen = new Map<string, number>();
|
||||
const rpc = async (raw: unknown, callback: unknown) => {
|
||||
if (typeof callback !== 'function') return;
|
||||
let response: ForwardAck;
|
||||
try {
|
||||
// Relay may dispatch immediately after accepting ready, before its ACK reaches Node.
|
||||
if (entry.socket !== socket || !socket.connected || entry.terminal || this.stopped || !entry.stream) {
|
||||
throw new ForwardFailure('SESSION_STALE');
|
||||
}
|
||||
const request = validate_rpc(raw);
|
||||
if (request.forward_session_id !== entry.command.forward_session_id) throw new ForwardFailure('SESSION_STALE');
|
||||
if (request.deadline_at <= Date.now()) throw new ForwardFailure('TIMEOUT');
|
||||
if (entry.lease && entry.lease <= Date.now()) throw new ForwardFailure('TOKEN_EXPIRED');
|
||||
for (const [id, deadline] of seen) if (deadline <= Date.now()) seen.delete(id);
|
||||
if (seen.has(request.correlation_id)) throw new ForwardFailure('CANCELLED');
|
||||
if (seen.size >= 512) throw new ForwardFailure('RATE_LIMITED');
|
||||
seen.set(request.correlation_id, request.deadline_at);
|
||||
let data: unknown;
|
||||
switch (request.method) {
|
||||
case 'bot.snapshot': data = entry.stream.snapshot(); break;
|
||||
case 'get_context_info': data = entry.stream.get_context_info(request.payload, request.deadline_at); break;
|
||||
case 'bot.logs': data = entry.stream.logs(request.payload as {limit: number; stream_id?: string; after_seq?: number}); break;
|
||||
case 'get_log_history':
|
||||
case 'get_data_file_list':
|
||||
case 'get_file_data':
|
||||
case 'execute_function':
|
||||
case 'workflow.start':
|
||||
case 'workflow.stop':
|
||||
case 'workflow.snapshot':
|
||||
case 'workflow.catalog':
|
||||
case 'workflow.runtime':
|
||||
case 'workflow.control':
|
||||
case 'workflow.command':
|
||||
case 'tab_complete': {
|
||||
const epoch = entry.epoch;
|
||||
const check_transport = () => {
|
||||
if (entry.socket !== socket || !socket.connected || entry.terminal || this.stopped || entry.epoch !== epoch)
|
||||
throw new ForwardFailure('SESSION_STALE');
|
||||
if (request.deadline_at <= Date.now()) throw new ForwardFailure('TIMEOUT');
|
||||
if (entry.lease && entry.lease <= Date.now()) throw new ForwardFailure('TOKEN_EXPIRED');
|
||||
};
|
||||
if (request.method === 'get_data_file_list' || request.method === 'get_file_data') {
|
||||
// File IDs belong to this transport/session, never the shared BotStream.
|
||||
const files = entry.files ??= this.file_factory(entry.command.bot_id);
|
||||
const deadline = Math.min(request.deadline_at, entry.lease || entry.command.expires_at);
|
||||
data = request.method === 'get_data_file_list' ? await files.list(deadline, entry.lease || entry.command.expires_at)
|
||||
: await files.read(request.payload as {id: string; offset?: number; length?: number}, deadline);
|
||||
if (request.method === 'get_data_file_list') {
|
||||
const listing = data as {expires_at: number};
|
||||
listing.expires_at = Math.min(listing.expires_at, entry.lease || entry.command.expires_at);
|
||||
}
|
||||
} else if (request.method === 'workflow.start') {
|
||||
data = entry.stream.workflow_start(request.payload, request.deadline_at, check_transport);
|
||||
} else if (request.method === 'workflow.stop') {
|
||||
data = await this.until_deadline(entry.stream.workflow_stop(request.payload, request.deadline_at, check_transport),
|
||||
request.deadline_at, () => {});
|
||||
} else if (request.method === 'workflow.snapshot') {
|
||||
data = entry.stream.workflow_snapshot(request.payload, request.deadline_at, check_transport);
|
||||
} else if (request.method === 'workflow.catalog') {
|
||||
data = entry.stream.workflow_catalog(request.payload, request.deadline_at, check_transport);
|
||||
} else if (request.method === 'workflow.runtime') {
|
||||
data = entry.stream.workflow_runtime(request.payload, request.deadline_at, check_transport);
|
||||
} else if (request.method === 'workflow.control') {
|
||||
data = entry.stream.workflow_control(request.payload, request.deadline_at, check_transport);
|
||||
} else if (request.method === 'workflow.command') {
|
||||
data = entry.stream.workflow_command(request.payload, request.deadline_at, check_transport);
|
||||
} else data = request.method === 'get_log_history' ? await entry.stream.get_log_history()
|
||||
: request.method === 'execute_function' ? await entry.stream.execute_function(
|
||||
request.payload as {function: string; name: string; args: unknown[]}, request.deadline_at, check_transport)
|
||||
: await entry.stream.tab_complete(request.payload, request.deadline_at);
|
||||
if (entry.socket !== socket || !socket.connected || entry.terminal || this.stopped || entry.epoch !== epoch)
|
||||
throw new ForwardFailure('SESSION_STALE');
|
||||
if (request.deadline_at <= Date.now()) throw new ForwardFailure('TIMEOUT');
|
||||
if (entry.lease && entry.lease <= Date.now()) throw new ForwardFailure('TOKEN_EXPIRED');
|
||||
break;
|
||||
}
|
||||
case 'bot.chat': data = entry.stream.chat(request.payload as {message: string}, request.deadline_at); break;
|
||||
}
|
||||
response = {status: 'success', data};
|
||||
} catch (error) {
|
||||
response = error_ack(failure_code(error));
|
||||
}
|
||||
try { callback(response); } catch { /* The request ACK must never escape an event callback. */ }
|
||||
};
|
||||
const closed = (raw: unknown) => {
|
||||
if (entry.socket !== socket || !exact(raw, ['reason', 'code'], ['forward_session_id'])
|
||||
|| (raw.forward_session_id !== undefined && raw.forward_session_id !== entry.command.forward_session_id)) return;
|
||||
this.retire(entry);
|
||||
};
|
||||
const disconnected = () => {
|
||||
if (entry.socket === socket) this.close_transport(entry);
|
||||
};
|
||||
const status = (raw: unknown) => {
|
||||
if (entry.socket === socket && exact(raw, ['forward_session_id', 'node_ready', 'reason'])
|
||||
&& raw.forward_session_id === entry.command.forward_session_id && raw.reason === 'resync_required') {
|
||||
entry.stream?.resync();
|
||||
}
|
||||
};
|
||||
socket.on('relay_request', rpc);
|
||||
socket.on('forward_session_closed', closed);
|
||||
socket.on('relay_status', status);
|
||||
socket.on('disconnect', disconnected);
|
||||
entry.detach = () => {
|
||||
socket.off('relay_request', rpc);
|
||||
socket.off('forward_session_closed', closed);
|
||||
socket.off('relay_status', status);
|
||||
socket.off('disconnect', disconnected);
|
||||
};
|
||||
}
|
||||
|
||||
private observe_updates(entry: Entry, socket: Socket) {
|
||||
entry.unsubscribe?.();
|
||||
let window = Date.now();
|
||||
let count = 0;
|
||||
entry.unsubscribe = entry.stream!.subscribe(event => {
|
||||
if ((event.name !== 'log' && event.name !== 'info_update') || entry.socket !== socket || !socket.connected
|
||||
|| !entry.ready || entry.terminal || this.stopped) return;
|
||||
if (Date.now() - window >= 1000) { window = Date.now(); count = 0; }
|
||||
if (++count > 150 || (socket.io.engine?.writeBuffer.length ?? 0) >= 64) return;
|
||||
if (event.name === 'log') {
|
||||
socket.volatile.emit('bot_log', {...event, forward_session_id: entry.command.forward_session_id});
|
||||
} else {
|
||||
const {name, payload, ...metadata} = event;
|
||||
const update = {...metadata,
|
||||
forward_session_id: entry.command.forward_session_id, data: payload};
|
||||
// Plugin lifecycle transitions can finish within the same transport
|
||||
// write. Volatile would drop the final state while that write drains,
|
||||
// leaving the UI initializing until another plugin change occurs.
|
||||
// Keep the shared rate/buffer bounds above for these queued packets.
|
||||
if (payload.name === 'plugin_states' || payload.name === 'workflow_states') socket.emit('info_update', update);
|
||||
else socket.volatile.emit('info_update', update);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private close_transport(entry: Entry) {
|
||||
entry.epoch++;
|
||||
entry.ready = false;
|
||||
entry.unsubscribe?.();
|
||||
entry.unsubscribe = undefined;
|
||||
entry.files?.dispose();
|
||||
entry.files = undefined;
|
||||
const socket = entry.socket;
|
||||
entry.socket = undefined;
|
||||
entry.detach?.();
|
||||
entry.detach = undefined;
|
||||
for (const cancel of [...entry.cancel]) cancel();
|
||||
entry.cancel.clear();
|
||||
socket?.disconnect();
|
||||
if (socket) socket.auth = {};
|
||||
}
|
||||
|
||||
private arm_expiry(entry: Entry) {
|
||||
clearTimeout(entry.expiry);
|
||||
const expires_at = Math.max(entry.lease, entry.command.expires_at);
|
||||
entry.expiry = setTimeout(() => {
|
||||
if (expires_at <= Date.now()) this.retire(entry);
|
||||
else this.arm_expiry(entry);
|
||||
}, Math.min(Math.max(0, expires_at - Date.now()), 2_147_483_647));
|
||||
entry.expiry.unref();
|
||||
}
|
||||
|
||||
private release_stream(entry: Entry) {
|
||||
if (!entry.stream) return;
|
||||
const shared = this.streams.get(entry.command.bot_id);
|
||||
if (shared && --shared.refs === 0) {
|
||||
shared.stream.dispose();
|
||||
this.streams.delete(entry.command.bot_id);
|
||||
}
|
||||
entry.stream = undefined;
|
||||
}
|
||||
|
||||
private retire(entry: Entry) {
|
||||
entry.terminal = true;
|
||||
clearTimeout(entry.expiry);
|
||||
this.close_transport(entry);
|
||||
this.release_stream(entry);
|
||||
entry.used_tokens.clear();
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.stopped) return;
|
||||
this.stopped = true;
|
||||
for (const entry of this.entries.values()) this.retire(entry);
|
||||
this.entries.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function bind_node_session(socket: Socket, node_id: number, node_session_id: string): void {
|
||||
clear_node_session(socket);
|
||||
managers.set(socket, new NodeForwardManager(node_id, node_session_id));
|
||||
}
|
||||
|
||||
export function clear_node_session(socket: Socket): void {
|
||||
managers.get(socket)?.stop();
|
||||
managers.delete(socket);
|
||||
}
|
||||
|
||||
export function ensure_forward(socket: Socket, command: unknown): Promise<ForwardAck<ForwardReady>> {
|
||||
if (!socket.connected) return Promise.resolve(error_ack('NODE_OFFLINE'));
|
||||
return managers.get(socket)?.ensure(command) ?? Promise.resolve(error_ack('UNAUTHORIZED'));
|
||||
}
|
||||
@@ -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