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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {base_define} from "../../../types/bot/plugins/plugin.ts";
|
||||
|
||||
export const define: base_define = {
|
||||
path: "block",
|
||||
i18n: [
|
||||
{
|
||||
lang: "zh-CN",
|
||||
name: "方块操作",
|
||||
description: "Bot 方块操作, 提供方块相关功能"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {block_arguments, run_block_operation, type CursorArgs} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<CursorArgs> {
|
||||
readonly name = 'destroy_cursor_block';
|
||||
readonly i18n = {lang: 'zh-CN', name: '破坏光标方块', description: '破坏光标指向的方块,默认保留视角,不自动移动或切换工具'};
|
||||
readonly args = block_arguments('cursor');
|
||||
async run(bot: Bot, args: CursorArgs): Promise<boolean> {
|
||||
return run_block_operation(bot, args, 'cursor', 'destroy');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {block_arguments, run_block_operation, type NearestArgs} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<NearestArgs> {
|
||||
readonly name = 'destroy_nearest_block';
|
||||
readonly i18n = {lang: 'zh-CN', name: '破坏最近方块', description: '破坏范围内名称匹配且距离眼睛最近的已加载方块,不自动移动或切换工具'};
|
||||
readonly args = block_arguments('nearest');
|
||||
async run(bot: Bot, args: NearestArgs): Promise<boolean> {
|
||||
return run_block_operation(bot, args, 'nearest', 'destroy');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {block_arguments, run_block_operation, type PositionArgs} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<PositionArgs> {
|
||||
readonly name = 'destroy_position_block';
|
||||
readonly i18n = {lang: 'zh-CN', name: '破坏指定方块', description: '破坏指定坐标及面的方块,操作距离为 4.5,不自动移动或切换工具'};
|
||||
readonly args = block_arguments('position');
|
||||
async run(bot: Bot, args: PositionArgs): Promise<boolean> {
|
||||
return run_block_operation(bot, args, 'position', 'destroy');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {block_arguments, run_block_operation, type GroupArgs} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<GroupArgs> {
|
||||
readonly name = 'destroy_position_group_block';
|
||||
readonly i18n = {lang: 'zh-CN', name: '依次破坏方块', description: '依次破坏坐标列表中的方块,失败停止且不回滚,不自动移动或切换工具'};
|
||||
readonly args = block_arguments('group');
|
||||
async run(bot: Bot, args: GroupArgs): Promise<boolean> {
|
||||
return run_block_operation(bot, args, 'group', 'destroy');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {block_arguments, run_block_operation, type CursorArgs} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<CursorArgs> {
|
||||
readonly name = 'place_cursor_block';
|
||||
readonly i18n = {lang: 'zh-CN', name: '向光标方块放置', description: '使用主手物品向光标命中面及交点放置,默认保留视角,不自动移动或装备'};
|
||||
readonly args = block_arguments('cursor');
|
||||
async run(bot: Bot, args: CursorArgs): Promise<boolean> {
|
||||
return run_block_operation(bot, args, 'cursor', 'place');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {block_arguments, run_block_operation, type NearestArgs} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<NearestArgs> {
|
||||
readonly name = 'place_nearest_block';
|
||||
readonly i18n = {lang: 'zh-CN', name: '向最近方块放置', description: '使用主手物品向范围内最近的匹配方块指定面放置,不自动移动或装备'};
|
||||
readonly args = block_arguments('nearest');
|
||||
async run(bot: Bot, args: NearestArgs): Promise<boolean> {
|
||||
return run_block_operation(bot, args, 'nearest', 'place');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {block_arguments, run_block_operation, type PositionArgs} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<PositionArgs> {
|
||||
readonly name = 'place_position_block';
|
||||
readonly i18n = {lang: 'zh-CN', name: '向指定方块放置', description: '使用主手物品向指定坐标方块的指定面放置,距离为 4.5,不自动移动或装备'};
|
||||
readonly args = block_arguments('position');
|
||||
async run(bot: Bot, args: PositionArgs): Promise<boolean> {
|
||||
return run_block_operation(bot, args, 'position', 'place');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {block_arguments, run_block_operation, type GroupArgs} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<GroupArgs> {
|
||||
readonly name = 'place_position_group_block';
|
||||
readonly i18n = {lang: 'zh-CN', name: '依次放置方块', description: '使用主手物品按列表放置,每次间隔 100 毫秒,失败停止且不回滚,不自动移动或装备'};
|
||||
readonly args = block_arguments('group');
|
||||
async run(bot: Bot, args: GroupArgs): Promise<boolean> {
|
||||
return run_block_operation(bot, args, 'group', 'place');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import type {Bot, RaycastBlock} from '@wp2508/mineflayer';
|
||||
import type {FunctionArgument} from '../../../types/bot/function/function.ts';
|
||||
import {on_function_cancel} from '../cancellation.ts';
|
||||
|
||||
type Block = NonNullable<ReturnType<Bot['blockAt']>>;
|
||||
type Vector = Bot['entity']['position'];
|
||||
export type BlockOperation = 'destroy' | 'place' | 'use';
|
||||
export type BlockTarget = 'cursor' | 'nearest' | 'position' | 'group';
|
||||
export type CursorArgs = [reach: number, need_look?: boolean];
|
||||
export type NearestArgs = [name: string, reach: number, face: number, need_look?: boolean];
|
||||
export type PositionArgs = [x: number, y: number, z: number, face: number, need_look?: boolean];
|
||||
export type GroupArgs = [positions: number[], need_look?: boolean];
|
||||
|
||||
const digging = new WeakSet<Bot>();
|
||||
const faces = [[0, -1, 0], [0, 1, 0], [0, 0, -1], [0, 0, 1], [-1, 0, 0], [1, 0, 0]] as const;
|
||||
|
||||
export function block_arguments(target: BlockTarget): FunctionArgument[] {
|
||||
const arg = (name: string, type: FunctionArgument['type'], label: string, description: string): FunctionArgument =>
|
||||
({name, type, i18n: {lang: 'zh-CN', name: label, description}});
|
||||
const reach = arg('reach', 'number', '最大距离', '眼睛到方块碰撞箱的最大距离,必须大于 0 且不超过 6');
|
||||
const face = arg('face', 'number', '操作面', '整数 0 下、1 上、2 北、3 南、4 西、5 东');
|
||||
const look = {...arg('need_look', 'boolean', '转向操作面', '操作前转向方块操作面的中心,默认不转向'), default: false};
|
||||
switch (target) {
|
||||
case 'cursor': return [reach, look];
|
||||
case 'nearest': return [arg('name', 'string', '方块 ID', '不区分大小写的方块 ID 子串,可带 minecraft: 前缀'), reach, face, look];
|
||||
case 'position': return [
|
||||
...['x', 'y', 'z'].map(axis => arg(axis, 'number', `${axis.toUpperCase()} 坐标`, '方块坐标,必须是安全整数;最大操作距离为 4.5')),
|
||||
face, look,
|
||||
];
|
||||
case 'group': return [arg('positions', 'number-array', '坐标与操作面列表', '非空平铺数组,每四项为 x、y、z、face;坐标为安全整数,面为 0 到 5,最大距离 4.5;按顺序操作,失败停止且不回滚'), look];
|
||||
}
|
||||
}
|
||||
|
||||
function valid_face(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 5;
|
||||
}
|
||||
|
||||
function valid_position(values: unknown[]): boolean {
|
||||
return values.length === 4 && [values[0], values[1], values[2]].every(value => typeof value === 'number' && Number.isSafeInteger(value))
|
||||
&& valid_face(values[3]);
|
||||
}
|
||||
|
||||
function valid_block(block: Block | null | undefined): block is Block {
|
||||
return !!block && !!block.position && typeof block.name === 'string'
|
||||
&& !['air', 'cave_air', 'void_air'].includes(block.name.toLowerCase().replace(/^minecraft:/, ''))
|
||||
&& [block.position.x, block.position.y, block.position.z].every(Number.isSafeInteger);
|
||||
}
|
||||
|
||||
function eye(bot: Bot): Vector | undefined {
|
||||
const self = bot.entity;
|
||||
const height = (self as Bot['entity'] & {eyeHeight?: number})?.eyeHeight ?? self?.height;
|
||||
if (!self?.position || ![self.position.x, self.position.y, self.position.z, height].every(Number.isFinite)) return;
|
||||
return self.position.offset(0, height, 0);
|
||||
}
|
||||
|
||||
function distance(point: Vector, block: Block): number {
|
||||
const pos = block.position;
|
||||
return Math.hypot(Math.max(pos.x - point.x, point.x - pos.x - 1, 0),
|
||||
Math.max(pos.y - point.y, point.y - pos.y - 1, 0), Math.max(pos.z - point.z, point.z - pos.z - 1, 0));
|
||||
}
|
||||
|
||||
export async function run_block_operation(bot: Bot, args: unknown[], target: BlockTarget, operation: BlockOperation): Promise<boolean> {
|
||||
if (!Array.isArray(args)) return false;
|
||||
const length = target === 'position' ? 4 : target === 'nearest' ? 3 : 1;
|
||||
if (args.length !== length && args.length !== length + 1) return false;
|
||||
if (args.length > length && typeof args[length] !== 'boolean') return false;
|
||||
const need_look = args[length] === true;
|
||||
args = args.slice(0, length);
|
||||
let positions: number[][] = [];
|
||||
let reach = 4.5;
|
||||
let query = '';
|
||||
if (target === 'cursor' || target === 'nearest') {
|
||||
if (args.length !== (target === 'cursor' ? 1 : 3)) return false;
|
||||
const value = args[target === 'cursor' ? 0 : 1];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 6) return false;
|
||||
reach = value;
|
||||
if (target === 'nearest') {
|
||||
if (typeof args[0] !== 'string' || !valid_face(args[2])) return false;
|
||||
query = args[0].trim().toLowerCase().replace(/^minecraft:/, '');
|
||||
if (!query) return false;
|
||||
}
|
||||
} else if (target === 'position') {
|
||||
if (!valid_position(args)) return false;
|
||||
positions = [args.slice() as number[]];
|
||||
} else {
|
||||
if (args.length !== 1 || !Array.isArray(args[0]) || !args[0].length || args[0].length % 4 !== 0) return false;
|
||||
for (let i = 0; i < args[0].length; i += 4) {
|
||||
const position = args[0].slice(i, i + 4);
|
||||
if (!valid_position(position)) return false;
|
||||
positions.push(position);
|
||||
}
|
||||
}
|
||||
|
||||
const self = bot.entity;
|
||||
const client = bot._client;
|
||||
let ended = false;
|
||||
let owned_dig: Block | undefined;
|
||||
let cancel!: () => void;
|
||||
const cancelled = new Promise<false>(resolve => { cancel = () => resolve(false); });
|
||||
const active = () => !ended && !!self && !!client && bot.entity === self && bot._client === client
|
||||
&& self.isValid !== false && !(client as typeof client & {ended?: boolean}).ended;
|
||||
const stop_owned = () => {
|
||||
if (owned_dig && bot.targetDigBlock === owned_dig) {
|
||||
try { bot.stopDigging(); } catch { /* The connection may already be closed. */ }
|
||||
}
|
||||
};
|
||||
const on_end = () => { ended = true; stop_owned(); cancel(); };
|
||||
const unwatch = on_function_cancel(bot, on_end);
|
||||
const wait = async (promise: Promise<unknown>): Promise<boolean> => {
|
||||
const result = await Promise.race([promise.then(() => true, () => false), cancelled]);
|
||||
return result && active();
|
||||
};
|
||||
try {
|
||||
bot.on('end', on_end);
|
||||
if (!active() || !eye(bot) || operation === 'place' && !bot.heldItem) return false;
|
||||
let selected: Block | undefined;
|
||||
let cursor: RaycastBlock | undefined;
|
||||
if (target === 'cursor') {
|
||||
cursor = bot.blockAtCursor(reach) ?? undefined;
|
||||
if (!valid_block(cursor) || !valid_face(cursor.face) || !cursor.intersect
|
||||
|| ![cursor.intersect.x, cursor.intersect.y, cursor.intersect.z].every(Number.isFinite)) return false;
|
||||
selected = cursor;
|
||||
} else if (target === 'nearest') {
|
||||
// findBlocks sorts block origins around a floored point. Include every
|
||||
// candidate before applying our eye-to-AABB distance ordering.
|
||||
const radius = Math.ceil(reach + 2 * Math.sqrt(3));
|
||||
const matching = (block: Block | null) => !!block && typeof block.name === 'string'
|
||||
&& block.name.toLowerCase().replace(/^minecraft:/, '').includes(query);
|
||||
selected = bot.findBlocks({point: eye(bot)!, matching, maxDistance: radius, count: (2 * radius + 1) ** 3})
|
||||
.map(position => bot.blockAt(position)).filter(valid_block).filter(matching)
|
||||
.map(block => ({block: block!, distance: distance(eye(bot)!, block!)}))
|
||||
.filter(entry => entry.distance <= reach).sort((a, b) => a.distance - b.distance)[0]?.block;
|
||||
if (!selected) return false;
|
||||
}
|
||||
const total = target === 'group' ? positions.length : 1;
|
||||
for (let i = 0; i < total; i++) {
|
||||
if (!active()) return false;
|
||||
const position = positions[i];
|
||||
const block = selected ?? bot.blockAt(self.position.scaled(0).offset(position![0]!, position![1]!, position![2]!));
|
||||
const point = eye(bot);
|
||||
if (!valid_block(block) || !point || distance(point, block) > reach) return false;
|
||||
const loaded = bot.blockAt(block.position);
|
||||
if (!valid_block(loaded) || loaded.type !== block.type) return false;
|
||||
const face = cursor?.face ?? (target === 'nearest' ? args[2] as number : position![3]!);
|
||||
const components = faces[face]!;
|
||||
const vector = self.position.scaled(0).offset(components[0], components[1], components[2]);
|
||||
const delta = cursor?.intersect.minus(block.position);
|
||||
const {type, stateId} = block;
|
||||
const valid = () => {
|
||||
const point = eye(bot);
|
||||
const loaded = bot.blockAt(block.position);
|
||||
return active() && !!point && distance(point, block) <= reach && valid_block(loaded)
|
||||
&& loaded.type === type && loaded.stateId === stateId
|
||||
&& (operation !== 'place' || !!bot.heldItem)
|
||||
&& (operation !== 'destroy' || bot.canDigBlock(block));
|
||||
};
|
||||
if (operation === 'destroy') {
|
||||
if (digging.has(bot) || bot.targetDigBlock || !bot.canDigBlock(block)) return false;
|
||||
digging.add(bot);
|
||||
owned_dig = block;
|
||||
}
|
||||
try {
|
||||
if (need_look && !await wait(bot.lookAt(block.position.offset(0.5, 0.5, 0.5).plus(vector.scaled(0.5))))) return false;
|
||||
if (!valid()) return false;
|
||||
if (operation === 'destroy') {
|
||||
if (bot.targetDigBlock) return false;
|
||||
try {
|
||||
const promise = bot.dig(block, 'ignore', vector);
|
||||
if (!await wait(promise)) { stop_owned(); return false; }
|
||||
} catch {
|
||||
stop_owned();
|
||||
return false;
|
||||
}
|
||||
} else if (operation === 'place') {
|
||||
if (!await wait(bot._placeBlockWithOptions(block, vector, {swingArm: 'right', forceLook: 'ignore', delta}))) return false;
|
||||
} else if (!await wait(bot.activateBlock(block, vector, delta, 'ignore'))) return false;
|
||||
} finally {
|
||||
if (operation === 'destroy') {
|
||||
owned_dig = undefined;
|
||||
digging.delete(bot);
|
||||
}
|
||||
}
|
||||
if (!active()) return false;
|
||||
if (operation === 'place' && target === 'group' && i + 1 < total) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
if (!await wait(new Promise<void>(resolve => { timer = setTimeout(resolve, 100); }))) return false;
|
||||
} finally { clearTimeout(timer); }
|
||||
}
|
||||
}
|
||||
return active();
|
||||
} catch {
|
||||
stop_owned();
|
||||
return false;
|
||||
} finally {
|
||||
unwatch();
|
||||
bot.removeListener('end', on_end);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {block_arguments, run_block_operation, type CursorArgs} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<CursorArgs> {
|
||||
readonly name = 'use_cursor_block';
|
||||
readonly i18n = {lang: 'zh-CN', name: '使用光标方块', description: '右键使用光标命中方块的面与交点,默认保留视角,不自动移动或装备'};
|
||||
readonly args = block_arguments('cursor');
|
||||
async run(bot: Bot, args: CursorArgs): Promise<boolean> {
|
||||
return run_block_operation(bot, args, 'cursor', 'use');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {block_arguments, run_block_operation, type NearestArgs} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<NearestArgs> {
|
||||
readonly name = 'use_nearest_block';
|
||||
readonly i18n = {lang: 'zh-CN', name: '使用最近方块', description: '右键使用范围内最近的匹配方块指定面,不自动移动或装备'};
|
||||
readonly args = block_arguments('nearest');
|
||||
async run(bot: Bot, args: NearestArgs): Promise<boolean> {
|
||||
return run_block_operation(bot, args, 'nearest', 'use');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {block_arguments, run_block_operation, type PositionArgs} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<PositionArgs> {
|
||||
readonly name = 'use_position_block';
|
||||
readonly i18n = {lang: 'zh-CN', name: '使用指定方块', description: '右键使用指定坐标方块的指定面,距离为 4.5,不自动移动或装备'};
|
||||
readonly args = block_arguments('position');
|
||||
async run(bot: Bot, args: PositionArgs): Promise<boolean> {
|
||||
return run_block_operation(bot, args, 'position', 'use');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {block_arguments, run_block_operation, type GroupArgs} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<GroupArgs> {
|
||||
readonly name = 'use_position_group_block';
|
||||
readonly i18n = {lang: 'zh-CN', name: '依次使用方块', description: '按坐标列表依次右键使用方块,失败停止且不回滚,不自动移动或装备'};
|
||||
readonly args = block_arguments('group');
|
||||
async run(bot: Bot, args: GroupArgs): Promise<boolean> {
|
||||
return run_block_operation(bot, args, 'group', 'use');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {argument, guarded} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<[message: string]> {
|
||||
readonly name = 'chat';
|
||||
readonly i18n = {lang: 'zh-CN', name: '发送聊天', description: '原样发送聊天消息,成功仅表示本地调用完成,不等待服务器确认'};
|
||||
readonly args = [argument('message', 'string', '消息', '非空白消息,最多 256 个字符,不允许换行或空字符')];
|
||||
async run(bot: Bot, args: [string]): Promise<boolean> {
|
||||
if (!Array.isArray(args) || args.length !== 1 || typeof args[0] !== 'string'
|
||||
|| !args[0].trim() || args[0].length > 256 || /[\r\n\0]/.test(args[0])) return false;
|
||||
return guarded(bot, async session => {
|
||||
bot.chat(args[0]);
|
||||
return session.active();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {base_define} from "../../../types/bot/plugins/plugin.ts";
|
||||
|
||||
export const define: base_define = {
|
||||
path: "bot",
|
||||
i18n: [
|
||||
{
|
||||
lang: "zh-CN",
|
||||
name: "BOT操作",
|
||||
description: "Bot 自身操作, 提供自身相关功能"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {pulse} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<[]> {
|
||||
readonly name = 'jump';
|
||||
readonly i18n = {lang: 'zh-CN', name: '跳跃一次', description: '在地面且未按住跳跃时,按住跳跃直到下一物理刻,最多等待 1 秒'};
|
||||
readonly args: [] = [];
|
||||
async run(bot: Bot, args: []): Promise<boolean> {
|
||||
if (!Array.isArray(args) || args.length !== 0 || !bot.entity?.onGround) return false;
|
||||
return pulse(bot, 'jump');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {argument, guarded} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<[x: number, y: number, z: number]> {
|
||||
readonly name = 'look_at';
|
||||
readonly i18n = {lang: 'zh-CN', name: '看向坐标', description: '看向指定有限坐标,允许小数,最多等待 5 秒,目标不能与眼睛重合'};
|
||||
readonly args = ['x', 'y', 'z'].map(axis => argument(axis, 'number', `${axis} 坐标`, '有限数值,允许小数'));
|
||||
async run(bot: Bot, args: [number, number, number]): Promise<boolean> {
|
||||
if (!Array.isArray(args) || args.length !== 3 || !args.every(value => typeof value === 'number' && Number.isFinite(value))) return false;
|
||||
return guarded(bot, async session => {
|
||||
const self = bot.entity;
|
||||
const eye_height = (self as typeof self & {eyeHeight?: number}).eyeHeight;
|
||||
if (!self.position || ![self.position.x, self.position.y, self.position.z, eye_height].every(Number.isFinite)) return false;
|
||||
const target = self.position.offset(0, 0, 0);
|
||||
[target.x, target.y, target.z] = args;
|
||||
if (target.x === self.position.x && target.y === self.position.y + eye_height! && target.z === self.position.z) return false;
|
||||
return await session.wait(() => bot.lookAt(target), 5000, 'promise') && session.active();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type {Bot, ControlState} from '@wp2508/mineflayer';
|
||||
import type {FunctionArgument} from '../../../types/bot/function/function.ts';
|
||||
import {on_function_cancel} from '../cancellation.ts';
|
||||
|
||||
export const argument = (name: string, type: FunctionArgument['type'], label: string, description: string): FunctionArgument =>
|
||||
({name, type, i18n: {lang: 'zh-CN', name: label, description}});
|
||||
|
||||
const owners = new WeakMap<Bot, Map<ControlState, symbol>>();
|
||||
function controls(bot: Bot): Map<ControlState, symbol> {
|
||||
let keys = owners.get(bot);
|
||||
if (!keys) owners.set(bot, keys = new Map());
|
||||
return keys;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
active(): boolean;
|
||||
wait(start: () => unknown, duration: number, mode: 'physicsTick' | 'delay' | 'promise'): Promise<boolean>;
|
||||
}
|
||||
|
||||
export async function guarded(bot: Bot, operation: (session: Session) => Promise<boolean>): Promise<boolean> {
|
||||
const self = bot.entity;
|
||||
const client = bot._client;
|
||||
let ended = false;
|
||||
let cancel: (() => void) | undefined;
|
||||
const on_end = () => { ended = true; cancel?.(); };
|
||||
const active = () => !ended && !!self && !!client && self.isValid !== false
|
||||
&& bot.entity === self && bot._client === client && client.state === 'play';
|
||||
const unwatch = on_function_cancel(bot, on_end);
|
||||
try {
|
||||
if (!active()) return false;
|
||||
bot.on('end', on_end);
|
||||
bot.on('error', on_end);
|
||||
client.on('end', on_end);
|
||||
client.on('error', on_end);
|
||||
return await operation({active, wait(start, duration, mode) {
|
||||
return new Promise<boolean>(resolve => {
|
||||
let settled = false;
|
||||
let started = false;
|
||||
let tick_seen = false;
|
||||
const finish = (success: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
bot.removeListener('physicsTick', on_tick);
|
||||
cancel = undefined;
|
||||
resolve(success && active());
|
||||
};
|
||||
const on_tick = () => {
|
||||
tick_seen = true;
|
||||
if (started) finish(true);
|
||||
};
|
||||
const timer = setTimeout(() => finish(mode === 'delay'), duration);
|
||||
cancel = () => finish(false);
|
||||
if (mode === 'physicsTick') bot.on('physicsTick', on_tick);
|
||||
try {
|
||||
if (!active()) return finish(false);
|
||||
const result = start();
|
||||
started = true;
|
||||
Promise.resolve(result).then(() => {
|
||||
if (mode === 'promise') finish(true);
|
||||
}, () => finish(false));
|
||||
if (mode === 'physicsTick' && tick_seen) finish(true);
|
||||
} catch {
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
}});
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
unwatch();
|
||||
cancel?.();
|
||||
bot.removeListener('end', on_end);
|
||||
bot.removeListener('error', on_end);
|
||||
client?.removeListener('end', on_end);
|
||||
client?.removeListener('error', on_end);
|
||||
}
|
||||
}
|
||||
|
||||
export async function pulse(bot: Bot, key: ControlState, duration?: number): Promise<boolean> {
|
||||
return guarded(bot, async session => {
|
||||
if (bot.getControlState(key)) return false;
|
||||
const keys = controls(bot);
|
||||
const owner = Symbol(key);
|
||||
keys.set(key, owner);
|
||||
try {
|
||||
const success = await session.wait(() => bot.setControlState(key, true), duration ?? 1000,
|
||||
duration === undefined ? 'physicsTick' : 'delay');
|
||||
return success && session.active();
|
||||
} finally {
|
||||
// A persistent command may have taken ownership while this pulse was waiting.
|
||||
if (keys.get(key) === owner) {
|
||||
keys.delete(key);
|
||||
bot.setControlState(key, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function set_sneak(bot: Bot, enabled: boolean): void {
|
||||
controls(bot).delete('sneak');
|
||||
bot.setControlState('sneak', enabled);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {argument, guarded, set_sneak} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<[enabled: boolean]> {
|
||||
readonly name = 'squat_continue_switch';
|
||||
readonly i18n = {lang: 'zh-CN', name: '设置持续下蹲', description: '显式设置持续下蹲状态,重复设置相同状态不会切换'};
|
||||
readonly args = [argument('enabled', 'boolean', '启用下蹲', 'true 持续下蹲,false 取消下蹲')];
|
||||
async run(bot: Bot, args: [boolean]): Promise<boolean> {
|
||||
if (!Array.isArray(args) || args.length !== 1 || typeof args[0] !== 'boolean') return false;
|
||||
return guarded(bot, async session => {
|
||||
set_sneak(bot, args[0]);
|
||||
return session.active();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {pulse} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<[]> {
|
||||
readonly name = 'squat_once';
|
||||
readonly i18n = {lang: 'zh-CN', name: '下蹲一次', description: '未按住下蹲时,按住下蹲直到下一物理刻,最多等待 1 秒'};
|
||||
readonly args: [] = [];
|
||||
async run(bot: Bot, args: []): Promise<boolean> {
|
||||
if (!Array.isArray(args) || args.length !== 0) return false;
|
||||
return pulse(bot, 'sneak');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {cancel_functions} from '../cancellation.ts';
|
||||
|
||||
export class init implements BaseFunction<[]> {
|
||||
readonly name = 'stop_current_function';
|
||||
readonly i18n = {lang: 'zh-CN', name: '停止当前函数', description: '立即取消当前和排队函数,停止挖掘、使用物品并释放所有控制键'};
|
||||
readonly args: [] = [];
|
||||
|
||||
async run(bot: Bot, args: []): Promise<boolean> {
|
||||
if (!Array.isArray(args) || args.length !== 0) return false;
|
||||
let success = true;
|
||||
for (const stop of [() => cancel_functions(bot), () => bot.stopDigging(),
|
||||
() => bot.deactivateItem(), () => bot.clearControlStates()]) {
|
||||
try { stop(); } catch { success = false; }
|
||||
}
|
||||
return success;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type {Bot, ControlState} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {argument, pulse} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<[direction: string, duration_ms: number]> {
|
||||
readonly name = 'use_direction_key';
|
||||
readonly i18n = {lang: 'zh-CN', name: '按住方向键', description: '按住指定方向键一段时间后释放,拒绝操作已按住的方向键'};
|
||||
readonly args = [argument('direction', 'string', '方向', '仅允许 forward、back、left、right'),
|
||||
argument('duration_ms', 'number', '持续毫秒数', '1 到 5000 的安全整数')];
|
||||
async run(bot: Bot, args: [string, number]): Promise<boolean> {
|
||||
if (!Array.isArray(args) || args.length !== 2 || typeof args[0] !== 'string'
|
||||
|| !['forward', 'back', 'left', 'right'].includes(args[0])
|
||||
|| !Number.isSafeInteger(args[1]) || args[1] < 1 || args[1] > 5000) return false;
|
||||
return pulse(bot, args[0] as ControlState, args[1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {EventEmitter} from 'node:events';
|
||||
import {AsyncLocalStorage} from 'node:async_hooks';
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
|
||||
type Cancellation = {generation: number; events: EventEmitter};
|
||||
const scopes = new AsyncLocalStorage<{bot: Bot; cancellation: Cancellation}>();
|
||||
const states = new WeakMap<Bot, Cancellation & {scopes: Set<Cancellation>}>();
|
||||
function state(bot: Bot) {
|
||||
let value = states.get(bot);
|
||||
if (!value) states.set(bot, value = {generation: 0, events: new EventEmitter(), scopes: new Set()});
|
||||
return value;
|
||||
}
|
||||
|
||||
function current(bot: Bot): Cancellation {
|
||||
const scope = scopes.getStore();
|
||||
return scope?.bot === bot ? scope.cancellation : state(bot);
|
||||
}
|
||||
|
||||
export function cancellation_generation(bot: Bot): number {
|
||||
return current(bot).generation;
|
||||
}
|
||||
|
||||
export function on_function_cancel(bot: Bot, listener: () => void): () => void {
|
||||
const {events} = current(bot);
|
||||
events.on('cancel', listener);
|
||||
return () => { events.removeListener('cancel', listener); };
|
||||
}
|
||||
|
||||
/** Existing helpers inherit the call's cancellation scope across awaits. */
|
||||
export function create_function_cancellation(bot: Bot) {
|
||||
const value = state(bot);
|
||||
const cancellation: Cancellation = {generation: 0, events: new EventEmitter()};
|
||||
value.scopes.add(cancellation);
|
||||
return {
|
||||
run<T>(operation: () => T): T { return scopes.run({bot, cancellation}, operation); },
|
||||
cancel() {
|
||||
cancellation.generation++;
|
||||
cancellation.events.emit('cancel');
|
||||
},
|
||||
dispose() { value.scopes.delete(cancellation); },
|
||||
};
|
||||
}
|
||||
|
||||
export function cancel_functions(bot: Bot): void {
|
||||
const value = state(bot);
|
||||
value.generation++;
|
||||
value.events.emit('cancel');
|
||||
for (const cancellation of value.scopes) {
|
||||
cancellation.generation++;
|
||||
cancellation.events.emit('cancel');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {entity_arguments, run_entity_operation, type CursorArgs} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<CursorArgs> {
|
||||
readonly name = 'attack_cursor_entity';
|
||||
readonly i18n = {lang: 'zh-CN', name: '攻击光标实体', description: '攻击光标指向且在指定距离内的实体,不自动移动'};
|
||||
readonly args = entity_arguments(false, false, true);
|
||||
async run(bot: Bot, args: CursorArgs): Promise<boolean> {
|
||||
return run_entity_operation(bot, args, false, 'attack');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {entity_arguments, run_entity_operation, type NearestArgs} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<NearestArgs> {
|
||||
readonly name = 'attack_nearest_entity';
|
||||
readonly i18n = {lang: 'zh-CN', name: '攻击最近实体', description: '攻击指定距离内名称匹配的最近实体,可选瞄准,不自动移动'};
|
||||
readonly args = entity_arguments(true, false, true);
|
||||
async run(bot: Bot, args: NearestArgs): Promise<boolean> {
|
||||
return run_entity_operation(bot, args, true, 'attack', this.name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {base_define} from "../../../types/bot/plugins/plugin.ts";
|
||||
|
||||
export const define: base_define = {
|
||||
path: "entity",
|
||||
i18n: [
|
||||
{
|
||||
lang: "zh-CN",
|
||||
name: "实体操作",
|
||||
description: "Bot 实体操作, 提供实体相关功能"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {entity_arguments, run_entity_operation, type CursorItemArgs} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<CursorItemArgs> {
|
||||
readonly name = 'feed_cursor_entity';
|
||||
readonly i18n = {lang: 'zh-CN', name: '喂食光标实体', description: '对光标实体使用指定手的物品,可选瞄准,由服务器判断是否可以喂食'};
|
||||
readonly args = entity_arguments(false, true);
|
||||
async run(bot: Bot, args: CursorItemArgs): Promise<boolean> {
|
||||
return run_entity_operation(bot, args, false, 'item');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {entity_arguments, run_entity_operation, type NearestItemArgs} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<NearestItemArgs> {
|
||||
readonly name = 'feed_nearest_entity';
|
||||
readonly i18n = {lang: 'zh-CN', name: '喂食最近实体', description: '对指定距离内名称匹配的最近实体使用指定手的物品,由服务器判断是否可以喂食'};
|
||||
readonly args = entity_arguments(true, true);
|
||||
async run(bot: Bot, args: NearestItemArgs): Promise<boolean> {
|
||||
return run_entity_operation(bot, args, true, 'item', this.name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {entity_arguments, run_entity_operation, type CursorArgs} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<CursorArgs> {
|
||||
readonly name = 'null_main_hand_right_cursor_entity';
|
||||
readonly i18n = {lang: 'zh-CN', name: '空主手右键光标实体', description: '主手为空时右键交互光标实体,可选瞄准,主手有物品则失败'};
|
||||
readonly args = entity_arguments(false, false);
|
||||
|
||||
async run(bot: Bot, args: CursorArgs): Promise<boolean> {
|
||||
return run_entity_operation(bot, args, false, 'empty_hand');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {entity_arguments, run_entity_operation, type NearestArgs} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<NearestArgs> {
|
||||
readonly name = 'null_main_hand_right_nearest_entity';
|
||||
readonly i18n = {lang: 'zh-CN', name: '空主手右键最近实体', description: '主手为空时右键交互指定距离内名称匹配的最近实体,可选瞄准,主手有物品则失败'};
|
||||
readonly args = entity_arguments(true, false);
|
||||
|
||||
async run(bot: Bot, args: NearestArgs): Promise<boolean> {
|
||||
return run_entity_operation(bot, args, true, 'empty_hand', this.name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {entity_arguments, run_entity_operation, type CursorArgs} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<CursorArgs> {
|
||||
readonly name = 'open_cursor_entity_container';
|
||||
readonly i18n = {lang: 'zh-CN', name: '打开光标实体容器', description: '激活光标实体并等待新容器窗口,已有窗口时失败,最多等待五秒'};
|
||||
readonly args = entity_arguments(false, false);
|
||||
async run(bot: Bot, args: CursorArgs): Promise<boolean> {
|
||||
return run_entity_operation(bot, args, false, 'open');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {entity_arguments, run_entity_operation, type NearestArgs} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<NearestArgs> {
|
||||
readonly name = 'open_nearest_entity_container';
|
||||
readonly i18n = {lang: 'zh-CN', name: '打开最近实体容器', description: '激活指定距离内名称匹配的最近实体并等待新窗口,已有窗口时失败,最多等待五秒'};
|
||||
readonly args = entity_arguments(true, false);
|
||||
async run(bot: Bot, args: NearestArgs): Promise<boolean> {
|
||||
return run_entity_operation(bot, args, true, 'open', this.name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {FunctionArgument} from '../../../types/bot/function/function.ts';
|
||||
import {on_function_cancel} from '../cancellation.ts';
|
||||
|
||||
type Entity = NonNullable<ReturnType<Bot['entityAtCursor']>>;
|
||||
export type EntityOperation = 'attack' | 'item' | 'open' | 'empty_hand';
|
||||
export type CursorArgs = [reach: number, ignore_blocks: boolean, need_look?: boolean];
|
||||
export type NearestArgs = [name: string, reach: number, lock_target: boolean, need_look?: boolean];
|
||||
export type CursorItemArgs = [reach: number, ignore_blocks: boolean, off_hand: boolean, need_look?: boolean];
|
||||
export type NearestItemArgs = [name: string, reach: number, off_hand: boolean, lock_target: boolean, need_look?: boolean];
|
||||
|
||||
const target_locks = new WeakMap<Bot, {
|
||||
self: Bot['entity']; client: Bot['_client']; targets: Map<string, Entity>;
|
||||
}>();
|
||||
|
||||
export function entity_arguments(nearest: boolean, item: boolean, attack = false): FunctionArgument[] {
|
||||
const arg = (name: string, type: FunctionArgument['type'], label: string, description: string): FunctionArgument =>
|
||||
({name, type, i18n: {lang: 'zh-CN', name: label, description}});
|
||||
const reach = arg(nearest && !attack ? 'max_distance' : 'reach', 'number', '最大距离', '眼睛到实体碰撞箱的最大距离,必须为有限正数');
|
||||
const args = nearest
|
||||
? [arg('name', 'string', '实体类型 ID 或用户名', '不区分大小写的子串匹配,如 zom 或 minecraft:zom;也支持玩家用户名'), reach]
|
||||
: [reach, arg('ignore_blocks', 'boolean', '忽略方块遮挡', '是否忽略光标射线上的方块遮挡')];
|
||||
if (item) args.push(arg('off_hand', 'boolean', '使用副手', 'false 使用主手,true 使用副手,所选手必须持有物品'));
|
||||
if (nearest) args.push(arg('lock_target', 'boolean', '持续锁定实体', '相同函数和参数持续操作首次选中的实体,直到实体消失;超距时保留锁定但不操作'));
|
||||
args.push({...arg('need_look', 'boolean', '瞄准实体', '是否先瞄准实体中心,默认 false'), default: false});
|
||||
return args;
|
||||
}
|
||||
|
||||
function valid_entity(bot: Bot, target: Entity): boolean {
|
||||
return !!target && target !== bot.entity && target.id !== bot.entity?.id
|
||||
&& !(bot.username && target.username === bot.username)
|
||||
&& target.isValid !== false && bot.entities?.[target.id] === target
|
||||
&& Number.isFinite(target.id) && !!target.position
|
||||
&& [target.position.x, target.position.y, target.position.z, target.width, target.height].every(Number.isFinite)
|
||||
&& target.width > 0 && target.height > 0;
|
||||
}
|
||||
|
||||
function distance(bot: Bot, target: Entity): number {
|
||||
const self = bot.entity;
|
||||
const eye_height = (self as Entity & {eyeHeight?: number})?.eyeHeight ?? self?.height;
|
||||
if (!self?.position || ![self.position.x, self.position.y, self.position.z, eye_height].every(Number.isFinite)) return Infinity;
|
||||
const half = target.width / 2;
|
||||
const dx = Math.max(Math.abs(self.position.x - target.position.x) - half, 0);
|
||||
const dy = Math.max(target.position.y - (self.position.y + eye_height), self.position.y + eye_height - (target.position.y + target.height), 0);
|
||||
const dz = Math.max(Math.abs(self.position.z - target.position.z) - half, 0);
|
||||
return Math.hypot(dx, dy, dz);
|
||||
}
|
||||
|
||||
function has_item(bot: Bot, off_hand: boolean): boolean {
|
||||
return off_hand ? !bot.supportFeature('doesntHaveOffHandSlot') && !!bot.inventory?.slots[45] : !!bot.heldItem;
|
||||
}
|
||||
|
||||
function open_container(bot: Bot, target: Entity, active: () => boolean): Promise<boolean> {
|
||||
if (bot.currentWindow || !active()) return Promise.resolve(false);
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
const finish = (success: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
bot.removeListener('windowOpen', on_open);
|
||||
bot.removeListener('end', on_end);
|
||||
unwatch();
|
||||
resolve(success);
|
||||
};
|
||||
const on_open = (window: Bot['currentWindow']) => {
|
||||
finish(!!window && active() && bot.currentWindow === window);
|
||||
};
|
||||
const on_end = () => finish(false);
|
||||
const timer = setTimeout(() => finish(false), 5000);
|
||||
const unwatch = on_function_cancel(bot, on_end);
|
||||
bot.on('windowOpen', on_open);
|
||||
bot.on('end', on_end);
|
||||
try {
|
||||
Promise.resolve(bot.interactEntity(target, {hand: 0})).catch(() => finish(false));
|
||||
} catch {
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function run_entity_operation(
|
||||
bot: Bot, args: unknown[], nearest: boolean, operation: EntityOperation, function_name: string = operation,
|
||||
): Promise<boolean> {
|
||||
const item = operation === 'item';
|
||||
const required_count = (item ? 3 : 2) + (nearest ? 1 : 0);
|
||||
if (!Array.isArray(args) || args.length !== required_count && args.length !== required_count + 1) return false;
|
||||
if (args.length > required_count && typeof args[required_count] !== 'boolean') return false;
|
||||
const need_look = args[required_count] === true;
|
||||
args = args.slice(0, required_count);
|
||||
const reach = args[nearest ? 1 : 0];
|
||||
if (typeof reach !== 'number' || !Number.isFinite(reach) || reach <= 0) return false;
|
||||
if (nearest ? typeof args[0] !== 'string' || !args[0].trim() : typeof args[1] !== 'boolean') return false;
|
||||
if (item && typeof args[2] !== 'boolean') return false;
|
||||
if (nearest && typeof args.at(-1) !== 'boolean') return false;
|
||||
const self = bot.entity;
|
||||
const client = bot._client;
|
||||
let ended = false;
|
||||
let cancel!: () => void;
|
||||
const cancelled = new Promise<false>(resolve => { cancel = () => resolve(false); });
|
||||
const on_end = () => { ended = true; cancel(); };
|
||||
const unwatch = on_function_cancel(bot, on_end);
|
||||
const active = () => !ended && !!self && bot.entity === self && bot._client === client && self.isValid !== false;
|
||||
try {
|
||||
bot.on('end', on_end);
|
||||
if (operation === 'empty_hand' && bot.heldItem) return false;
|
||||
if (!active() || operation === 'open' && bot.currentWindow || item && !has_item(bot, args[2] as boolean)) return false;
|
||||
let target: Entity | undefined;
|
||||
if (nearest) {
|
||||
let locks = target_locks.get(bot);
|
||||
if (!locks || locks.self !== self || locks.client !== client) {
|
||||
locks = {self, client, targets: new Map()};
|
||||
target_locks.set(bot, locks);
|
||||
}
|
||||
for (const [key, entity] of locks.targets) {
|
||||
if (!valid_entity(bot, entity)) locks.targets.delete(key);
|
||||
}
|
||||
const lock_target = args.at(-1) === true;
|
||||
const key = JSON.stringify([function_name, [...args, need_look]]);
|
||||
target = lock_target ? locks.targets.get(key) : undefined;
|
||||
if (!target) {
|
||||
const name = (args[0] as string).trim().toLowerCase();
|
||||
const entity_name = name.replace(/^minecraft:/, '');
|
||||
target = Object.values(bot.entities).filter(entity => valid_entity(bot, entity)
|
||||
&& (entity_name.length > 0 && typeof entity.name === 'string'
|
||||
&& entity.name.toLowerCase().replace(/^minecraft:/, '').includes(entity_name)
|
||||
|| typeof entity.username === 'string' && entity.username.toLowerCase().includes(name)))
|
||||
.map(entity => ({entity, distance: distance(bot, entity)}))
|
||||
.filter(entry => Number.isFinite(entry.distance) && entry.distance <= reach)
|
||||
.sort((a, b) => a.distance - b.distance)[0]?.entity;
|
||||
if (lock_target && target) locks.targets.set(key, target);
|
||||
}
|
||||
} else {
|
||||
target = bot.entityAtCursor(reach, true, args[1] as boolean) ?? undefined;
|
||||
}
|
||||
const in_reach = () => active() && valid_entity(bot, target!) && distance(bot, target!) <= reach;
|
||||
if (!target || !in_reach()) return false;
|
||||
if (need_look) {
|
||||
if (!await Promise.race([
|
||||
bot.lookAt(target.position.offset(0, target.height / 2, 0)).then(() => true, () => false), cancelled,
|
||||
])) return false;
|
||||
if (!in_reach()) return false;
|
||||
}
|
||||
if (operation === 'attack') {
|
||||
bot.attack(target);
|
||||
return true;
|
||||
}
|
||||
if (operation === 'empty_hand') {
|
||||
if (bot.heldItem) return false;
|
||||
bot.interactEntity(target, {hand: 0});
|
||||
return true;
|
||||
}
|
||||
if (item) {
|
||||
if (!has_item(bot, args[2] as boolean)) return false;
|
||||
bot.interactEntity(target, {hand: args[2] ? 1 : 0, position: target.position.offset(0, target.height / 2, 0)});
|
||||
return true;
|
||||
}
|
||||
return await open_container(bot, target, active);
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
unwatch();
|
||||
bot.removeListener('end', on_end);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {entity_arguments, run_entity_operation, type CursorItemArgs} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<CursorItemArgs> {
|
||||
readonly name = 'use_item_on_cursor_entity';
|
||||
readonly i18n = {lang: 'zh-CN', name: '对光标实体使用物品', description: '对光标实体使用指定手的物品,可选瞄准,不自动移动'};
|
||||
readonly args = entity_arguments(false, true);
|
||||
async run(bot: Bot, args: CursorItemArgs): Promise<boolean> {
|
||||
return run_entity_operation(bot, args, false, 'item');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction} from '../../../types/bot/function/function.ts';
|
||||
import {entity_arguments, run_entity_operation, type NearestItemArgs} from './shared.ts';
|
||||
|
||||
export class init implements BaseFunction<NearestItemArgs> {
|
||||
readonly name = 'use_item_on_nearest_entity';
|
||||
readonly i18n = {lang: 'zh-CN', name: '对最近实体使用物品', description: '对指定距离内名称匹配的最近实体使用指定手的物品,可选瞄准'};
|
||||
readonly args = entity_arguments(true, true);
|
||||
async run(bot: Bot, args: NearestItemArgs): Promise<boolean> {
|
||||
return run_entity_operation(bot, args, true, 'item', this.name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import {readdir} from 'node:fs/promises';
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction, FunctionArgument, FunctionI18n} from '../../types/bot/function/function.ts';
|
||||
import type {data_type} from '../../types/bot/instance/bot_runtime.ts';
|
||||
import type {bot_instance_context} from '../instance/bot_instance_init.ts';
|
||||
import {create_function_cancellation, on_function_cancel} from './cancellation.ts';
|
||||
import {measure_sync} from '../info/resource_metrics.ts';
|
||||
|
||||
export interface FunctionTemplate {
|
||||
name: string;
|
||||
i18n: FunctionI18n;
|
||||
args: FunctionArgument[];
|
||||
}
|
||||
|
||||
export interface FunctionCategory {
|
||||
path: string;
|
||||
name: string;
|
||||
i18n: FunctionI18n[];
|
||||
function_list: FunctionTemplate[];
|
||||
}
|
||||
|
||||
export type FunctionList = FunctionCategory[];
|
||||
|
||||
export interface FunctionManager {
|
||||
get_function_list(): FunctionList;
|
||||
run(path: string, name: string, args: unknown[], before_run?: () => void,
|
||||
options?: {signal?: AbortSignal}): Promise<boolean>;
|
||||
/** Cancels queued calls and notifies the running call, which drains before rejecting. */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
const data_types = new Set<data_type>([
|
||||
'string', 'boolean', 'number', 'datetime', 'string-select', 'number-select',
|
||||
'string-multi-select', 'number-multi-select', 'string-array', 'number-array',
|
||||
]);
|
||||
const valid_name = (value: unknown): value is string => typeof value === 'string' && /^[a-zA-Z0-9_-]+$/.test(value);
|
||||
const valid_i18n = (value: unknown): value is FunctionI18n => {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const item = value as FunctionI18n;
|
||||
return typeof item.lang === 'string' && item.lang.length > 0
|
||||
&& typeof item.name === 'string' && typeof item.description === 'string';
|
||||
};
|
||||
|
||||
async function load_functions(check_active: () => void = () => {}): Promise<{
|
||||
list: FunctionList;
|
||||
functions: Map<string, BaseFunction>;
|
||||
}> {
|
||||
const list: FunctionList = [];
|
||||
const functions = new Map<string, BaseFunction>();
|
||||
const directories = [''];
|
||||
const stamp = `${Date.now()}-${process.hrtime.bigint()}`;
|
||||
const import_module = async (url: URL): Promise<Record<string, unknown>> => {
|
||||
check_active();
|
||||
url.searchParams.set('t', stamp);
|
||||
const module = await import(url.href);
|
||||
check_active();
|
||||
return module;
|
||||
};
|
||||
for (const path of directories) {
|
||||
check_active();
|
||||
const directory = new URL(path ? `./${path}/` : './', import.meta.url);
|
||||
const entries = await readdir(directory, {withFileTypes: true});
|
||||
check_active();
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && valid_name(entry.name)) directories.push(path ? `${path}/${entry.name}` : entry.name);
|
||||
}
|
||||
if (!path) continue;
|
||||
const files = entries.filter(entry => entry.isFile() && entry.name !== 'define.ts'
|
||||
&& /^[a-zA-Z0-9_-]+\.ts$/.test(entry.name));
|
||||
const has_define = entries.some(entry => entry.isFile() && entry.name === 'define.ts');
|
||||
if (!files.length && !has_define) continue;
|
||||
if (!has_define) throw new Error(`函数分类 ${path} 缺少 define.ts`);
|
||||
try {
|
||||
const {define} = await import_module(new URL('define.ts', directory));
|
||||
const config = define as {path?: unknown; name?: unknown; i18n?: unknown} | undefined;
|
||||
if (config?.path !== path || !Array.isArray(config.i18n) || !config.i18n.length
|
||||
|| !config.i18n.every(valid_i18n) || config.name !== undefined && !valid_name(config.name)) {
|
||||
throw new Error('分类 define 无效,path 必须与目录路径一致');
|
||||
}
|
||||
const category: FunctionCategory = {
|
||||
path, name: typeof config.name === 'string' ? config.name : path.split('/').at(-1)!,
|
||||
i18n: config.i18n.map(item => ({...item})), function_list: [],
|
||||
};
|
||||
for (const file of files) {
|
||||
const name = file.name.slice(0, -3);
|
||||
try {
|
||||
const module = await import_module(new URL(file.name, directory));
|
||||
if (!Object.hasOwn(module, 'init')) continue;
|
||||
if (typeof module.init !== 'function') throw new Error('必须导出 init 类');
|
||||
const instance = new (module.init as new () => BaseFunction)();
|
||||
if (instance.name !== name || !valid_i18n(instance.i18n)
|
||||
|| !Array.isArray(instance.args) || typeof instance.run !== 'function') {
|
||||
throw new Error('函数必须满足 BaseFunction 且 name 与文件名一致');
|
||||
}
|
||||
const names = new Set<string>();
|
||||
let optional = false;
|
||||
for (const arg of instance.args) {
|
||||
if (!arg || !valid_name(arg.name) || names.has(arg.name)
|
||||
|| !data_types.has(arg.type) || !valid_i18n(arg.i18n)) {
|
||||
throw new Error('函数参数定义无效或名称重复');
|
||||
}
|
||||
if (Object.hasOwn(arg, 'default')) {
|
||||
if (arg.type !== 'boolean' || typeof arg.default !== 'boolean') throw new Error('默认值必须匹配布尔参数');
|
||||
optional = true;
|
||||
} else if (optional) throw new Error('带默认值的参数必须位于末尾');
|
||||
names.add(arg.name);
|
||||
}
|
||||
const key = `${path}/${name}`;
|
||||
if (functions.has(key)) throw new Error('函数重复注册');
|
||||
functions.set(key, instance);
|
||||
category.function_list.push({name, i18n: {...instance.i18n},
|
||||
args: instance.args.map(arg => ({name: arg.name, type: arg.type, i18n: {...arg.i18n},
|
||||
...(Object.hasOwn(arg, 'default') ? {default: arg.default} : {})}))});
|
||||
} catch (error) {
|
||||
throw new Error(`函数 ${path}/${name} 加载失败`, {cause: error});
|
||||
}
|
||||
}
|
||||
list.push(category);
|
||||
} catch (error) {
|
||||
throw new Error(`函数分类 ${path} 加载失败`, {cause: error});
|
||||
}
|
||||
}
|
||||
list.sort((a, b) => a.path.localeCompare(b.path));
|
||||
return {list, functions};
|
||||
}
|
||||
|
||||
/** Builds grouped metadata without a Bot; constructors must have no side effects. */
|
||||
export async function get_function_list(): Promise<FunctionList> {
|
||||
return (await load_functions()).list;
|
||||
}
|
||||
|
||||
function valid_argument(value: unknown, type: data_type): boolean {
|
||||
switch (type) {
|
||||
case 'number': case 'number-select': return typeof value === 'number' && Number.isFinite(value);
|
||||
case 'string': case 'string-select': return typeof value === 'string';
|
||||
case 'boolean': return typeof value === 'boolean';
|
||||
case 'datetime': return typeof value === 'string'
|
||||
&& /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value) && Number.isFinite(Date.parse(value));
|
||||
case 'string-array': case 'string-multi-select':
|
||||
return Array.isArray(value) && Array.from(value).every(item => typeof item === 'string');
|
||||
case 'number-array': case 'number-multi-select':
|
||||
return Array.isArray(value) && Array.from(value).every(item => typeof item === 'number' && Number.isFinite(item));
|
||||
}
|
||||
}
|
||||
|
||||
/** The caller owns disposal; this manager never registers end listeners or follows a replacement Bot. */
|
||||
export async function create_function_manager(
|
||||
context: Pick<bot_instance_context<unknown>, 'bot_instance' | 'bot_status'>
|
||||
& Partial<Pick<bot_instance_context<unknown>, 'workflow_manager'>>,
|
||||
): Promise<FunctionManager> {
|
||||
const bot: Bot | null = context.bot_instance;
|
||||
if (!bot) throw new Error('Bot 实例尚未创建');
|
||||
const check_instance = () => {
|
||||
if (context.bot_instance !== bot) throw new Error('Bot 实例已变化');
|
||||
};
|
||||
const {list, functions} = await load_functions(check_instance);
|
||||
let disposed = false;
|
||||
let tail = Promise.resolve();
|
||||
const pending = new Set<{reject: (error: unknown) => void; resolve: (value: boolean) => void}>();
|
||||
let cancel_running: ((reason: unknown) => void) | undefined;
|
||||
const check_active = () => {
|
||||
if (disposed) throw new Error('Function manager 已销毁');
|
||||
check_instance();
|
||||
};
|
||||
return {
|
||||
get_function_list() {
|
||||
check_active();
|
||||
return structuredClone(list);
|
||||
},
|
||||
async run(path, name, args, before_run, options) {
|
||||
const signal = options?.signal;
|
||||
signal?.throwIfAborted();
|
||||
check_active();
|
||||
const template = list.find(category => category.path === path)?.function_list.find(item => item.name === name);
|
||||
const instance = template && functions.get(`${path}/${name}`);
|
||||
if (!instance || !template) throw new Error('函数不存在');
|
||||
if (!Array.isArray(args) || args.length > template.args.length
|
||||
|| !template.args.every((arg, index) => index < args.length
|
||||
? valid_argument(args[index], arg.type) : Object.hasOwn(arg, 'default'))) {
|
||||
throw new Error('函数参数数量或类型不正确');
|
||||
}
|
||||
const values = structuredClone(template.args.map((arg, index) => index < args.length ? args[index] : arg.default));
|
||||
if (path === 'workflow' && instance.execution === 'workflow_control') {
|
||||
const workflow = context.workflow_manager;
|
||||
const check_control = () => {
|
||||
signal?.throwIfAborted();
|
||||
check_active();
|
||||
if (context.workflow_manager !== workflow) throw new Error('Workflow manager 已变化');
|
||||
};
|
||||
before_run?.();
|
||||
check_control();
|
||||
if (!workflow) return false;
|
||||
// Bind only the control capability to this dispatch, never the raw Bot context.
|
||||
let result = false;
|
||||
try {
|
||||
result = await measure_sync(bot, () => instance.run(bot, values, {workflow: {
|
||||
control(request) {
|
||||
check_control();
|
||||
const command = workflow.control(request);
|
||||
check_control();
|
||||
return command;
|
||||
},
|
||||
}})) === true;
|
||||
} catch { /* Function failures retain the boolean API. */ }
|
||||
check_control();
|
||||
return result;
|
||||
}
|
||||
if (context.bot_status !== 'online') throw new Error('Bot 当前不在线');
|
||||
if (path === 'bot' && name === 'stop_current_function') {
|
||||
before_run?.();
|
||||
signal?.throwIfAborted();
|
||||
check_active();
|
||||
for (const task of pending) task.resolve(false);
|
||||
pending.clear();
|
||||
const result = await measure_sync(bot, () => instance.run(bot, values));
|
||||
signal?.throwIfAborted();
|
||||
return result;
|
||||
}
|
||||
if (pending.size >= 64) throw new Error('函数等待队列已满');
|
||||
return new Promise<boolean>((resolve, reject) => {
|
||||
let cancel_execution: ((reason: unknown) => void) | undefined;
|
||||
const cleanup = () => signal?.removeEventListener('abort', abort);
|
||||
const task = {
|
||||
reject(error: unknown) { cleanup(); reject(error); },
|
||||
resolve(value: boolean) { cleanup(); resolve(value); },
|
||||
};
|
||||
const abort = () => {
|
||||
if (pending.delete(task)) task.reject(signal!.reason);
|
||||
else cancel_execution?.(signal!.reason);
|
||||
};
|
||||
pending.add(task);
|
||||
signal?.addEventListener('abort', abort, {once: true});
|
||||
const execute = async () => {
|
||||
if (!pending.delete(task)) return;
|
||||
try {
|
||||
check_active();
|
||||
if (context.bot_status !== 'online') throw new Error('Bot 当前不在线');
|
||||
signal?.throwIfAborted();
|
||||
before_run?.();
|
||||
signal?.throwIfAborted();
|
||||
check_active();
|
||||
const cancellation = create_function_cancellation(bot);
|
||||
let interrupted = false;
|
||||
let reason: unknown;
|
||||
cancel_running = cancel_execution = error => {
|
||||
if (interrupted) return;
|
||||
interrupted = true;
|
||||
reason = error;
|
||||
cancellation.cancel();
|
||||
};
|
||||
try {
|
||||
await cancellation.run(async () => {
|
||||
// Global stop may answer early, but the queue still drains instance.run.
|
||||
const unwatch = on_function_cancel(bot, () => {
|
||||
if (!interrupted) task.resolve(false);
|
||||
});
|
||||
let result = false;
|
||||
try { result = await measure_sync(bot, () => instance.run(bot, values)) === true; }
|
||||
catch { /* Function failures retain the boolean API. */ }
|
||||
finally { unwatch(); }
|
||||
if (interrupted) task.reject(reason);
|
||||
else task.resolve(result);
|
||||
});
|
||||
} finally {
|
||||
cleanup();
|
||||
cancel_running = cancel_execution = undefined;
|
||||
cancellation.dispose();
|
||||
}
|
||||
} catch (error) { task.reject(error); }
|
||||
};
|
||||
tail = tail.then(execute, execute);
|
||||
});
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
for (const task of pending) task.reject(new Error('Function manager 已销毁,排队调用已取消'));
|
||||
pending.clear();
|
||||
functions.clear();
|
||||
cancel_running?.(new Error('Function manager 已销毁,当前调用已取消'));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts';
|
||||
|
||||
export class init implements BaseFunction<number[]> {
|
||||
readonly name = 'change_main_hand_slot';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN', name: '切换主手槽位', description: '切换主手选中的快捷栏槽位,编号为 0 到 8',
|
||||
};
|
||||
readonly args: FunctionArgument[] = [{
|
||||
name: 'slot', type: 'number',
|
||||
i18n: {lang: 'zh-CN', name: '快捷栏槽位', description: '0 到 8 的整数,分别对应快捷栏第 1 到第 9 格'},
|
||||
}];
|
||||
|
||||
async run(bot: Bot, args: number[]): Promise<boolean> {
|
||||
if (!Array.isArray(args) || args.length !== 1 || !Number.isSafeInteger(args[0])
|
||||
|| args[0] < 0 || args[0] > 8) return false;
|
||||
try {
|
||||
bot.setQuickBarSlot(args[0]);
|
||||
return bot.quickBarSlot === args[0];
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts';
|
||||
|
||||
export class init implements BaseFunction<[]> {
|
||||
readonly name = 'close_current_window';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN', name: '关闭当前窗口', description: '关闭当前打开的容器窗口;没有打开窗口时直接成功',
|
||||
};
|
||||
readonly args: FunctionArgument[] = [];
|
||||
|
||||
async run(bot: Bot, args: []): Promise<boolean> {
|
||||
if (!Array.isArray(args) || args.length !== 0) return false;
|
||||
const window = bot.currentWindow;
|
||||
if (!window) return true;
|
||||
try {
|
||||
await bot.closeWindow(window);
|
||||
return bot.currentWindow == null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {base_define} from "../../../types/bot/plugins/plugin.ts";
|
||||
|
||||
export const define: base_define = {
|
||||
path: "inventory",
|
||||
i18n: [
|
||||
{
|
||||
lang: "zh-CN",
|
||||
name: "容器操作",
|
||||
description: "Bot 容器操作, 提供容器相关功能"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts';
|
||||
|
||||
export class init implements BaseFunction<number[]> {
|
||||
readonly name = 'left_click_slot';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN', name: '左键点击槽位', description: '在当前窗口左键点击一个物品槽位,使用原生拾取、放置或合并行为',
|
||||
};
|
||||
readonly args: FunctionArgument[] = [{
|
||||
name: 'slot', type: 'number',
|
||||
i18n: {lang: 'zh-CN', name: '物品槽位', description: '当前窗口中的槽位编号'},
|
||||
}];
|
||||
|
||||
async run(bot: Bot, args: number[]): Promise<boolean> {
|
||||
const window = bot.currentWindow ?? bot.inventory;
|
||||
if (!window || !Array.isArray(args) || args.length !== 1 || !Number.isSafeInteger(args[0])
|
||||
|| args[0] < 0 || args[0] >= window.slots.length) return false;
|
||||
try {
|
||||
await bot.clickWindow(args[0], 0, 0);
|
||||
return (bot.currentWindow ?? bot.inventory) === window;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts';
|
||||
|
||||
export class init implements BaseFunction<number[]> {
|
||||
readonly name = 'right_click_slot';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN', name: '右键点击槽位', description: '在当前窗口右键点击一个物品槽位,使用原生半组拾取或单个放置行为',
|
||||
};
|
||||
readonly args: FunctionArgument[] = [{
|
||||
name: 'slot', type: 'number',
|
||||
i18n: {lang: 'zh-CN', name: '物品槽位', description: '当前窗口中的槽位编号'},
|
||||
}];
|
||||
|
||||
async run(bot: Bot, args: number[]): Promise<boolean> {
|
||||
const window = bot.currentWindow ?? bot.inventory;
|
||||
if (!window || !Array.isArray(args) || args.length !== 1 || !Number.isSafeInteger(args[0])
|
||||
|| args[0] < 0 || args[0] >= window.slots.length) return false;
|
||||
try {
|
||||
await bot.clickWindow(args[0], 1, 0);
|
||||
return (bot.currentWindow ?? bot.inventory) === window;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts';
|
||||
import {Mutex} from 'async-mutex';
|
||||
import {cancellation_generation} from '../cancellation.ts';
|
||||
|
||||
const locks = new WeakMap<Bot, Mutex>();
|
||||
|
||||
export class init implements BaseFunction<number[]> {
|
||||
readonly name = 'slot_switch_slot';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN', name: '物品交换', description: '依次左键点击源槽位、目标槽位,并将鼠标剩余物品放回源槽位;同类物品按原生规则合并',
|
||||
};
|
||||
readonly args: FunctionArgument[] = [
|
||||
{name: 'slot', type: 'number', i18n: {lang: 'zh-CN', name: '物品槽位', description: '第一个槽位编号'}},
|
||||
{name: 'new_slot', type: 'number', i18n: {lang: 'zh-CN', name: '目标槽位', description: '第二个槽位编号'}},
|
||||
];
|
||||
|
||||
async run(bot: Bot, args: number[]): Promise<boolean> {
|
||||
if (!Array.isArray(args) || args.length !== 2 || !args.every(Number.isSafeInteger)) return false;
|
||||
const window = bot.currentWindow ?? bot.inventory;
|
||||
if (!window) return false;
|
||||
const generation = cancellation_generation(bot);
|
||||
const active = () => cancellation_generation(bot) === generation;
|
||||
let lock = locks.get(bot);
|
||||
if (!lock) locks.set(bot, lock = new Mutex());
|
||||
return lock.runExclusive(async () => {
|
||||
const [slot, new_slot] = args;
|
||||
if (!active() || (bot.currentWindow ?? bot.inventory) !== window || window.selectedItem
|
||||
|| slot === new_slot || args.some(value => value < 0 || value >= window.slots.length)) return false;
|
||||
try {
|
||||
await bot.clickWindow(slot, 0, 0);
|
||||
if (!active() || (bot.currentWindow ?? bot.inventory) !== window) return false;
|
||||
await bot.clickWindow(new_slot, 0, 0);
|
||||
if (!active() || (bot.currentWindow ?? bot.inventory) !== window) return false;
|
||||
// An empty cursor means a final click would pick the source item up again.
|
||||
if (window.selectedItem) {
|
||||
await bot.clickWindow(slot, 0, 0);
|
||||
}
|
||||
return active() && (bot.currentWindow ?? bot.inventory) === window && !window.selectedItem;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type {Bot} from "@wp2508/mineflayer";
|
||||
import type {BaseFunction, FunctionArgument} from "../../../types/bot/function/function.ts";
|
||||
import {Mutex} from "async-mutex";
|
||||
import {cancellation_generation} from '../cancellation.ts';
|
||||
|
||||
const locks = new WeakMap<Bot, Mutex>();
|
||||
|
||||
export class init implements BaseFunction<number[]> {
|
||||
public name = "slot_to_slot";
|
||||
public i18n = {
|
||||
lang: "zh-CN",
|
||||
name: "物品移动",
|
||||
description: "将物品从一个槽位移动到另一个槽位, 目标槽位必须为空"
|
||||
}
|
||||
public args: FunctionArgument[] = [
|
||||
{
|
||||
name: "slot",
|
||||
type: "number",
|
||||
i18n: {
|
||||
lang: "zh-CN",
|
||||
name: "物品槽位",
|
||||
description: "物品槽位"
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "new_slot",
|
||||
type: "number",
|
||||
i18n: {
|
||||
lang: "zh-CN",
|
||||
name: "新物品槽位",
|
||||
description: "新物品槽位"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
async run(bot: Bot, args: number[]): Promise<boolean> {
|
||||
if (!Array.isArray(args) || args.length !== 2 || !args.every(Number.isSafeInteger)) {
|
||||
return false;
|
||||
}
|
||||
const [slot, new_slot] = args;
|
||||
const window = bot.currentWindow ?? bot.inventory;
|
||||
if (!window) return false;
|
||||
const generation = cancellation_generation(bot);
|
||||
const active = () => cancellation_generation(bot) === generation;
|
||||
let lock = locks.get(bot);
|
||||
if (!lock) locks.set(bot, lock = new Mutex());
|
||||
return lock.runExclusive(async () => {
|
||||
if (!active() || (bot.currentWindow ?? bot.inventory) !== window) return false;
|
||||
if ([slot, new_slot].some(value => value < 0 || value >= window.slots.length)) {
|
||||
return false;
|
||||
}
|
||||
if (slot === new_slot || !window.slots[slot] || window.slots[new_slot] || window.selectedItem) return false;
|
||||
|
||||
try {
|
||||
await bot.clickWindow(slot, 0, 0);
|
||||
// Never apply the destination click to a replacement window.
|
||||
if (!active() || (bot.currentWindow ?? bot.inventory) !== window || window.slots[new_slot]) return false;
|
||||
await bot.clickWindow(new_slot, 0, 0);
|
||||
return active();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {base_define} from "../../../types/bot/plugins/plugin.ts";
|
||||
|
||||
export const define: base_define = {
|
||||
path: "item",
|
||||
i18n: [
|
||||
{
|
||||
lang: "zh-CN",
|
||||
name: "物品操作",
|
||||
description: "Bot 物品操作, 提供物品相关功能"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts';
|
||||
|
||||
export class init implements BaseFunction<number[]> {
|
||||
readonly name = 'drop_slot_all_item';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN', name: '丢弃整组物品', description: '丢弃当前窗口指定槽位的整组物品,不主动关闭窗口',
|
||||
};
|
||||
readonly args: FunctionArgument[] = [{
|
||||
name: 'slot', type: 'number',
|
||||
i18n: {lang: 'zh-CN', name: '物品槽位', description: '当前窗口中的槽位编号'},
|
||||
}];
|
||||
|
||||
async run(bot: Bot, args: number[]): Promise<boolean> {
|
||||
const window = bot.currentWindow ?? bot.inventory;
|
||||
if (!window || !Array.isArray(args) || args.length !== 1 || !Number.isSafeInteger(args[0])
|
||||
|| args[0] < 0 || args[0] >= window.slots.length || !window.slots[args[0]]
|
||||
|| window.selectedItem) return false;
|
||||
try {
|
||||
await bot.clickWindow(args[0], 1, 4);
|
||||
return (bot.currentWindow ?? bot.inventory) === window && !window.slots[args[0]];
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts';
|
||||
|
||||
export class init implements BaseFunction<number[]> {
|
||||
readonly name = 'drop_slot_item_number';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN', name: '丢弃指定数量物品', description: '逐个丢弃指定槽位的物品,数量不超过槽位现有数量',
|
||||
};
|
||||
readonly args: FunctionArgument[] = [
|
||||
{name: 'slot', type: 'number', i18n: {lang: 'zh-CN', name: '物品槽位', description: '当前窗口中的槽位编号'}},
|
||||
{name: 'count', type: 'number', i18n: {lang: 'zh-CN', name: '丢弃数量', description: '大于零的整数,超过现有数量时丢弃全部'}},
|
||||
];
|
||||
|
||||
async run(bot: Bot, args: number[]): Promise<boolean> {
|
||||
if (!Array.isArray(args) || args.length !== 2 || !args.every(Number.isSafeInteger)) return false;
|
||||
const [slot, count] = args;
|
||||
const window = bot.currentWindow ?? bot.inventory;
|
||||
if (!window || slot < 0 || slot >= window.slots.length || count <= 0 || window.selectedItem) return false;
|
||||
const item = window.slots[slot];
|
||||
if (!item) return false;
|
||||
const drop_count = Math.min(count, item.count);
|
||||
const initial_count = item.count;
|
||||
const type = item.type;
|
||||
const metadata = item.metadata;
|
||||
try {
|
||||
for (let index = 0; index < drop_count; index++) {
|
||||
const current = window.slots[slot];
|
||||
if ((bot.currentWindow ?? bot.inventory) !== window || window.selectedItem
|
||||
|| !current || current.type !== type || current.metadata !== metadata
|
||||
|| current.count !== initial_count - index) return false;
|
||||
await bot.clickWindow(slot, 0, 4);
|
||||
if ((bot.currentWindow ?? bot.inventory) !== window || window.selectedItem) return false;
|
||||
const remaining = window.slots[slot];
|
||||
const expected = initial_count - index - 1;
|
||||
if (expected === 0 ? remaining !== null && remaining !== undefined
|
||||
: !remaining || remaining.type !== type || remaining.metadata !== metadata
|
||||
|| remaining.count !== expected) return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts';
|
||||
|
||||
export class init implements BaseFunction<boolean[]> {
|
||||
readonly name = 'use_item';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN', name: '使用手持物品', description: '发起主手或副手物品的使用,不等待使用完成,也不自动停止持续使用',
|
||||
};
|
||||
readonly args: FunctionArgument[] = [{
|
||||
name: 'off_hand', type: 'boolean',
|
||||
i18n: {lang: 'zh-CN', name: '使用副手', description: 'false 使用主手,true 使用副手'},
|
||||
}];
|
||||
|
||||
async run(bot: Bot, args: boolean[]): Promise<boolean> {
|
||||
if (!Array.isArray(args) || args.length !== 1 || typeof args[0] !== 'boolean') return false;
|
||||
try {
|
||||
const off_hand = args[0];
|
||||
if (off_hand) {
|
||||
if (bot.supportFeature('doesntHaveOffHandSlot') || !bot.inventory?.slots[45]) return false;
|
||||
} else if (!bot.heldItem) return false;
|
||||
bot.activateItem(off_hand);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type {base_define} from '../../../types/bot/plugins/plugin.ts';
|
||||
|
||||
export const define: base_define = {
|
||||
path: 'workflow',
|
||||
i18n: [{lang: 'zh-CN', name: '工作流控制', description: '运行、停止、管理工作流触发器和重新加载工作流'}],
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import {WorkflowControlFunction} from './shared.ts';
|
||||
|
||||
export class init extends WorkflowControlFunction {
|
||||
readonly name = 'reload';
|
||||
readonly i18n = {lang: 'zh-CN', name: '重新加载工作流', description: '提交重新加载指定工作流的请求,接受后立即返回,不等待加载完成'};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {WorkflowControlFunction} from './shared.ts';
|
||||
|
||||
export class init extends WorkflowControlFunction {
|
||||
readonly name = 'run_once';
|
||||
readonly i18n = {lang: 'zh-CN', name: '运行工作流一次', description: '提交工作流单次运行请求,接受后立即返回,不等待运行完成'};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction, FunctionExecutionContext, FunctionI18n} from '../../../types/bot/function/function.ts';
|
||||
import type {WorkflowAction} from '../../../types/bot/workflow/runtime.ts';
|
||||
|
||||
export abstract class WorkflowControlFunction implements BaseFunction<[string]> {
|
||||
abstract readonly name: WorkflowAction;
|
||||
abstract readonly i18n: FunctionI18n;
|
||||
readonly execution = 'workflow_control';
|
||||
readonly args = [{name: 'workflow_id', type: 'string' as const,
|
||||
i18n: {lang: 'zh-CN', name: '工作流 ID', description: '目标工作流的唯一标识'}}];
|
||||
|
||||
async run(_bot: Bot, args: [string], executionContext?: FunctionExecutionContext): Promise<boolean> {
|
||||
if (!Array.isArray(args) || args.length !== 1 || typeof args[0] !== 'string'
|
||||
|| args[0].length < 1 || args[0].length > 100 || /[^a-zA-Z0-9_-]/.test(args[0])
|
||||
|| ['__proto__', 'prototype', 'constructor'].includes(args[0])) return false;
|
||||
const workflow = executionContext?.workflow;
|
||||
if (!workflow) return false;
|
||||
try {
|
||||
const command = workflow.control({command_id: randomUUID(), action: this.name,
|
||||
...(this.name === 'stop_run' ? {run_id: args[0]} : {workflow_id: args[0]})});
|
||||
// Acknowledgement is enough; waiting for completion would deadlock a self-stop.
|
||||
return command.state === 'accepted' || command.state === 'completed';
|
||||
} catch { return false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {WorkflowControlFunction} from './shared.ts';
|
||||
|
||||
export class init extends WorkflowControlFunction {
|
||||
readonly name = 'stop';
|
||||
readonly i18n = {lang: 'zh-CN', name: '停止工作流', description: '提交停止指定工作流的请求,接受后立即返回,不等待运行结束'};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import {WorkflowControlFunction} from './shared.ts';
|
||||
|
||||
export class init extends WorkflowControlFunction {
|
||||
readonly name = 'stop_run';
|
||||
readonly i18n = {lang: 'zh-CN', name: '停止工作流运行实例', description: '提交停止指定运行实例的请求,接受后立即返回,不等待实例结束'};
|
||||
readonly args = [{name: 'run_id', type: 'string' as const,
|
||||
i18n: {lang: 'zh-CN', name: '运行实例 ID', description: '目标工作流运行实例的唯一标识'}}];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {WorkflowControlFunction} from './shared.ts';
|
||||
|
||||
export class init extends WorkflowControlFunction {
|
||||
readonly name = 'trigger_start';
|
||||
readonly i18n = {lang: 'zh-CN', name: '启动工作流触发器', description: '提交启动指定工作流触发器的请求,接受后立即返回'};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {WorkflowControlFunction} from './shared.ts';
|
||||
|
||||
export class init extends WorkflowControlFunction {
|
||||
readonly name = 'trigger_stop';
|
||||
readonly i18n = {lang: 'zh-CN', name: '停止工作流触发器', description: '提交停止指定工作流触发器的请求,接受后立即返回'};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseInfo, InfoI18n} from '../types.ts';
|
||||
import type {log} from '../../../types/utils/logger.ts';
|
||||
import {MinecraftJsonParser} from '../../../utils/message_json_parser.ts';
|
||||
|
||||
export interface EntityInfo {
|
||||
entity_count: number;
|
||||
player_count: number;
|
||||
players: log[][];
|
||||
entities: log[][];
|
||||
}
|
||||
|
||||
export class init implements BaseInfo<EntityInfo> {
|
||||
readonly name = 'entity';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN', name: '附近实体', description: '当前已加载的附近实体和玩家,不包含机器人自身',
|
||||
fields: {
|
||||
entity_count: {lang: 'zh-CN', name: '附近实体数量', description: '已加载的实体数量,包含玩家,不包含机器人自身'},
|
||||
player_count: {lang: 'zh-CN', name: '附近玩家数量', description: '已加载的其他玩家实体数量'},
|
||||
players: {lang: 'zh-CN', name: '附近玩家列表', description: '每个玩家名称返回一组 log 片段,优先使用玩家对象的 displayName,其次 username'},
|
||||
entities: {lang: 'zh-CN', name: '附近实体列表', description: '按显示名称合并去重,每个名称返回一组 log 片段并保留首次出现的样式,包含玩家'},
|
||||
},
|
||||
} satisfies InfoI18n;
|
||||
|
||||
private info: EntityInfo = {entity_count: 0, player_count: 0, players: [], entities: []};
|
||||
private disposed = false;
|
||||
private readonly listeners = new Set<(data: EntityInfo) => void>();
|
||||
private readonly events = ['entitySpawn', 'entityGone', 'entityUpdate', 'playerJoined', 'playerUpdated', 'playerLeft', 'spawn', 'respawn', 'physicsTick'] as const;
|
||||
|
||||
constructor(private readonly instance: Bot) {
|
||||
this.handle_entities();
|
||||
for (const event of this.events) instance.on(event, this.handle_entities);
|
||||
}
|
||||
|
||||
private readonly handle_entities = (): void => {
|
||||
if (this.disposed) return;
|
||||
|
||||
const bot = this.instance;
|
||||
const info: EntityInfo = {entity_count: 0, player_count: 0, players: [], entities: []};
|
||||
const entity_names = new Set<string>();
|
||||
const players = new Map(Object.values(bot.players ?? {})
|
||||
.filter(player => player.entity)
|
||||
.map(player => [player.entity.id, player]));
|
||||
for (const entity of Object.values(bot.entities ?? {}).sort((a, b) => a.id - b.id)) {
|
||||
if (entity.id === bot.entity?.id || entity.isValid === false) continue;
|
||||
|
||||
const player = players.get(entity.id) ?? (entity.username ? bot.players?.[entity.username] : undefined);
|
||||
const is_player = entity.type === 'player' || Boolean(player);
|
||||
const fallback = player?.username || entity.username || entity.name || '未知';
|
||||
const display_name = is_player ? player?.displayName ?? entity.displayName : entity.displayName;
|
||||
const name = MinecraftJsonParser.toLog(display_name ?? fallback);
|
||||
const segments = name.length ? name : MinecraftJsonParser.toLog(fallback);
|
||||
info.entity_count++;
|
||||
const entity_name = segments.map(segment => segment.message).join('');
|
||||
if (!entity_names.has(entity_name)) {
|
||||
entity_names.add(entity_name);
|
||||
info.entities.push(segments);
|
||||
}
|
||||
if (is_player) info.players.push(segments);
|
||||
}
|
||||
info.player_count = info.players.length;
|
||||
|
||||
if (JSON.stringify(this.info) === JSON.stringify(info)) return;
|
||||
this.info = info;
|
||||
for (const callback of Array.from(this.listeners)) callback(info);
|
||||
};
|
||||
|
||||
get_info(): EntityInfo {
|
||||
return this.info;
|
||||
}
|
||||
|
||||
on_update(callback: (data: EntityInfo) => void): () => void {
|
||||
if (this.disposed) throw new Error('EntityInfo 已销毁');
|
||||
this.listeners.add(callback);
|
||||
return () => { this.listeners.delete(callback); };
|
||||
}
|
||||
|
||||
readonly dispose = (): void => {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
for (const event of this.events) this.instance.off(event, this.handle_entities);
|
||||
this.listeners.clear();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseInfo} from '../types.ts';
|
||||
|
||||
export class init implements BaseInfo<number> {
|
||||
readonly name = 'food';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN',
|
||||
name: '饥饿值',
|
||||
description: '机器人的当前饥饿值',
|
||||
};
|
||||
|
||||
private food: number;
|
||||
private disposed = false;
|
||||
private readonly listeners = new Set<(data: number) => void>();
|
||||
|
||||
constructor(private readonly instance: Bot) {
|
||||
this.food = instance.food ?? 0;
|
||||
instance.on('health', this.handle_food);
|
||||
}
|
||||
|
||||
private readonly handle_food = (): void => {
|
||||
this.update_food(this.instance.food ?? 0);
|
||||
};
|
||||
|
||||
get_info(): number {
|
||||
return this.food;
|
||||
}
|
||||
|
||||
on_update(callback: (data: number) => void): () => void {
|
||||
if (this.disposed) {
|
||||
throw new Error('FoodInfo 已销毁');
|
||||
}
|
||||
|
||||
this.listeners.add(callback);
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
private update_food(food: number): void {
|
||||
if (this.disposed || Object.is(this.food, food)) return;
|
||||
|
||||
this.food = food;
|
||||
for (const callback of Array.from(this.listeners)) {
|
||||
callback(food);
|
||||
}
|
||||
}
|
||||
|
||||
readonly dispose = (): void => {
|
||||
if (this.disposed) return;
|
||||
|
||||
this.disposed = true;
|
||||
this.instance.off('health', this.handle_food);
|
||||
this.listeners.clear();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseInfo} from '../types.ts';
|
||||
|
||||
export class init implements BaseInfo<number> {
|
||||
readonly name = 'health';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN',
|
||||
name: '生命值',
|
||||
description: '机器人的当前生命值',
|
||||
};
|
||||
|
||||
private health: number;
|
||||
private disposed = false;
|
||||
private readonly listeners = new Set<(data: number) => void>();
|
||||
|
||||
constructor(private readonly instance: Bot) {
|
||||
this.health = instance.health ?? 0;
|
||||
instance.on('health', this.handle_health);
|
||||
}
|
||||
|
||||
private readonly handle_health = (): void => {
|
||||
this.update_health(this.instance.health ?? 0);
|
||||
};
|
||||
|
||||
get_info(): number {
|
||||
return this.health;
|
||||
}
|
||||
|
||||
on_update(callback: (data: number) => void): () => void {
|
||||
if (this.disposed) {
|
||||
throw new Error('HealthInfo 已销毁');
|
||||
}
|
||||
|
||||
this.listeners.add(callback);
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
private update_health(health: number): void {
|
||||
if (this.disposed || Object.is(this.health, health)) return;
|
||||
|
||||
this.health = health;
|
||||
for (const callback of Array.from(this.listeners)) {
|
||||
callback(health);
|
||||
}
|
||||
}
|
||||
|
||||
readonly dispose = (): void => {
|
||||
if (this.disposed) return;
|
||||
|
||||
this.disposed = true;
|
||||
this.instance.off('health', this.handle_health);
|
||||
this.listeners.clear();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseInfo, InfoI18n} from '../types.ts';
|
||||
import type {log} from '../../../types/utils/logger.ts';
|
||||
import {MinecraftJsonParser} from '../../../utils/message_json_parser.ts';
|
||||
import {parse_inventory_item, type InventoryItem} from '../../../utils/inventory_item.ts';
|
||||
export type {InventoryItem} from '../../../utils/inventory_item.ts';
|
||||
|
||||
type WireValue = null | boolean | number | string | WireValue[] | {[key: string]: WireValue};
|
||||
type Item = NonNullable<Bot['inventory']['slots'][number]>;
|
||||
type Window = Bot['inventory'];
|
||||
type WindowKind = 'inventory' | 'chest' | 'large_chest' | 'crafting' | 'enchantment'
|
||||
| 'stonecutter' | 'furnace' | 'merchant' | 'lectern' | 'generic';
|
||||
|
||||
export interface InventoryInfo {
|
||||
window: {
|
||||
id: number;
|
||||
kind: WindowKind;
|
||||
type: string | number;
|
||||
title: log[];
|
||||
inventoryStart: number | null;
|
||||
inventoryEnd: number | null;
|
||||
hotbarStart: number | null;
|
||||
hasPlayerInventory: boolean;
|
||||
craftingResultSlot: number | null;
|
||||
slots: (InventoryItem | null)[];
|
||||
properties: Record<string, number>;
|
||||
} | null;
|
||||
cursorItem: InventoryItem | null;
|
||||
handSlot: number | null;
|
||||
special: Record<string, WireValue>;
|
||||
}
|
||||
|
||||
function to_wire(value: unknown, ancestors = new Set<object>()): WireValue {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'bigint') return value.toString();
|
||||
if (typeof value === 'string' || typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
||||
if (Buffer.isBuffer(value)) return Array.from(value);
|
||||
if (typeof value === 'object') {
|
||||
if (ancestors.has(value)) return null;
|
||||
ancestors.add(value);
|
||||
try {
|
||||
if (Array.isArray(value)) return Array.from(value, entry => to_wire(entry, ancestors));
|
||||
return Object.fromEntries(Object.entries(Object.getOwnPropertyDescriptors(value))
|
||||
.filter(([, descriptor]) => descriptor.enumerable)
|
||||
.map(([key, descriptor]) => [key, 'value' in descriptor ? to_wire(descriptor.value, ancestors) : null]));
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function window_kind(window: Window): WindowKind {
|
||||
const type = String(window.type).replace(/^minecraft:/, '');
|
||||
if (window.id === 0 || type === 'inventory') return 'inventory';
|
||||
if (['furnace', 'blast_furnace', 'smoker'].includes(type)) return 'furnace';
|
||||
if (['crafting', 'crafting_table', 'crafting3x3'].includes(type)) return 'crafting';
|
||||
if (['enchantment', 'enchanting_table'].includes(type)) return 'enchantment';
|
||||
if (type === 'stonecutter' || type === 'lectern') return type;
|
||||
if (type === 'merchant' || type === 'villager') return 'merchant';
|
||||
if (['container', 'chest'].includes(type) || /^generic_9x[1-6]$/.test(type)) {
|
||||
if (window.inventoryStart === 54) return 'large_chest';
|
||||
if (window.inventoryStart === 27) return 'chest';
|
||||
}
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
const field = (name: string, description: string): InfoI18n => ({lang: 'zh-CN', name, description});
|
||||
const item_fields = {
|
||||
slot: field('槽位', '当前窗口槽位编号,鼠标物品和交易物品为 null'),
|
||||
type: field('物品 ID', '当前版本的物品数值 ID,不包含图标'),
|
||||
name: field('物品标识', '物品英文标识'), displayName: field('物品名称', '优先自定义名称,其次物品名称组件和默认名称,返回 log 片段'),
|
||||
i18n: {...field('物品翻译', '按方块、物品翻译键依次查询中文名称,缺失时使用英文标识'), fields: {
|
||||
lang: field('语言', '翻译语言标识'), name: field('中文名称', '物品默认中文名称,不覆盖自定义昵称'),
|
||||
description: field('描述', '翻译描述,没有时为空字符串'),
|
||||
}},
|
||||
count: field('数量', '物品堆叠数量'), metadata: field('元数据', '旧版本物品元数据'),
|
||||
lore: field('物品描述', '每行是一组 log 片段,保留空行'),
|
||||
enchants: {...field('附魔', '已解析的附魔列表'), fields: {
|
||||
id: field('附魔 ID', '版本内数值标识,未知时省略'), name: field('附魔标识', '英文附魔标识,未知时省略'),
|
||||
i18n: {...field('附魔翻译', '查询中文附魔名称,缺失时回退到附魔标识或 ID'), fields: {
|
||||
lang: field('语言', '翻译语言标识'), name: field('中文名称', '附魔中文名称'), description: field('描述', '翻译描述,没有时为空字符串'),
|
||||
}},
|
||||
level: field('附魔等级', '实际附魔等级'), hidden: field('隐藏附魔提示', '服务器是否要求隐藏此附魔提示'),
|
||||
}},
|
||||
durability: {...field('耐久', '耐久信息,没有耐久时为 null'), fields: {
|
||||
used: field('已用耐久', '已消耗耐久'), max: field('最大耐久', '最大耐久'), remaining: field('剩余耐久', '剩余可用耐久'),
|
||||
}},
|
||||
unbreakable: field('不可破坏', '物品是否不会损耗耐久'), glint: field('附魔光效', '强制光效开关,未指定为 null'),
|
||||
book: {...field('书籍', '已解析的现代或旧版书籍,没有时为 null'), fields: {
|
||||
title: field('书名', '书籍标题'), author: field('作者', '书籍作者'), pages: field('书页', '每页是一组 log 片段'),
|
||||
}},
|
||||
details: {...field('特殊属性', '已解析特殊数据及解除 NBT 包装的未知字段'), fields: {
|
||||
potion: {...field('药水', '药水类型、颜色和效果'), fields: {
|
||||
type: field('药水类型', '版本内药水数值 ID 或旧版标识'), color: field('药水颜色', '自定义 RGB 整数颜色,未指定为 null'),
|
||||
effects: {...field('药水效果', '自定义状态效果列表'), fields: {
|
||||
id: field('效果 ID', '版本内数值 ID 或英文标识'), name: field('效果名称', '已识别效果名称'),
|
||||
amplifier: field('效果增幅', '从 0 开始'), duration: field('持续时间', '游戏刻数'),
|
||||
ambient: field('环境效果', '是否为环境效果'), showParticles: field('显示粒子', '是否显示效果粒子'),
|
||||
}},
|
||||
}},
|
||||
attributes: {...field('属性修饰符', '属性及提示可见性'), fields: {
|
||||
hidden: field('隐藏属性提示', '是否隐藏属性修饰符提示'), modifiers: {...field('修饰符列表', '已解析属性记录'), fields: {
|
||||
type: field('属性类型', '版本内数值 ID 或英文标识'), name: field('修饰符名称', '修饰符名称或标识'),
|
||||
amount: field('数值', '属性修饰数值'), operation: field('运算方式', '加法或乘法运算标识'), slot: field('生效槽位', '属性生效的装备槽'),
|
||||
hidden: field('隐藏此项', '是否单独隐藏此属性'), displayName: field('显示文本', '覆盖显示文本的 log 片段'),
|
||||
}},
|
||||
}},
|
||||
container: field('容器物品', '嵌套物品,使用相同物品结构'), bundle: field('收纳袋物品', '嵌套物品,使用相同物品结构'),
|
||||
tooltip: {...field('提示可见性', '隐藏全部或附加提示及旧版标志'), fields: {
|
||||
hideAll: field('隐藏全部提示', '隐藏全部物品提示'), hideAdditional: field('隐藏附加提示', '隐藏附加提示'),
|
||||
hiddenComponents: field('隐藏组件', '被隐藏的组件名称,无法识别时保留 ID'), legacyFlags: field('旧版隐藏标志', '原始 HideFlags 数值'),
|
||||
}},
|
||||
extra: field('其他标签', '未识别标签解除 NBT 包装后保留'),
|
||||
}},
|
||||
truncated: field('数据已裁剪', '物品数据超出解析预算或存在循环引用、访问器等无法表示的数据'),
|
||||
};
|
||||
|
||||
export class init implements BaseInfo<InventoryInfo> {
|
||||
readonly name = 'inventory';
|
||||
readonly i18n: InfoI18n = {
|
||||
...field('背包与窗口', '只读查询当前窗口及物品,支持背包和各类容器,不提供操作'),
|
||||
fields: {
|
||||
window: {...field('当前窗口', '未打开容器时返回玩家背包,尚未初始化时为 null'), fields: {
|
||||
id: field('窗口 ID', '服务器窗口编号'), kind: field('窗口类别', '背包、箱子、合成、附魔、切石、熔炉、交易、讲台或通用窗口'),
|
||||
type: field('窗口类型', 'Mineflayer 原始窗口类型'), title: field('窗口标题', '标题的 log 片段'),
|
||||
inventoryStart: field('背包起点', '当前窗口玩家背包起始槽,讲台为 null'),
|
||||
inventoryEnd: field('背包终点', '玩家背包结束槽,不包含该槽,讲台为 null'),
|
||||
hotbarStart: field('快捷栏起点', '当前窗口快捷栏起始槽,讲台为 null'),
|
||||
hasPlayerInventory: field('包含玩家背包', '讲台不包含玩家背包'),
|
||||
craftingResultSlot: field('结果槽', '窗口结果槽,没有时为 null'),
|
||||
slots: {...field('物品槽', '按槽号排列,空槽为 null,fields 描述物品'), fields: item_fields},
|
||||
properties: field('窗口属性', '服务器下发的属性 ID 到数值的映射,未收到属性不填充'),
|
||||
}},
|
||||
cursorItem: {...field('鼠标物品', '鼠标携带物品,没有时为 null'), fields: item_fields},
|
||||
handSlot: field('主手槽', '当前窗口坐标系中的主手槽,讲台或未初始化时为 null'),
|
||||
special: {...field('特殊窗口状态', '仅包含当前窗口对应状态,未知值为 null'), fields: {
|
||||
furnace: {...field('熔炉', '熔炉、高炉和烟熏炉状态'), fields: {
|
||||
burnRemaining: field('剩余燃烧', '剩余燃烧游戏刻'), burnTotal: field('总燃烧时间', '燃料总游戏刻'),
|
||||
cookProgress: field('烧炼进度', '已烧炼游戏刻'), cookTotal: field('总烧炼时间', '所需游戏刻'),
|
||||
fuelRatio: field('燃料比例', '剩余燃料比例,范围 0 至 1'), progressRatio: field('烧炼比例', '烧炼进度比例,范围 0 至 1'),
|
||||
}},
|
||||
enchantment: {...field('附魔台', '附魔选项与种子'), fields: {
|
||||
seed: field('附魔种子', '服务器下发的附魔种子'),
|
||||
options: {...field('附魔选项', '三个候选项,fields 描述每项'), fields: {
|
||||
index: field('选项索引', '从 0 开始'), requiredLevel: field('所需等级', '选择该项需要达到的等级'),
|
||||
levelCost: field('等级消耗', '消耗 1、2 或 3 级'), lapisCost: field('青金石消耗', '消耗 1、2 或 3 个青金石'),
|
||||
enchantmentId: field('提示附魔 ID', '提示的附魔 ID'), enchantmentLevel: field('提示附魔等级', '提示的附魔等级'),
|
||||
}},
|
||||
}},
|
||||
merchant: {...field('村民交易', '由 Mineflayer 专用交易 API 初始化的报价'), fields: {
|
||||
ready: field('报价就绪', '尚未初始化交易列表时为 false'),
|
||||
offers: {...field('报价列表', 'fields 描述每项报价'), fields: {
|
||||
index: field('报价索引', '从 0 开始'),
|
||||
input1: {...field('第一材料', '交易的第一种材料'), fields: item_fields},
|
||||
input2: {...field('第二材料', '没有时为 null'), fields: item_fields},
|
||||
output: {...field('交易产物', '交易结果物品'), fields: item_fields},
|
||||
realPrice: field('实际价格', '第一材料实际数量'), disabled: field('不可交易', '服务器报告的交易禁用状态'),
|
||||
uses: field('已交易次数', '当前报价已使用次数'), maxUses: field('最多交易次数', '报价最大使用次数'),
|
||||
}},
|
||||
}},
|
||||
lectern: {...field('讲台', '讲台上的书及页码'), fields: {
|
||||
page: field('当前页码', '服务器下发的页码,从 0 开始,未知为 null'),
|
||||
book: {...field('书籍', '支持现代组件与旧版 NBT,未收到书籍时为 null'), fields: {
|
||||
title: field('书名', '书籍标题'), author: field('作者', '书籍作者'), pages: field('书页', '每页为一组 log 片段'),
|
||||
}},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
};
|
||||
|
||||
private info: InventoryInfo = {window: null, cursorItem: null, handSlot: null, special: {}};
|
||||
private disposed = false;
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
private attached_inventory: Window | undefined;
|
||||
private attached_window: Window | null = null;
|
||||
private readonly properties = new Map<number, Map<number, number>>();
|
||||
private readonly listeners = new Set<(data: InventoryInfo) => void>();
|
||||
private readonly events = ['heldItemChanged', 'spawn', 'physicsTick'] as const;
|
||||
private readonly packets = ['trade_list', 'MC|TrList', 'minecraft:trader_list', 'window_items', 'set_slot', 'set_player_inventory'] as const;
|
||||
|
||||
constructor(private readonly instance: Bot) {
|
||||
this.attach();
|
||||
this.update();
|
||||
instance.on('windowOpen', this.handle_open);
|
||||
instance.on('windowClose', this.handle_close);
|
||||
for (const event of this.events) instance.on(event, this.refresh);
|
||||
for (const packet of this.packets) instance._client.on(packet, this.refresh);
|
||||
instance._client.on('craft_progress_bar', this.handle_property);
|
||||
}
|
||||
|
||||
private attach(): void {
|
||||
const inventory = this.instance.inventory;
|
||||
if (inventory !== this.attached_inventory) {
|
||||
this.attached_inventory?.off('updateSlot', this.refresh);
|
||||
this.attached_inventory = inventory;
|
||||
inventory?.on('updateSlot', this.refresh);
|
||||
}
|
||||
const window = this.instance.currentWindow as Window | null;
|
||||
if (window !== this.attached_window) {
|
||||
this.attached_window?.off('updateSlot', this.refresh);
|
||||
this.attached_window = window;
|
||||
if (window) this.properties.delete(window.id);
|
||||
window?.on('updateSlot', this.refresh);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly handle_open = (): void => {
|
||||
this.refresh();
|
||||
};
|
||||
|
||||
private readonly handle_close = (window: Bot['inventory']): void => {
|
||||
if (window) this.properties.delete(window.id);
|
||||
this.refresh();
|
||||
};
|
||||
|
||||
private readonly handle_property = (packet: {windowId: number; property: number; value: number}): void => {
|
||||
const window = this.instance.currentWindow;
|
||||
if (this.disposed || !window || window.id !== packet.windowId) return;
|
||||
this.attach();
|
||||
let values = this.properties.get(window.id);
|
||||
if (!values) this.properties.set(window.id, values = new Map());
|
||||
values.set(packet.property, packet.value);
|
||||
this.refresh();
|
||||
};
|
||||
|
||||
private readonly refresh = (): void => {
|
||||
if (this.disposed) return;
|
||||
this.attach();
|
||||
if (this.timer) return;
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = undefined;
|
||||
this.update();
|
||||
}, 50);
|
||||
this.timer.unref();
|
||||
};
|
||||
|
||||
private update(): void {
|
||||
if (this.disposed) return;
|
||||
const bot = this.instance;
|
||||
const read_item = (item: Item | null | undefined, slot: number | null = null) => parse_inventory_item(item, slot, bot.registry);
|
||||
const window = bot.currentWindow ?? bot.inventory;
|
||||
const info: InventoryInfo = {window: null, cursorItem: null, handSlot: null, special: {}};
|
||||
if (window) {
|
||||
const kind = window_kind(window);
|
||||
const hasPlayerInventory = kind !== 'lectern';
|
||||
const values = this.properties.get(window.id);
|
||||
const property = (id: number) => values?.get(id) ?? null;
|
||||
const ratio = (value: number | null, total: number | null) => value === null || total === null || total <= 0
|
||||
? null : Math.max(0, Math.min(1, value / total));
|
||||
info.window = {
|
||||
id: window.id, kind, type: window.type,
|
||||
title: MinecraftJsonParser.toLog(window.title),
|
||||
inventoryStart: hasPlayerInventory ? window.inventoryStart : null,
|
||||
inventoryEnd: hasPlayerInventory ? window.inventoryEnd : null,
|
||||
hotbarStart: hasPlayerInventory ? window.hotbarStart : null,
|
||||
hasPlayerInventory,
|
||||
craftingResultSlot: window.craftingResultSlot >= 0 ? window.craftingResultSlot : null,
|
||||
slots: Array.from(kind === 'lectern' ? window.slots.slice(0, 1) : window.slots, (item, slot) => read_item(item, slot)),
|
||||
properties: Object.fromEntries(values ?? []),
|
||||
};
|
||||
info.cursorItem = read_item(window.selectedItem);
|
||||
info.handSlot = hasPlayerInventory && Number.isInteger(bot.quickBarSlot) ? window.hotbarStart + bot.quickBarSlot : null;
|
||||
if (kind === 'furnace') {
|
||||
info.special.furnace = {
|
||||
burnRemaining: property(0), burnTotal: property(1), cookProgress: property(2), cookTotal: property(3),
|
||||
fuelRatio: ratio(property(0), property(1)), progressRatio: ratio(property(2), property(3)),
|
||||
};
|
||||
} else if (kind === 'enchantment') {
|
||||
info.special.enchantment = {
|
||||
seed: property(3), options: [0, 1, 2].map(index => ({
|
||||
index, requiredLevel: property(index), levelCost: index + 1, lapisCost: index + 1,
|
||||
enchantmentId: property(4 + index), enchantmentLevel: property(7 + index),
|
||||
})),
|
||||
};
|
||||
} else if (kind === 'merchant') {
|
||||
const trades = (window as Window & {trades?: Awaited<ReturnType<Bot['openVillager']>>['trades']}).trades;
|
||||
info.special.merchant = to_wire({ready: Array.isArray(trades), offers: (trades ?? []).map((trade, index) => ({
|
||||
index, input1: read_item(trade.inputItem1), input2: trade.hasItem2 ? read_item(trade.inputItem2) : null,
|
||||
output: read_item(trade.outputItem), realPrice: trade.realPrice ?? null,
|
||||
disabled: Boolean(trade.tradeDisabled), uses: trade.nbTradeUses ?? null, maxUses: trade.maximumNbTradeUses ?? null,
|
||||
}))});
|
||||
} else if (kind === 'lectern') {
|
||||
info.special.lectern = {page: property(0), book: to_wire(read_item(window.slots[0])?.book)};
|
||||
}
|
||||
}
|
||||
if (JSON.stringify(this.info) === JSON.stringify(info)) return;
|
||||
this.info = info;
|
||||
for (const callback of Array.from(this.listeners)) callback(info);
|
||||
}
|
||||
|
||||
get_info(): InventoryInfo {
|
||||
return this.info;
|
||||
}
|
||||
|
||||
on_update(callback: (data: InventoryInfo) => void): () => void {
|
||||
if (this.disposed) throw new Error('InventoryInfo 已销毁');
|
||||
this.listeners.add(callback);
|
||||
return () => { this.listeners.delete(callback); };
|
||||
}
|
||||
|
||||
readonly dispose = (): void => {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
clearTimeout(this.timer);
|
||||
this.attached_inventory?.off('updateSlot', this.refresh);
|
||||
this.attached_window?.off('updateSlot', this.refresh);
|
||||
this.instance.off('windowOpen', this.handle_open);
|
||||
this.instance.off('windowClose', this.handle_close);
|
||||
for (const event of this.events) this.instance.off(event, this.refresh);
|
||||
for (const packet of this.packets) this.instance._client.off(packet, this.refresh);
|
||||
this.instance._client.off('craft_progress_bar', this.handle_property);
|
||||
this.properties.clear();
|
||||
this.listeners.clear();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseInfo, InfoI18n} from '../types.ts';
|
||||
import type {log} from '../../../types/utils/logger.ts';
|
||||
import {MinecraftJsonParser} from '../../../utils/message_json_parser.ts';
|
||||
|
||||
export interface PlayerListEntry {
|
||||
displayName: log[];
|
||||
ping: number;
|
||||
}
|
||||
|
||||
export class init implements BaseInfo<PlayerListEntry[]> {
|
||||
readonly name = 'player_list';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN', name: '玩家列表', description: '服务器玩家列表,包含机器人自身,fields 描述每个数组元素',
|
||||
fields: {
|
||||
displayName: {lang: 'zh-CN', name: '显示名称', description: '优先使用玩家昵称,缺失、为空或解析失败时使用用户名,返回 log 片段'},
|
||||
ping: {lang: 'zh-CN', name: '玩家延迟', description: '玩家延迟,单位为毫秒,缺失、无效或读取失败时返回 0'},
|
||||
},
|
||||
} satisfies InfoI18n;
|
||||
|
||||
private players: PlayerListEntry[] = [];
|
||||
private disposed = false;
|
||||
private readonly listeners = new Set<(data: PlayerListEntry[]) => void>();
|
||||
private readonly events = ['playerJoined', 'playerUpdated', 'playerLeft', 'spawn', 'physicsTick'] as const;
|
||||
|
||||
constructor(private readonly instance: Bot) {
|
||||
this.handle_players();
|
||||
for (const event of this.events) instance.on(event, this.handle_players);
|
||||
}
|
||||
|
||||
private readonly handle_players = (): void => {
|
||||
if (this.disposed) return;
|
||||
|
||||
const players = Object.entries(this.instance.players ?? {})
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([username, player]): PlayerListEntry => {
|
||||
let displayName: log[] = [];
|
||||
try {
|
||||
displayName = MinecraftJsonParser.toLog(player.displayName);
|
||||
} catch {
|
||||
// A malformed nickname must not prevent the player list from updating.
|
||||
}
|
||||
if (!displayName.length) displayName = MinecraftJsonParser.toLog(player.username || username);
|
||||
|
||||
let ping = 0;
|
||||
try {
|
||||
const value = player.ping;
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) ping = value;
|
||||
} catch {
|
||||
// Keep the default when latency cannot be read.
|
||||
}
|
||||
return {displayName, ping};
|
||||
});
|
||||
|
||||
if (JSON.stringify(this.players) === JSON.stringify(players)) return;
|
||||
this.players = players;
|
||||
for (const callback of Array.from(this.listeners)) callback(players);
|
||||
};
|
||||
|
||||
get_info(): PlayerListEntry[] {
|
||||
return this.players;
|
||||
}
|
||||
|
||||
on_update(callback: (data: PlayerListEntry[]) => void): () => void {
|
||||
if (this.disposed) throw new Error('PlayerListInfo 已销毁');
|
||||
this.listeners.add(callback);
|
||||
return () => { this.listeners.delete(callback); };
|
||||
}
|
||||
|
||||
readonly dispose = (): void => {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
for (const event of this.events) this.instance.off(event, this.handle_players);
|
||||
this.listeners.clear();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseInfo, InfoContext, InfoI18n} from '../types.ts';
|
||||
import type {PluginSnapshot} from '../../plugins/init.ts';
|
||||
|
||||
export interface PluginStatesInfo {
|
||||
available: boolean;
|
||||
plugins: Omit<PluginSnapshot, 'config'>[];
|
||||
}
|
||||
|
||||
export class init implements BaseInfo<PluginStatesInfo> {
|
||||
readonly name = 'plugin_states';
|
||||
readonly i18n: InfoI18n = {
|
||||
lang: 'zh-CN', name: '插件状态', description: '插件启用情况、生命周期和内部状态,不包含配置',
|
||||
fields: {
|
||||
available: {lang: 'zh-CN', name: '状态源可用', description: '是否提供插件管理器状态源'},
|
||||
plugins: {lang: 'zh-CN', name: '插件列表', description: '包含已禁用和失败记录', fields: {
|
||||
name: {lang: 'zh-CN', name: '插件名称', description: '稳定的插件标识'},
|
||||
path: {lang: 'zh-CN', name: '插件路径', description: '插件分类路径'},
|
||||
enabled: {lang: 'zh-CN', name: '已启用', description: '仅初始化成功且尚未开始清理时为 true'},
|
||||
lifecycle: {lang: 'zh-CN', name: '生命周期', description: 'initializing / enabled / disabling / disabled / error'},
|
||||
states: {lang: 'zh-CN', name: '内部状态', description: '插件发布的内部状态;禁用记录保留清理完成时的最终快照'},
|
||||
}},
|
||||
},
|
||||
};
|
||||
private value: PluginStatesInfo;
|
||||
private readonly listeners = new Set<(value: PluginStatesInfo) => void>();
|
||||
private unsubscribe?: () => void;
|
||||
private disposed = false;
|
||||
|
||||
constructor(_bot: Bot, options?: InfoContext) {
|
||||
const source = options?.plugin_source;
|
||||
const read = (): PluginStatesInfo => ({available: !!source,
|
||||
plugins: (source?.get_all_plugins() ?? []).map(({name, path, enabled, lifecycle, states}) =>
|
||||
structuredClone({name, path, enabled, lifecycle, states})),
|
||||
});
|
||||
this.value = read();
|
||||
this.unsubscribe = source?.on_update(() => {
|
||||
if (this.disposed) return;
|
||||
this.value = read();
|
||||
for (const listener of Array.from(this.listeners)) {
|
||||
if (!this.listeners.has(listener)) continue;
|
||||
try { listener(this.get_info()); } catch {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get_info(): PluginStatesInfo { return structuredClone(this.value); }
|
||||
|
||||
on_update(callback: (value: PluginStatesInfo) => void): () => void {
|
||||
if (this.disposed) throw new Error('Plugin states info has been disposed');
|
||||
const listener = (value: PluginStatesInfo) => callback(value);
|
||||
this.listeners.add(listener);
|
||||
return () => { this.listeners.delete(listener); };
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.unsubscribe?.();
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseInfo, InfoI18n} from '../types.ts';
|
||||
|
||||
export interface PositionInfo {
|
||||
location?: {x: number; y: number; z: number};
|
||||
yaw?: number;
|
||||
face_direction?: 'south' | 'west' | 'north' | 'east';
|
||||
pitch?: number;
|
||||
velocity?: {x: number; y: number; z: number};
|
||||
}
|
||||
|
||||
export class init implements BaseInfo<PositionInfo> {
|
||||
readonly name = 'position';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN',
|
||||
name: '位置',
|
||||
description: '机器人的当前位置、朝向和速度',
|
||||
fields: {
|
||||
location: {
|
||||
lang: 'zh-CN',
|
||||
name: '坐标',
|
||||
description: '机器人在世界中的坐标',
|
||||
fields: {
|
||||
x: {lang: 'zh-CN', name: 'X 坐标', description: '东西方向的坐标'},
|
||||
y: {lang: 'zh-CN', name: 'Y 坐标', description: '垂直方向的坐标'},
|
||||
z: {lang: 'zh-CN', name: 'Z 坐标', description: '南北方向的坐标'},
|
||||
},
|
||||
},
|
||||
yaw: {
|
||||
lang: 'zh-CN',
|
||||
name: '偏航角',
|
||||
description: '机器人水平方向的朝向角度,单位为弧度',
|
||||
},
|
||||
face_direction: {
|
||||
lang: 'zh-CN',
|
||||
name: '朝向',
|
||||
description: '机器人面向的水平方位',
|
||||
values: {
|
||||
south: {lang: 'zh-CN', name: '南', description: '朝向南方'},
|
||||
west: {lang: 'zh-CN', name: '西', description: '朝向西方'},
|
||||
north: {lang: 'zh-CN', name: '北', description: '朝向北方'},
|
||||
east: {lang: 'zh-CN', name: '东', description: '朝向东方'},
|
||||
},
|
||||
},
|
||||
pitch: {
|
||||
lang: 'zh-CN',
|
||||
name: '俯仰角',
|
||||
description: '机器人垂直方向的朝向角度,单位为弧度',
|
||||
},
|
||||
velocity: {
|
||||
lang: 'zh-CN',
|
||||
name: '速度',
|
||||
description: '机器人在各坐标轴上的速度分量,单位为格每物理刻',
|
||||
fields: {
|
||||
x: {lang: 'zh-CN', name: 'X 轴速度', description: '东西方向的速度分量'},
|
||||
y: {lang: 'zh-CN', name: 'Y 轴速度', description: '垂直方向的速度分量'},
|
||||
z: {lang: 'zh-CN', name: 'Z 轴速度', description: '南北方向的速度分量'},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies InfoI18n;
|
||||
|
||||
private position: PositionInfo = {};
|
||||
private disposed = false;
|
||||
private readonly listeners = new Set<(data: PositionInfo) => void>();
|
||||
|
||||
constructor(private readonly instance: Bot) {
|
||||
this.handle_position();
|
||||
instance.on('move', this.handle_position);
|
||||
instance.on('forcedMove', this.handle_position);
|
||||
instance.on('physicsTick', this.handle_position);
|
||||
instance.on('spawn', this.handle_position);
|
||||
}
|
||||
|
||||
private readonly handle_position = (): void => {
|
||||
if (this.disposed) return;
|
||||
|
||||
const entity = this.instance.entity;
|
||||
const position: PositionInfo = {};
|
||||
const pos = entity?.position;
|
||||
if (pos) {
|
||||
position.location = {
|
||||
x: parseFloat(pos.x.toFixed(2)),
|
||||
y: parseFloat(pos.y.toFixed(2)),
|
||||
z: parseFloat(pos.z.toFixed(2)),
|
||||
};
|
||||
}
|
||||
if (typeof entity?.yaw === 'number') {
|
||||
position.yaw = parseFloat(entity.yaw.toFixed(3));
|
||||
// Mineflayer yaw: 0 = south, PI / 2 = west.
|
||||
const directions = ['south', 'west', 'north', 'east'] as const;
|
||||
const index = ((Math.round(entity.yaw / (Math.PI / 2)) % 4) + 4) % 4;
|
||||
position.face_direction = directions[index];
|
||||
}
|
||||
if (typeof entity?.pitch === 'number') {
|
||||
position.pitch = parseFloat(entity.pitch.toFixed(3));
|
||||
}
|
||||
const vel = entity?.velocity;
|
||||
if (vel) {
|
||||
position.velocity = {
|
||||
x: parseFloat(vel.x.toFixed(3)),
|
||||
y: parseFloat(vel.y.toFixed(3)),
|
||||
z: parseFloat(vel.z.toFixed(3)),
|
||||
};
|
||||
}
|
||||
|
||||
if (JSON.stringify(this.position) === JSON.stringify(position)) return;
|
||||
this.position = position;
|
||||
for (const callback of Array.from(this.listeners)) {
|
||||
callback(position);
|
||||
}
|
||||
};
|
||||
|
||||
get_info(): PositionInfo {
|
||||
return this.position;
|
||||
}
|
||||
|
||||
on_update(callback: (data: PositionInfo) => void): () => void {
|
||||
if (this.disposed) {
|
||||
throw new Error('PositionInfo 已销毁');
|
||||
}
|
||||
|
||||
this.listeners.add(callback);
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
readonly dispose = (): void => {
|
||||
if (this.disposed) return;
|
||||
|
||||
this.disposed = true;
|
||||
this.instance.off('move', this.handle_position);
|
||||
this.instance.off('forcedMove', this.handle_position);
|
||||
this.instance.off('physicsTick', this.handle_position);
|
||||
this.instance.off('spawn', this.handle_position);
|
||||
this.listeners.clear();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseInfo, InfoI18n} from '../types.ts';
|
||||
import type {log} from '../../../types/utils/logger.ts';
|
||||
import {MinecraftJsonParser} from '../../../utils/message_json_parser.ts';
|
||||
|
||||
export interface ScoreboardInfo {
|
||||
scoreboard_title: log[];
|
||||
scoreboard: log[][];
|
||||
header: log[];
|
||||
footer: log[];
|
||||
}
|
||||
|
||||
export class init implements BaseInfo<ScoreboardInfo> {
|
||||
readonly name = 'scoreboard';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN', name: '计分板与 TAB', description: '当前侧边栏计分板及 TAB 页眉、页脚,以 log 片段返回',
|
||||
fields: {
|
||||
scoreboard_title: {lang: 'zh-CN', name: '计分板标题', description: '当前侧边栏标题的 log 片段,无侧边栏时为空数组'},
|
||||
scoreboard: {lang: 'zh-CN', name: '计分板内容', description: '按分数降序排列,每行是一组 log 片段,保留空行,不混入隐藏计分板'},
|
||||
header: {lang: 'zh-CN', name: 'TAB 页眉', description: 'TAB 页眉的 log 片段,保留换行和文本样式'},
|
||||
footer: {lang: 'zh-CN', name: 'TAB 页脚', description: 'TAB 页脚的 log 片段,保留换行和文本样式'},
|
||||
},
|
||||
} satisfies InfoI18n;
|
||||
|
||||
private info: ScoreboardInfo = {scoreboard_title: [], scoreboard: [], header: [], footer: []};
|
||||
private disposed = false;
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
private last_update = -Infinity;
|
||||
private readonly listeners = new Set<(data: ScoreboardInfo) => void>();
|
||||
private readonly events = ['scoreboardCreated', 'scoreboardDeleted', 'scoreboardTitleChanged', 'scoreboardPosition', 'scoreUpdated', 'scoreRemoved', 'teamCreated', 'teamRemoved', 'teamUpdated', 'teamMemberAdded', 'teamMemberRemoved', 'spawn', 'respawn', 'physicsTick'] as const;
|
||||
|
||||
constructor(private readonly instance: Bot) {
|
||||
this.update();
|
||||
for (const event of this.events) instance.on(event, this.schedule_update);
|
||||
instance._client.on('playerlist_header', this.schedule_update);
|
||||
}
|
||||
|
||||
private readonly schedule_update = (): void => {
|
||||
if (this.disposed || this.timer) return;
|
||||
// Some Mineflayer events fire before their state mutation finishes.
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = undefined;
|
||||
this.update();
|
||||
}, Math.max(0, 2000 - (performance.now() - this.last_update)));
|
||||
this.timer.unref();
|
||||
};
|
||||
|
||||
private clean_text(component: Parameters<typeof MinecraftJsonParser.toLog>[0]): log[] {
|
||||
let line_start = true;
|
||||
return MinecraftJsonParser.toLog(component).flatMap(segment => {
|
||||
let message = '';
|
||||
for (const character of String(segment.message)) {
|
||||
if (character === '\n' || character === '\r') {
|
||||
message += character;
|
||||
line_start = true;
|
||||
} else if (!line_start || !/[\s\u200B-\u200D\u2060]/u.test(character)) {
|
||||
message += character;
|
||||
line_start = false;
|
||||
}
|
||||
}
|
||||
return message ? [{...segment, message}] : [];
|
||||
});
|
||||
}
|
||||
|
||||
private update(): void {
|
||||
if (this.disposed) return;
|
||||
this.last_update = performance.now();
|
||||
const bot = this.instance;
|
||||
const board = bot.scoreboard?.sidebar;
|
||||
const info: ScoreboardInfo = {
|
||||
scoreboard_title: this.clean_text(board?.title),
|
||||
scoreboard: board ? board.items.map(item => {
|
||||
try {
|
||||
return this.clean_text(item.displayName);
|
||||
} catch {
|
||||
return this.clean_text(item.name);
|
||||
}
|
||||
}) : [],
|
||||
header: this.clean_text(bot.tablist?.header),
|
||||
footer: this.clean_text(bot.tablist?.footer),
|
||||
};
|
||||
if (JSON.stringify(this.info) === JSON.stringify(info)) return;
|
||||
this.info = info;
|
||||
for (const callback of Array.from(this.listeners)) callback(info);
|
||||
}
|
||||
|
||||
get_info(): ScoreboardInfo {
|
||||
return this.info;
|
||||
}
|
||||
|
||||
on_update(callback: (data: ScoreboardInfo) => void): () => void {
|
||||
if (this.disposed) throw new Error('ScoreboardInfo 已销毁');
|
||||
this.listeners.add(callback);
|
||||
return () => { this.listeners.delete(callback); };
|
||||
}
|
||||
|
||||
readonly dispose = (): void => {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
for (const event of this.events) this.instance.off(event, this.schedule_update);
|
||||
this.instance._client.off('playerlist_header', this.schedule_update);
|
||||
this.listeners.clear();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import minecraftData from '@wp2508/minecraft-data';
|
||||
import type {BaseInfo, InfoI18n} from '../types.ts';
|
||||
import type {log} from '../../../types/utils/logger.ts';
|
||||
import {MinecraftJsonParser} from '../../../utils/message_json_parser.ts';
|
||||
|
||||
export interface SelfInfo {
|
||||
displayName?: log[];
|
||||
level?: number;
|
||||
experience?: {points: number; progress: number};
|
||||
effects?: {id: number; name?: string; display_name: string; amplifier: number; duration: number}[];
|
||||
armor?: number;
|
||||
game_mode?: Bot['game']['gameMode'];
|
||||
difficulty?: Bot['game']['difficulty'];
|
||||
}
|
||||
|
||||
interface Attribute {
|
||||
value: number;
|
||||
modifiers: {operation: number; amount: number}[];
|
||||
}
|
||||
|
||||
export class init implements BaseInfo<SelfInfo> {
|
||||
readonly name = 'self';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN', name: '自身信息', description: '机器人的等级、经验、药水效果、护甲、游戏模式和难度',
|
||||
fields: {
|
||||
displayName: {lang: 'zh-CN', name: '显示名称', description: '优先使用玩家昵称,缺失或为空时使用用户名,返回 log 片段'},
|
||||
level: {lang: 'zh-CN', name: '等级', description: '机器人当前的经验等级'},
|
||||
experience: {
|
||||
lang: 'zh-CN', name: '经验值', description: '服务器同步的经验值和升级进度',
|
||||
fields: {
|
||||
points: {lang: 'zh-CN', name: '总经验值', description: '服务器同步的累计经验点数'},
|
||||
progress: {lang: 'zh-CN', name: '等级进度', description: '当前等级的经验条进度,范围为 0 至 1'},
|
||||
},
|
||||
},
|
||||
effects: {
|
||||
lang: 'zh-CN', name: '药水效果', description: '机器人当前具有的状态效果,fields 描述每个数组元素',
|
||||
fields: {
|
||||
id: {lang: 'zh-CN', name: '效果 ID', description: '当前版本的状态效果数值标识'},
|
||||
name: {lang: 'zh-CN', name: '效果名称', description: '状态效果的英文标识,未知效果省略'},
|
||||
display_name: {lang: 'zh-CN', name: '效果中文昵称', description: '按机器人版本查询的中文名称,没有中文名称时返回未知'},
|
||||
amplifier: {lang: 'zh-CN', name: '效果增幅', description: '效果增幅等级,从 0 开始,0 表示一级'},
|
||||
duration: {lang: 'zh-CN', name: '持续时间', description: '服务器最近下发的持续游戏刻数,-1 表示无限,不在本地倒计时'},
|
||||
},
|
||||
},
|
||||
armor: {lang: 'zh-CN', name: '护甲值', description: '应用属性修饰符后的护甲点数,2 点对应一个护甲图标'},
|
||||
game_mode: {
|
||||
lang: 'zh-CN', name: '游戏模式', description: '机器人当前的游戏模式',
|
||||
values: {
|
||||
survival: {lang: 'zh-CN', name: '生存', description: '生存模式'},
|
||||
creative: {lang: 'zh-CN', name: '创造', description: '创造模式'},
|
||||
adventure: {lang: 'zh-CN', name: '冒险', description: '冒险模式'},
|
||||
spectator: {lang: 'zh-CN', name: '旁观', description: '旁观模式'},
|
||||
},
|
||||
},
|
||||
difficulty: {
|
||||
lang: 'zh-CN', name: '游戏难度', description: '服务器当前的游戏难度',
|
||||
values: {
|
||||
peaceful: {lang: 'zh-CN', name: '和平', description: '和平难度'},
|
||||
easy: {lang: 'zh-CN', name: '简单', description: '简单难度'},
|
||||
normal: {lang: 'zh-CN', name: '普通', description: '普通难度'},
|
||||
hard: {lang: 'zh-CN', name: '困难', description: '困难难度'},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies InfoI18n;
|
||||
|
||||
private self: SelfInfo = {};
|
||||
private disposed = false;
|
||||
private readonly listeners = new Set<(data: SelfInfo) => void>();
|
||||
private readonly events = ['experience', 'game', 'spawn', 'respawn', 'playerJoined', 'playerUpdated', 'playerLeft', 'physicsTick'] as const;
|
||||
private readonly entity_events = ['entityEffect', 'entityEffectEnd', 'entityAttributes'] as const;
|
||||
|
||||
constructor(private readonly instance: Bot) {
|
||||
this.handle_self();
|
||||
for (const event of this.events) instance.on(event, this.handle_self);
|
||||
for (const event of this.entity_events) instance.on(event, this.handle_entity);
|
||||
}
|
||||
|
||||
private readonly handle_entity = (entity: Bot['entity']): void => {
|
||||
if (entity === this.instance.entity) this.handle_self();
|
||||
};
|
||||
|
||||
private readonly handle_self = (): void => {
|
||||
if (this.disposed) return;
|
||||
|
||||
const bot = this.instance;
|
||||
const self: SelfInfo = {};
|
||||
const player = bot.players?.[bot.username] ?? bot.player;
|
||||
const username = player?.username || bot.username;
|
||||
const displayName = MinecraftJsonParser.toLog(player?.displayName ?? username);
|
||||
if (displayName.length) self.displayName = displayName;
|
||||
else if (username) self.displayName = MinecraftJsonParser.toLog(username);
|
||||
if (typeof bot.experience?.level === 'number') self.level = bot.experience.level;
|
||||
if (typeof bot.experience?.points === 'number' && typeof bot.experience.progress === 'number') {
|
||||
self.experience = {points: bot.experience.points, progress: bot.experience.progress};
|
||||
}
|
||||
const entity = bot.entity;
|
||||
if (entity) {
|
||||
const data = bot.version ? minecraftData(bot.version) : null;
|
||||
self.effects = Object.values(entity.effects ?? {}).map(effect => {
|
||||
const name = bot.registry?.effects?.[effect.id]?.name;
|
||||
const display_name = data?.effects[effect.id]?.displayName;
|
||||
return {
|
||||
id: effect.id,
|
||||
...(name ? {name} : {}),
|
||||
display_name: display_name && /\p{Script=Han}/u.test(display_name) ? display_name : '未知',
|
||||
amplifier: effect.amplifier,
|
||||
duration: effect.duration,
|
||||
};
|
||||
}).sort((a, b) => a.id - b.id);
|
||||
// Mineflayer populates attributes at runtime, but its Entity type omits them.
|
||||
const attributes = (entity as typeof entity & {attributes?: Record<string, Attribute>}).attributes;
|
||||
const armor = attributes?.['minecraft:armor'] ?? attributes?.armor
|
||||
?? attributes?.['minecraft:generic.armor'] ?? attributes?.['generic.armor'];
|
||||
if (armor) {
|
||||
let base = armor.value;
|
||||
for (const modifier of armor.modifiers ?? []) {
|
||||
if (modifier.operation === 0) base += modifier.amount;
|
||||
}
|
||||
let value = base;
|
||||
for (const modifier of armor.modifiers ?? []) {
|
||||
if (modifier.operation === 1) value += base * modifier.amount;
|
||||
}
|
||||
for (const modifier of armor.modifiers ?? []) {
|
||||
if (modifier.operation === 2) value *= 1 + modifier.amount;
|
||||
}
|
||||
self.armor = Math.min(30, Math.max(0, value));
|
||||
}
|
||||
}
|
||||
if (bot.game?.gameMode != null) self.game_mode = bot.game.gameMode;
|
||||
if (bot.game?.difficulty != null) self.difficulty = bot.game.difficulty;
|
||||
|
||||
if (JSON.stringify(this.self) === JSON.stringify(self)) return;
|
||||
this.self = self;
|
||||
for (const callback of Array.from(this.listeners)) callback(self);
|
||||
};
|
||||
|
||||
get_info(): SelfInfo {
|
||||
return this.self;
|
||||
}
|
||||
|
||||
on_update(callback: (data: SelfInfo) => void): () => void {
|
||||
if (this.disposed) throw new Error('SelfInfo 已销毁');
|
||||
this.listeners.add(callback);
|
||||
return () => { this.listeners.delete(callback); };
|
||||
}
|
||||
|
||||
readonly dispose = (): void => {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
for (const event of this.events) this.instance.off(event, this.handle_self);
|
||||
for (const event of this.entity_events) this.instance.off(event, this.handle_entity);
|
||||
this.listeners.clear();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseInfo, InfoI18n} from '../types.ts';
|
||||
|
||||
export interface ServerInfo {
|
||||
host?: string;
|
||||
brand?: string;
|
||||
version?: string;
|
||||
protocol?: number;
|
||||
compression_threshold?: number;
|
||||
online_seconds: number;
|
||||
online_time: string;
|
||||
ping: number;
|
||||
}
|
||||
|
||||
export class init implements BaseInfo<ServerInfo> {
|
||||
readonly name = 'server';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN', name: '服务器信息', description: '当前连接的服务器、协议、在线时长和延迟',
|
||||
fields: {
|
||||
host: {lang: 'zh-CN', name: '主机', description: '连接配置中的服务器主机和端口'},
|
||||
brand: {lang: 'zh-CN', name: 'Brand', description: '服务器报告的品牌字符串'},
|
||||
version: {lang: 'zh-CN', name: '版本', description: '当前连接使用的 Minecraft 版本'},
|
||||
protocol: {lang: 'zh-CN', name: '协议', description: '当前连接使用的协议版本号'},
|
||||
compression_threshold: {lang: 'zh-CN', name: '压缩阈值', description: '触发数据包压缩的字节数,负数表示未启用压缩'},
|
||||
online_seconds: {lang: 'zh-CN', name: '本次在线秒数', description: '从本次首次登录成功起累计的秒数'},
|
||||
online_time: {lang: 'zh-CN', name: '本次在线', description: '本次在线时长,格式为小时和分钟,例如 3h 05m'},
|
||||
ping: {lang: 'zh-CN', name: '服务器延迟', description: '服务器下发的当前机器人玩家延迟,单位毫秒,缺失或读取失败时为 0'},
|
||||
},
|
||||
} satisfies InfoI18n;
|
||||
|
||||
private info: ServerInfo = {online_seconds: 0, online_time: '0h 00m', ping: 0};
|
||||
private login_at: number | undefined;
|
||||
private disposed = false;
|
||||
private readonly listeners = new Set<(data: ServerInfo) => void>();
|
||||
private readonly events = ['game', 'playerJoined', 'playerUpdated'] as const;
|
||||
private readonly timer: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(private readonly instance: Bot) {
|
||||
this.handle_server();
|
||||
instance.on('login', this.handle_login);
|
||||
for (const event of this.events) instance.on(event, this.handle_server);
|
||||
this.timer = setInterval(this.handle_server, 1000);
|
||||
this.timer.unref();
|
||||
}
|
||||
|
||||
private readonly handle_login = (): void => {
|
||||
// Backend switches can emit login again without starting a new connection.
|
||||
this.login_at ??= performance.now();
|
||||
this.handle_server();
|
||||
};
|
||||
|
||||
private readonly handle_server = (): void => {
|
||||
if (this.disposed) return;
|
||||
const bot = this.instance;
|
||||
const client = bot._client;
|
||||
const online_seconds = this.login_at === undefined ? 0
|
||||
: Math.max(0, Math.floor((performance.now() - this.login_at) / 1000));
|
||||
const info: ServerInfo = {
|
||||
online_seconds,
|
||||
online_time: `${Math.floor(online_seconds / 3600)}h ${String(Math.floor(online_seconds / 60) % 60).padStart(2, '0')}m`,
|
||||
ping: 0,
|
||||
};
|
||||
const host = (bot as Bot & {server_address?: string}).server_address;
|
||||
if (host) info.host = host;
|
||||
if (typeof bot.game?.serverBrand === 'string') info.brand = bot.game.serverBrand;
|
||||
if (bot.version) info.version = bot.version;
|
||||
if (typeof client?.protocolVersion === 'number') info.protocol = client.protocolVersion;
|
||||
// The protocol library exposes a number at runtime despite its string declaration.
|
||||
const threshold = client?.compressionThreshold as unknown;
|
||||
if (typeof threshold === 'number') info.compression_threshold = threshold;
|
||||
try {
|
||||
const ping = (bot.players?.[bot.username] ?? bot.player)?.ping;
|
||||
if (typeof ping === 'number' && Number.isFinite(ping) && ping >= 0) info.ping = ping;
|
||||
} catch {
|
||||
// Keep zero when player latency is unavailable.
|
||||
}
|
||||
|
||||
if (JSON.stringify(this.info) === JSON.stringify(info)) return;
|
||||
this.info = info;
|
||||
for (const callback of Array.from(this.listeners)) callback(info);
|
||||
};
|
||||
|
||||
get_info(): ServerInfo {
|
||||
return this.info;
|
||||
}
|
||||
|
||||
on_update(callback: (data: ServerInfo) => void): () => void {
|
||||
if (this.disposed) throw new Error('ServerInfo 已销毁');
|
||||
this.listeners.add(callback);
|
||||
return () => { this.listeners.delete(callback); };
|
||||
}
|
||||
|
||||
readonly dispose = (): void => {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
clearInterval(this.timer);
|
||||
this.instance.off('login', this.handle_login);
|
||||
for (const event of this.events) this.instance.off(event, this.handle_server);
|
||||
this.listeners.clear();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseInfo, InfoContext} from '../types.ts';
|
||||
import type {WorkflowRuntimeSnapshot} from '../../../types/bot/workflow/runtime.ts';
|
||||
import {summarize_run, type RunSummary} from '../../workflow/runtime/summary.ts';
|
||||
|
||||
interface WorkflowStates extends WorkflowRuntimeSnapshot {available: boolean; runs: RunSummary[]}
|
||||
const push_interval_ms = 250;
|
||||
|
||||
export class init implements BaseInfo<WorkflowStates> {
|
||||
readonly name = 'workflow_states';
|
||||
readonly i18n = {lang: 'zh-CN', name: '工作流状态', description: '工作流运行状态、当前节点与结束原因'};
|
||||
private readonly listeners = new Set<(value: WorkflowStates) => void>();
|
||||
private unsubscribe?: () => void;
|
||||
private value: WorkflowStates;
|
||||
private disposed = false;
|
||||
private dirty = false;
|
||||
private timer?: ReturnType<typeof setTimeout>;
|
||||
private last_sent: string;
|
||||
private readonly read: () => WorkflowStates;
|
||||
|
||||
constructor(_bot: Bot, options?: InfoContext) {
|
||||
const source = options?.workflow_source;
|
||||
this.read = (): WorkflowStates => {
|
||||
const runtime = source?.get_runtime_snapshot?.() ?? {source_config_revision: 0, workflows: [], commands: []};
|
||||
// Older command results remain queryable by ID; do not resend all 100 on each live update.
|
||||
const recent = new Set(runtime.commands.filter(command => command.state !== 'accepted').slice(-20).map(command => command.command_id));
|
||||
return {available: !!source, ...runtime,
|
||||
commands: runtime.commands.filter(command => command.state === 'accepted' || recent.has(command.command_id)),
|
||||
runs: (source?.get_snapshots() ?? []).map(summarize_run)};
|
||||
};
|
||||
this.value = this.read();
|
||||
this.last_sent = JSON.stringify(this.value);
|
||||
this.unsubscribe = source?.on_update(event => {
|
||||
if (this.disposed || event.type !== 'run_updated' && event.type !== 'runtime_updated') return;
|
||||
this.dirty = true;
|
||||
this.schedule();
|
||||
});
|
||||
}
|
||||
private schedule() {
|
||||
if (this.disposed || this.timer !== undefined || !this.listeners.size) return;
|
||||
// A fixed window bounds traffic without starving updates during continuous activity.
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = undefined;
|
||||
if (this.disposed || !this.dirty || !this.listeners.size) return;
|
||||
this.value = this.read();
|
||||
this.dirty = false;
|
||||
const serialized = JSON.stringify(this.value);
|
||||
if (serialized === this.last_sent) return;
|
||||
this.last_sent = serialized;
|
||||
const snapshot = this.value;
|
||||
for (const listener of [...this.listeners]) {
|
||||
if (this.disposed || !this.listeners.has(listener)) continue;
|
||||
try {listener(structuredClone(snapshot));} catch { /* Observers own their failures. */ }
|
||||
}
|
||||
}, push_interval_ms);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
get_info() {
|
||||
if (!this.disposed && this.dirty) this.value = this.read();
|
||||
return structuredClone(this.value);
|
||||
}
|
||||
on_update(callback: (value: WorkflowStates) => void) {
|
||||
if (this.disposed) throw new Error('Workflow info has been disposed');
|
||||
if (!this.listeners.size) this.last_sent = JSON.stringify(this.get_info());
|
||||
this.listeners.add(callback);
|
||||
if (this.dirty) this.schedule();
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
if (!this.listeners.size && this.timer !== undefined) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
dispose() {
|
||||
if (this.disposed) return;
|
||||
if (this.dirty) this.value = this.read();
|
||||
this.disposed = true;
|
||||
if (this.timer !== undefined) clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
this.unsubscribe?.();
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseInfo, InfoI18n} from '../types.ts';
|
||||
|
||||
export interface WorldInfo {
|
||||
time?: {time: number; time_of_day: number; day: number; is_day: boolean};
|
||||
biome?: {id: number; name: string};
|
||||
dimension?: string;
|
||||
weather?: 'clear' | 'rain' | 'thunder';
|
||||
light?: {block: number; sky: number};
|
||||
}
|
||||
|
||||
export class init implements BaseInfo<WorldInfo> {
|
||||
readonly name = 'world';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN',
|
||||
name: '世界信息',
|
||||
description: '机器人所在世界的时间、群系、维度、天气和光照',
|
||||
fields: {
|
||||
time: {
|
||||
lang: 'zh-CN', name: '世界时间', description: '服务器同步的世界时间',
|
||||
fields: {
|
||||
time: {lang: 'zh-CN', name: '总时间', description: '世界累计昼夜时间,单位为游戏刻'},
|
||||
time_of_day: {lang: 'zh-CN', name: '日内时间', description: '一天内的时间,范围为 0 至 23999 游戏刻'},
|
||||
day: {lang: 'zh-CN', name: '天数', description: '世界时间对应的天数,从 0 开始'},
|
||||
is_day: {lang: 'zh-CN', name: '是否白天', description: '日内时间是否处于 0 至 12999 游戏刻'},
|
||||
},
|
||||
},
|
||||
biome: {
|
||||
lang: 'zh-CN', name: '当前群系', description: '机器人当前位置的生物群系',
|
||||
fields: {
|
||||
id: {lang: 'zh-CN', name: '群系 ID', description: '当前版本的群系数值标识'},
|
||||
name: {lang: 'zh-CN', name: '群系名称', description: '群系的英文标识'},
|
||||
},
|
||||
},
|
||||
dimension: {
|
||||
lang: 'zh-CN', name: '当前维度', description: '机器人所在的世界维度',
|
||||
values: {
|
||||
overworld: {lang: 'zh-CN', name: '主世界', description: '主世界维度'},
|
||||
the_nether: {lang: 'zh-CN', name: '地狱', description: '下界维度'},
|
||||
the_end: {lang: 'zh-CN', name: '末地', description: '末地维度'},
|
||||
},
|
||||
},
|
||||
weather: {
|
||||
lang: 'zh-CN', name: '当前天气', description: '世界天气状态,局部降水表现由群系决定',
|
||||
values: {
|
||||
clear: {lang: 'zh-CN', name: '晴天', description: '当前没有降水'},
|
||||
rain: {lang: 'zh-CN', name: '降水', description: '当前正在降水,寒冷群系表现为降雪'},
|
||||
thunder: {lang: 'zh-CN', name: '雷暴', description: '当前正在发生雷暴'},
|
||||
},
|
||||
},
|
||||
light: {
|
||||
lang: 'zh-CN', name: '当前位置光照', description: '当前位置的原始光照等级,不包含昼夜和天气衰减',
|
||||
fields: {
|
||||
block: {lang: 'zh-CN', name: '方块光照', description: '方块光源产生的光照等级,范围为 0 至 15'},
|
||||
sky: {lang: 'zh-CN', name: '天空光照', description: '天空光照等级,范围为 0 至 15'},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies InfoI18n;
|
||||
|
||||
private world: WorldInfo = {};
|
||||
private disposed = false;
|
||||
private readonly listeners = new Set<(data: WorldInfo) => void>();
|
||||
private readonly events = ['time', 'rain', 'game', 'spawn', 'respawn', 'move', 'forcedMove', 'physicsTick', 'chunkColumnLoad', 'chunkColumnUnload', 'blockUpdate'] as const;
|
||||
|
||||
constructor(private readonly instance: Bot) {
|
||||
this.handle_world();
|
||||
for (const event of this.events) instance.on(event, this.handle_world);
|
||||
}
|
||||
|
||||
private readonly handle_world = (): void => {
|
||||
if (this.disposed) return;
|
||||
|
||||
const bot = this.instance;
|
||||
const world: WorldInfo = {};
|
||||
if (typeof bot.time?.time === 'number') {
|
||||
world.time = {
|
||||
time: bot.time.time,
|
||||
time_of_day: bot.time.timeOfDay,
|
||||
day: bot.time.day,
|
||||
is_day: bot.time.isDay,
|
||||
};
|
||||
}
|
||||
if (typeof bot.game?.dimension === 'string') {
|
||||
world.dimension = bot.game.dimension.replace(/^minecraft:/, '');
|
||||
}
|
||||
if (typeof bot.isRaining === 'boolean') {
|
||||
// Nether and End have no weather, even if the server retains rain state.
|
||||
const no_weather = world.dimension === 'the_nether' || world.dimension === 'the_end';
|
||||
world.weather = no_weather || !bot.isRaining ? 'clear'
|
||||
: bot.thunderState > 0 ? 'thunder' : 'rain';
|
||||
}
|
||||
const pos = bot.entity?.position;
|
||||
const block = pos && bot.blockAt(pos, false);
|
||||
if (block) {
|
||||
if (block.biome) {
|
||||
world.biome = {
|
||||
id: block.biome.id,
|
||||
name: block.biome.name,
|
||||
};
|
||||
}
|
||||
world.light = {block: block.light, sky: block.skyLight};
|
||||
}
|
||||
|
||||
if (JSON.stringify(this.world) === JSON.stringify(world)) return;
|
||||
this.world = world;
|
||||
for (const callback of Array.from(this.listeners)) callback(world);
|
||||
};
|
||||
|
||||
get_info(): WorldInfo {
|
||||
return this.world;
|
||||
}
|
||||
|
||||
on_update(callback: (data: WorldInfo) => void): () => void {
|
||||
if (this.disposed) throw new Error('WorldInfo 已销毁');
|
||||
this.listeners.add(callback);
|
||||
return () => { this.listeners.delete(callback); };
|
||||
}
|
||||
|
||||
readonly dispose = (): void => {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
for (const event of this.events) this.instance.off(event, this.handle_world);
|
||||
this.listeners.clear();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import {readdir} from 'node:fs/promises';
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseInfo, InfoConstructor, InfoContext, InfoI18n} from './types.ts';
|
||||
|
||||
export interface InfoSnapshot {
|
||||
readonly name: string;
|
||||
readonly i18n: InfoI18n;
|
||||
readonly value: unknown;
|
||||
}
|
||||
|
||||
export interface InfoUpdate {
|
||||
readonly name: string;
|
||||
readonly value: unknown;
|
||||
}
|
||||
|
||||
export interface InfoManager {
|
||||
get_info(name: string): InfoSnapshot | undefined;
|
||||
get_all_info(): InfoSnapshot[];
|
||||
/** Subscribes to future changes; returns an idempotent unsubscribe function. */
|
||||
on_update(callback: (event: InfoUpdate) => void): () => void;
|
||||
/** Safe to call repeatedly, including before the bot emits end. */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/** Scans bot/ and instance/ recursively and creates fresh modules for this bot. */
|
||||
export async function create_info_manager(bot: Bot, options?: InfoContext): Promise<InfoManager> {
|
||||
const infos = new Map<string, BaseInfo<unknown>>();
|
||||
const subscriptions = new Set<() => void>();
|
||||
const listeners = new Set<(event: InfoUpdate) => void>();
|
||||
let disposed = false;
|
||||
|
||||
const assert_active = () => {
|
||||
if (disposed) throw new Error('Info manager has been disposed');
|
||||
};
|
||||
const cleanup = (callback: () => void) => {
|
||||
try { callback(); }
|
||||
catch (error) { console.error('Info manager cleanup failed', error); }
|
||||
};
|
||||
const snapshot = (info: BaseInfo<unknown>): InfoSnapshot => ({
|
||||
name: info.name,
|
||||
i18n: {...info.i18n},
|
||||
value: structuredClone(info.get_info()),
|
||||
});
|
||||
const manager: InfoManager = {
|
||||
get_info(name) {
|
||||
const info = infos.get(name);
|
||||
return info ? snapshot(info) : undefined;
|
||||
},
|
||||
get_all_info() {
|
||||
return Array.from(infos.values(), snapshot);
|
||||
},
|
||||
on_update(callback) {
|
||||
assert_active();
|
||||
// Each subscription owns its cancellation, even for the same callback.
|
||||
const listener = (event: InfoUpdate) => callback(event);
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
bot.off('end', manager.dispose);
|
||||
listeners.clear();
|
||||
for (const unsubscribe of subscriptions) cleanup(unsubscribe);
|
||||
subscriptions.clear();
|
||||
for (const info of infos.values()) cleanup(() => info.dispose());
|
||||
infos.clear();
|
||||
},
|
||||
};
|
||||
|
||||
// Install before the first await so an end during loading also cancels creation.
|
||||
bot.once('end', manager.dispose);
|
||||
try {
|
||||
const directories = [new URL('./bot/', import.meta.url), new URL('./instance/', import.meta.url)];
|
||||
const modules: URL[] = [];
|
||||
for (const directory of directories) {
|
||||
const entries = await readdir(directory, {withFileTypes: true});
|
||||
assert_active();
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && /^[a-zA-Z0-9_-]+$/.test(entry.name)) {
|
||||
directories.push(new URL(`${entry.name}/`, directory));
|
||||
} else if (entry.isFile() && /^[a-zA-Z0-9_-]+\.ts$/.test(entry.name)) {
|
||||
modules.push(new URL(entry.name, directory));
|
||||
}
|
||||
}
|
||||
}
|
||||
modules.sort((a, b) => a.href.localeCompare(b.href));
|
||||
for (const url of modules) {
|
||||
let info: BaseInfo<unknown> | undefined;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
try {
|
||||
assert_active();
|
||||
url.searchParams.set('t', `${Date.now()}-${process.hrtime.bigint()}`);
|
||||
const module = await import(url.href) as {init?: unknown};
|
||||
assert_active();
|
||||
if (typeof module.init !== 'function') {
|
||||
throw new Error('Info module must export an init constructor');
|
||||
}
|
||||
const candidate = new (module.init as InfoConstructor<unknown>)(bot, options);
|
||||
if (!candidate || typeof candidate.dispose !== 'function') {
|
||||
throw new Error('Info module must implement dispose()');
|
||||
}
|
||||
// Keep ownership locally until validation and subscription both succeed.
|
||||
info = candidate;
|
||||
assert_active();
|
||||
if (typeof info.name !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(info.name)
|
||||
|| !info.i18n || typeof info.i18n.lang !== 'string'
|
||||
|| typeof info.i18n.name !== 'string' || typeof info.i18n.description !== 'string'
|
||||
|| typeof info.get_info !== 'function' || typeof info.on_update !== 'function') {
|
||||
throw new Error('Info module does not implement BaseInfo');
|
||||
}
|
||||
const name = info.name;
|
||||
if (infos.has(name)) throw new Error(`Duplicate info name: ${name}`);
|
||||
const cancel = info.on_update(value => {
|
||||
if (disposed) return;
|
||||
for (const listener of Array.from(listeners)) {
|
||||
if (disposed) break;
|
||||
if (!listeners.has(listener)) continue;
|
||||
try { listener({name, value: structuredClone(value)}); }
|
||||
catch (error) { console.error(`Info update listener failed: ${name}`, error); }
|
||||
}
|
||||
});
|
||||
if (typeof cancel !== 'function') {
|
||||
throw new Error('Info on_update() must return an unsubscribe function');
|
||||
}
|
||||
unsubscribe = cancel;
|
||||
assert_active();
|
||||
infos.set(name, info);
|
||||
subscriptions.add(unsubscribe);
|
||||
} catch (error) {
|
||||
if (unsubscribe) cleanup(unsubscribe);
|
||||
if (info) cleanup(() => info!.dispose());
|
||||
throw new Error(`Info module ${url.pathname} initialization failed`, {cause: error});
|
||||
}
|
||||
}
|
||||
assert_active();
|
||||
return manager;
|
||||
} catch (error) {
|
||||
manager.dispose();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {performance} from 'node:perf_hooks';
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import {instrument_bot, metadata, ResourceInfo} from '../resource_metrics.ts';
|
||||
|
||||
export interface CpuInfo {
|
||||
scope: 'bot_context';
|
||||
estimated: true;
|
||||
method: 'instrumented_sync_time';
|
||||
usage_percent: number | null;
|
||||
sampled_time_ms: number;
|
||||
interval_ms: number;
|
||||
coverage: string;
|
||||
}
|
||||
|
||||
const coverage = '计量 Bot.emit 和初始 _client.emit 监听器的同步执行(包括数据包监听器和 physicsTick),'
|
||||
+ '以及函数 run 的同步调用阶段;嵌套调用去重。不包含 emit 前的协议解析、异步等待及后续执行、'
|
||||
+ '无关定时器、上述入口之外的插件执行和 BotContext 通知。'
|
||||
+ '同步经过时间包含垃圾回收和操作系统调度暂停,因此该指标不是进程 CPU 使用率。';
|
||||
|
||||
export class init extends ResourceInfo<CpuInfo> {
|
||||
readonly name = 'used_cpu';
|
||||
readonly i18n = metadata('上下文 CPU 占用估算', coverage, {
|
||||
scope: ['归属范围', '指标归属于机器人上下文,固定为 bot_context'],
|
||||
estimated: ['是否为估算', '固定为 true,表示该指标始终是估算值'],
|
||||
method: ['计量方法', '通过插桩计量同步经过时间,不使用操作系统 CPU 计数器'],
|
||||
usage_percent: ['占用比例', '采样同步耗时除以实际经过时间再乘以 100,单位为百分比;首次采样前为 null'],
|
||||
sampled_time_ms: ['采样同步耗时', '本次采样间隔内去重后的同步执行耗时,单位为毫秒'],
|
||||
interval_ms: ['采样间隔', '通过单调时钟测得的实际经过时间,单位为毫秒'],
|
||||
coverage: ['计量覆盖范围', '同步执行计量包含的入口及未覆盖的执行阶段'],
|
||||
});
|
||||
private readonly meter: ReturnType<typeof instrument_bot>;
|
||||
constructor(bot: Bot) {
|
||||
super({scope: 'bot_context', estimated: true, method: 'instrumented_sync_time',
|
||||
usage_percent: null, sampled_time_ms: 0, interval_ms: 0, coverage});
|
||||
this.meter = instrument_bot(bot);
|
||||
let time = performance.now();
|
||||
let total = this.meter.total();
|
||||
this.start(1000, () => {
|
||||
const now = performance.now();
|
||||
const next = this.meter.total();
|
||||
const elapsed = now - time;
|
||||
const sampled = Math.max(0, next - total);
|
||||
time = now;
|
||||
total = next;
|
||||
this.publish({...this.info, usage_percent: elapsed > 0 ? sampled / elapsed * 100 : null,
|
||||
sampled_time_ms: sampled, interval_ms: elapsed});
|
||||
});
|
||||
}
|
||||
override dispose(): void {
|
||||
super.dispose();
|
||||
this.meter.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import {setImmediate as yield_batch} from 'node:timers/promises';
|
||||
import {performance} from 'node:perf_hooks';
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {InfoI18n} from '../types.ts';
|
||||
import {metadata, ResourceInfo} from '../resource_metrics.ts';
|
||||
|
||||
export interface MemoryInfo {
|
||||
estimated: true;
|
||||
total_bytes: number;
|
||||
chunks_bytes: number;
|
||||
entities_bytes: number;
|
||||
inventory_bytes: number;
|
||||
other_bytes: number;
|
||||
partial: boolean;
|
||||
categories_complete: Record<Category, boolean>;
|
||||
}
|
||||
|
||||
type Category = 'chunks' | 'entities' | 'inventory' | 'other';
|
||||
const excluded = new Set(['registry', 'world', '_client', 'client', 'bot', 'global', 'globalThis',
|
||||
'_events', '_eventsCount', '_maxListeners', '__proto__', 'constructor', 'prototype']);
|
||||
const own = (object: unknown, key: PropertyKey): unknown => object && typeof object === 'object'
|
||||
? Object.getOwnPropertyDescriptor(object, key)?.value : undefined;
|
||||
const typed_buffer = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(Uint8Array.prototype), 'buffer')!.get!;
|
||||
const data_buffer = Object.getOwnPropertyDescriptor(DataView.prototype, 'buffer')!.get!;
|
||||
const array_length = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength')!.get!;
|
||||
const shared_length = Object.getOwnPropertyDescriptor(SharedArrayBuffer.prototype, 'byteLength')!.get!;
|
||||
|
||||
/** Bounded live-data estimate. Objects/slots use approximate JS overhead; backing stores use real byteLength. */
|
||||
export async function estimate_memory(bot: Bot, cancelled = () => false,
|
||||
limits = {nodes: 50000, properties: 200000, milliseconds: 200}): Promise<MemoryInfo> {
|
||||
const info: MemoryInfo = {estimated: true, total_bytes: 0, chunks_bytes: 0, entities_bytes: 0,
|
||||
inventory_bytes: 0, other_bytes: 0, partial: true,
|
||||
categories_complete: {chunks: false, entities: false, inventory: false, other: false}};
|
||||
const start = performance.now();
|
||||
const seen = new WeakSet<object>();
|
||||
const blocked = new WeakSet<object>();
|
||||
for (const key of ['registry', 'world', '_client']) {
|
||||
const value = own(bot, key);
|
||||
if (value && typeof value === 'object') blocked.add(value);
|
||||
}
|
||||
blocked.add(bot);
|
||||
blocked.add(globalThis);
|
||||
type Task = {value: unknown} | {object: object; keys: PropertyKey[]; index: number; columns?: boolean}
|
||||
| {load_columns: true};
|
||||
const categories: Category[] = ['chunks', 'entities', 'inventory', 'other'];
|
||||
const roots = {entities: ['entity', 'entities', 'players'], inventory: ['inventory', 'currentWindow'],
|
||||
other: ['game', 'time', 'settings', 'scoreboards', 'teams', 'tablist']};
|
||||
// Fixed category order and DFS order make first-visit ownership deterministic, including backing stores.
|
||||
const states = categories.map((category, index) => ({category,
|
||||
stack: (category === 'chunks' ? [{load_columns: true}]
|
||||
: [{object: bot, keys: roots[category], index: 0}]) as Task[],
|
||||
nodes: 0, properties: 0, elapsed: 0, incomplete: false,
|
||||
node_limit: Math.floor(limits.nodes / 4) + (index < limits.nodes % 4 ? 1 : 0),
|
||||
property_limit: Math.floor(limits.properties / 4) + (index < limits.properties % 4 ? 1 : 0),
|
||||
}));
|
||||
let steps = 0;
|
||||
let processed = 0;
|
||||
while (states.some(state => state.stack.length)) {
|
||||
if (cancelled() || performance.now() - start >= limits.milliseconds) break;
|
||||
const state = states[steps++ % states.length];
|
||||
if (!state.stack.length) continue;
|
||||
if (state.elapsed >= limits.milliseconds / 4) {
|
||||
state.incomplete = true;
|
||||
state.stack.length = 0;
|
||||
continue;
|
||||
}
|
||||
const turn = performance.now();
|
||||
const {stack, category} = state;
|
||||
const add = (bytes: number) => { info[`${category}_bytes`] += bytes; };
|
||||
const cap = () => { state.incomplete = true; stack.length = 0; };
|
||||
const task = stack.pop()!;
|
||||
try {
|
||||
if ('load_columns' in task) {
|
||||
// WorldSync exposes getColumns on its prototype; resolve descriptors without invoking getters.
|
||||
const world = own(bot, 'world');
|
||||
const world_descriptor = Object.getOwnPropertyDescriptor(bot, 'world');
|
||||
if (world_descriptor && !('value' in world_descriptor)) state.incomplete = true;
|
||||
let target = world;
|
||||
let descriptor: PropertyDescriptor | undefined;
|
||||
for (let depth = 0; target && depth < 16; depth++, target = Object.getPrototypeOf(target)) {
|
||||
descriptor = Object.getOwnPropertyDescriptor(target, 'getColumns');
|
||||
if (descriptor) break;
|
||||
}
|
||||
if (world && typeof descriptor?.value !== 'function') state.incomplete = true;
|
||||
else if (world) {
|
||||
const columns = Reflect.apply(descriptor!.value, world, []);
|
||||
const length = own(columns, 'length');
|
||||
if (!Array.isArray(columns) || typeof length !== 'number') state.incomplete = true;
|
||||
else {
|
||||
if (length > 4096) state.incomplete = true;
|
||||
// Extraction is incremental and charged to the chunks property budget.
|
||||
stack.push({object: columns,
|
||||
keys: Array.from({length: Math.min(length, 4096)}, (_, i) => String(i)),
|
||||
index: 0, columns: true});
|
||||
}
|
||||
}
|
||||
} else if ('object' in task) {
|
||||
if (task.index < task.keys.length) {
|
||||
if (state.properties >= state.property_limit) cap();
|
||||
else {
|
||||
state.properties++;
|
||||
const key = task.keys[task.index++];
|
||||
if (task.index < task.keys.length) stack.push(task);
|
||||
if (typeof key !== 'string' || !excluded.has(key)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(task.object, key);
|
||||
if (descriptor && !('value' in descriptor)) state.incomplete = true;
|
||||
else if (descriptor) {
|
||||
if (task.columns) {
|
||||
const column = own(descriptor.value, 'column');
|
||||
if (column && typeof column === 'object') stack.push({value: column});
|
||||
else state.incomplete = true;
|
||||
} else {
|
||||
if (task.object !== bot) add(8 + (typeof key === 'string' ? key.length * 2 : 0));
|
||||
if (typeof descriptor.value !== 'function') stack.push({value: descriptor.value});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const {value} = task;
|
||||
if (typeof value === 'string') add(value.length * 2);
|
||||
else if (typeof value === 'number' || typeof value === 'bigint') add(8);
|
||||
else if (typeof value === 'boolean') add(4);
|
||||
else if (value && typeof value === 'object' && !seen.has(value) && !blocked.has(value)) {
|
||||
if (state.nodes >= state.node_limit) { cap(); continue; }
|
||||
seen.add(value);
|
||||
state.nodes++;
|
||||
add(32);
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
// Includes a pooled Buffer's full backing allocation once, even for overlapping views.
|
||||
stack.push({value: Reflect.apply(value instanceof DataView ? data_buffer : typed_buffer, value, [])});
|
||||
} else if (value instanceof ArrayBuffer || value instanceof SharedArrayBuffer) {
|
||||
add(Reflect.apply(value instanceof ArrayBuffer ? array_length : shared_length, value, []));
|
||||
} else {
|
||||
// Never read property values directly: accessors and prototypes are deliberately omitted.
|
||||
const keys = Reflect.ownKeys(value);
|
||||
const remaining = Math.max(0, state.property_limit - state.properties);
|
||||
if (keys.length > remaining) { keys.length = remaining; state.incomplete = true; }
|
||||
if (keys.length) stack.push({object: value, keys, index: 0});
|
||||
if (value instanceof Map || value instanceof Set) state.incomplete = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { state.incomplete = true; }
|
||||
finally { state.elapsed += performance.now() - turn; }
|
||||
if (++processed % 256 === 0) await yield_batch();
|
||||
}
|
||||
for (const state of states) info.categories_complete[state.category] = !state.incomplete && !state.stack.length;
|
||||
info.partial = Object.values(info.categories_complete).some(complete => !complete);
|
||||
info.total_bytes = info.chunks_bytes + info.entities_bytes + info.inventory_bytes + info.other_bytes;
|
||||
return info;
|
||||
}
|
||||
|
||||
export class init extends ResourceInfo<MemoryInfo> {
|
||||
readonly name = 'used_memory';
|
||||
readonly i18n: InfoI18n = {...metadata('自有数据内存估算', '仅通过自身属性描述符分批估算当前数据,不代表堆内存或进程常驻内存。'
|
||||
+ '扫描根为已加载的区块列对象(最多 4096 个)、entity/entities/players 和 inventory/currentWindow。'
|
||||
+ '其他分类仅扫描本实例的 game、time、settings、scoreboards、teams、tablist 自有数据根。'
|
||||
+ '排除注册表、世界对象遍历、客户端、全局对象、监听器、函数和原型;不调用属性 getter。'
|
||||
+ '按区块、实体、物品栏、其他的固定顺序逐步轮转,共享对象及底层缓冲区归首次访问分类且只计一次,池化缓冲区按完整底层分配大小计入。'
|
||||
+ '对象和字符串开销为近似值,不统计 Map/Set 内部数据。上限为 50000 个节点、200000 个属性和 200 毫秒,'
|
||||
+ '节点、属性和同步处理时间各分类至多使用四分之一,总时限包含让出事件循环的时间。'
|
||||
+ '每轮只处理一个值或属性,每 256 步让出事件循环;getColumns 和 ownKeys 枚举本身仍同步执行,单次调用可能超时。'
|
||||
+ '完成状态仅针对上述有限范围;未完成分类的零值不表示没有内存占用。', {}), fields: {
|
||||
...metadata('', '', {
|
||||
estimated: ['是否为估算', '固定为 true,表示该指标始终是估算值'],
|
||||
total_bytes: ['估算总内存', '各分类已扫描部分内存估算值之和,单位为字节;partial 为 true 时不是完整总量'],
|
||||
chunks_bytes: ['区块内存', '已加载区块列自身数据的内存估算,单位为字节'],
|
||||
entities_bytes: ['实体内存', '实体和玩家自身数据的内存估算,单位为字节'],
|
||||
inventory_bytes: ['物品栏内存', '物品栏和当前窗口自身数据的内存估算,单位为字节'],
|
||||
other_bytes: ['其他内存', '仅 game、time、settings、scoreboards、teams、tablist 自有数据的内存估算,单位为字节'],
|
||||
partial: ['结果是否不完整', '首次采样尚未完成,或因扫描上限、取消、不支持或无法访问而遗漏了数据'],
|
||||
}).fields,
|
||||
categories_complete: metadata('分类完成状态', '各分类是否完成约定范围的扫描;初始均为 false,false 时字节数仅为已扫描部分,零值不能说明实际无占用。', {
|
||||
chunks: ['区块扫描完成', '已扫描约定的全部区块列且未触及上限或遗漏数据时为 true'],
|
||||
entities: ['实体扫描完成', '已扫描 entity、entities、players 且未触及上限或遗漏数据时为 true'],
|
||||
inventory: ['物品栏扫描完成', '已扫描 inventory、currentWindow 且未触及上限或遗漏数据时为 true'],
|
||||
other: ['其他扫描完成', '已扫描 game、time、settings、scoreboards、teams、tablist 且未触及上限或遗漏数据时为 true'],
|
||||
}),
|
||||
}};
|
||||
constructor(bot: Bot) {
|
||||
super({estimated: true, total_bytes: 0, chunks_bytes: 0, entities_bytes: 0,
|
||||
inventory_bytes: 0, other_bytes: 0, partial: true,
|
||||
categories_complete: {chunks: false, entities: false, inventory: false, other: false}});
|
||||
this.start(5000, async () => { this.publish(await estimate_memory(bot, () => this.disposed)); }, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {performance} from 'node:perf_hooks';
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import {metadata, ResourceInfo} from '../resource_metrics.ts';
|
||||
|
||||
export interface NetworkInfo {
|
||||
available: boolean;
|
||||
received_bytes: number;
|
||||
sent_bytes: number;
|
||||
received_bytes_per_second: number | null;
|
||||
sent_bytes_per_second: number | null;
|
||||
interval_ms: number;
|
||||
}
|
||||
|
||||
/** Totals include the first observed counters of every new socket. Unobserved old-socket tails are unknowable. */
|
||||
export class NetworkCounter {
|
||||
private socket: object | undefined;
|
||||
private read = 0;
|
||||
private written = 0;
|
||||
private time: number | undefined;
|
||||
private received = 0;
|
||||
private sent = 0;
|
||||
sample(socket: {bytesRead?: number; bytesWritten?: number} | undefined, now: number): NetworkInfo {
|
||||
const read = socket?.bytesRead;
|
||||
const written = socket?.bytesWritten;
|
||||
const available = typeof read === 'number' && Number.isFinite(read) && read >= 0
|
||||
&& typeof written === 'number' && Number.isFinite(written) && written >= 0;
|
||||
const elapsed = this.time === undefined ? 0 : now - this.time;
|
||||
const continuous = available && socket === this.socket && this.time !== undefined
|
||||
&& read >= this.read && written >= this.written && elapsed > 0;
|
||||
const dr = available ? (socket === this.socket && read >= this.read ? read - this.read : read) : 0;
|
||||
const dw = available ? (socket === this.socket && written >= this.written ? written - this.written : written) : 0;
|
||||
this.received += dr;
|
||||
this.sent += dw;
|
||||
if (available) {
|
||||
this.socket = socket;
|
||||
this.read = read;
|
||||
this.written = written;
|
||||
}
|
||||
this.time = available ? now : undefined;
|
||||
return {available, received_bytes: this.received, sent_bytes: this.sent,
|
||||
received_bytes_per_second: continuous ? dr * 1000 / elapsed : null,
|
||||
sent_bytes_per_second: continuous ? dw * 1000 / elapsed : null, interval_ms: continuous ? elapsed : 0};
|
||||
}
|
||||
}
|
||||
|
||||
export class init extends ResourceInfo<NetworkInfo> {
|
||||
readonly name = 'used_network';
|
||||
readonly i18n = metadata('机器人网络流量', '读取传输层套接字的字节计数,包含协议开销;'
|
||||
+ '更换套接字后保留累计观察量,不包含旧套接字被替换前未观察到的尾部流量。', {
|
||||
available: ['计数是否可用', '套接字是否同时提供有效的接收和发送字节计数'],
|
||||
received_bytes: ['累计接收量', '累计观察到的接收流量,单位为字节'],
|
||||
sent_bytes: ['累计发送量', '累计观察到的发送流量,单位为字节'],
|
||||
received_bytes_per_second: ['接收速率', '接收流量速率,单位为字节每秒;缺少连续采样基线时为 null'],
|
||||
sent_bytes_per_second: ['发送速率', '发送流量速率,单位为字节每秒;缺少连续采样基线时为 null'],
|
||||
interval_ms: ['速率采样间隔', '通过单调时钟测得的实际速率采样间隔,单位为毫秒;缺少连续采样基线时为 0'],
|
||||
});
|
||||
constructor(bot: Bot) {
|
||||
const counter = new NetworkCounter();
|
||||
super(counter.sample(bot._client?.socket, performance.now()));
|
||||
this.start(1000, () => { this.publish(counter.sample(bot._client?.socket, performance.now())); });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {lstat, opendir} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {performance} from 'node:perf_hooks';
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import {get_project_root_path} from '../../../utils/path_utils.ts';
|
||||
import type {InfoContext} from '../types.ts';
|
||||
import {metadata, ResourceInfo} from '../resource_metrics.ts';
|
||||
|
||||
export interface StorageInfo {
|
||||
available: boolean;
|
||||
total_bytes: number | null;
|
||||
file_count: number;
|
||||
partial: boolean;
|
||||
}
|
||||
|
||||
/** lstat and streamed directory enumeration never intentionally follow symlinks. Live mutation can race a scan. */
|
||||
export async function scan_storage(directory: string, cancelled = () => false,
|
||||
limits = {entries: 20000, depth: 32, milliseconds: 2000}): Promise<StorageInfo> {
|
||||
const info: StorageInfo = {available: false, total_bytes: null, file_count: 0, partial: false};
|
||||
const start = performance.now();
|
||||
let entries = 0;
|
||||
const pending: [string, number][] = [[directory, 0]];
|
||||
while (pending.length) {
|
||||
if (cancelled() || entries >= limits.entries || performance.now() - start >= limits.milliseconds) {
|
||||
info.partial = true; break;
|
||||
}
|
||||
const [current, depth] = pending.pop()!;
|
||||
entries++;
|
||||
try {
|
||||
const stat = await lstat(current);
|
||||
if (current === directory) {
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) { info.partial = true; break; }
|
||||
info.available = true;
|
||||
info.total_bytes = 0;
|
||||
}
|
||||
if (stat.isSymbolicLink()) continue;
|
||||
if (stat.isFile()) {
|
||||
info.total_bytes! += stat.size;
|
||||
info.file_count++;
|
||||
} else if (stat.isDirectory()) {
|
||||
if (depth >= limits.depth) { info.partial = true; continue; }
|
||||
const dir = await opendir(current);
|
||||
for await (const entry of dir) {
|
||||
if (cancelled() || entries + pending.length >= limits.entries
|
||||
|| performance.now() - start >= limits.milliseconds) { info.partial = true; break; }
|
||||
if (!entry.isSymbolicLink()) pending.push([path.join(current, entry.name), depth + 1]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (current === directory && (error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
info.available = true;
|
||||
info.total_bytes = 0;
|
||||
} else info.partial = true;
|
||||
}
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
export class init extends ResourceInfo<StorageInfo> {
|
||||
readonly name = 'used_storage';
|
||||
readonly i18n = metadata('机器人数据存储', '统计 data/bot/<bot_id> 下文件的逻辑大小,跳过符号链接。'
|
||||
+ '每 30 秒尽力扫描一次,不重叠执行;上限为 20000 个条目、32 层深度和 2 秒。'
|
||||
+ '扫描期间的并发文件变动可能影响结果;该指标不代表实际分配的磁盘空间。', {
|
||||
available: ['存储统计是否可用', '目录可访问或不存在时为 true,不存在按 0 统计;缺少有效机器人编号或根目录不可访问时为 false'],
|
||||
total_bytes: ['文件总大小', '已观察到的普通文件逻辑大小之和,单位为字节;不可用时为 null'],
|
||||
file_count: ['文件数量', '已观察到的普通文件数量'],
|
||||
partial: ['结果是否不完整', '因扫描上限、取消或错误而未完成扫描;保留已读取文件的统计小计'],
|
||||
});
|
||||
constructor(_bot: Bot, options?: InfoContext) {
|
||||
super({available: false, total_bytes: null, file_count: 0, partial: true});
|
||||
if (!Number.isSafeInteger(options?.bot_id) || options!.bot_id! <= 0) return;
|
||||
const directory = path.join(get_project_root_path(), 'data', 'bot', String(options!.bot_id));
|
||||
this.start(30000, async () => { this.publish(await scan_storage(directory, () => this.disposed)); }, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import {performance} from 'node:perf_hooks';
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseInfo, InfoI18n} from './types.ts';
|
||||
|
||||
type Meter = {depth: number; total: number};
|
||||
const meters = new WeakMap<object, Meter>();
|
||||
|
||||
/** Only the synchronous invocation is timed, even when it returns a Promise. */
|
||||
export function measure_sync<T>(bot: object, invoke: () => T): T {
|
||||
const meter = meters.get(bot);
|
||||
if (!meter) return invoke();
|
||||
const outer = meter.depth++ === 0;
|
||||
const start = outer ? performance.now() : 0;
|
||||
try { return invoke(); }
|
||||
finally {
|
||||
meter.depth--;
|
||||
if (outer) meter.total += performance.now() - start;
|
||||
}
|
||||
}
|
||||
|
||||
export function instrument_bot(bot: Bot): {total: () => number; dispose: () => void} {
|
||||
if (meters.has(bot)) throw new Error('Bot resource meter already installed');
|
||||
const meter: Meter = {depth: 0, total: 0};
|
||||
meters.set(bot, meter);
|
||||
const restore: (() => void)[] = [];
|
||||
try {
|
||||
for (const target of new Set([bot, bot._client])) {
|
||||
if (!target || typeof target.emit !== 'function') continue;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(target, 'emit');
|
||||
const original = target.emit;
|
||||
const wrapper = function(this: typeof target, ...args: Parameters<typeof original>) {
|
||||
return measure_sync(bot, () => Reflect.apply(original, this, args));
|
||||
};
|
||||
Object.defineProperty(target, 'emit', {configurable: true, writable: true,
|
||||
enumerable: descriptor?.enumerable ?? false, value: wrapper});
|
||||
restore.push(() => {
|
||||
if (Object.getOwnPropertyDescriptor(target, 'emit')?.value !== wrapper) return;
|
||||
if (descriptor) Object.defineProperty(target, 'emit', descriptor);
|
||||
else delete (target as unknown as {emit?: unknown}).emit;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
for (const undo of restore.reverse()) undo();
|
||||
meters.delete(bot);
|
||||
throw error;
|
||||
}
|
||||
let disposed = false;
|
||||
return {total: () => meter.total, dispose: () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
meters.delete(bot);
|
||||
for (const undo of restore.reverse()) undo();
|
||||
}};
|
||||
}
|
||||
|
||||
export function metadata(name: string, description: string, fields: Record<string, readonly [string, string]>): InfoI18n {
|
||||
return {lang: 'zh-CN', name, description, fields: Object.fromEntries(Object.entries(fields).map(
|
||||
([key, [name, description]]) => [key, {lang: 'zh-CN', name, description}],
|
||||
))};
|
||||
}
|
||||
|
||||
/** No module owns bot end listeners; InfoManager owns disposal. */
|
||||
export abstract class ResourceInfo<T> implements BaseInfo<T> {
|
||||
abstract readonly name: string;
|
||||
abstract readonly i18n: InfoI18n;
|
||||
protected disposed = false;
|
||||
private timer?: ReturnType<typeof setInterval>;
|
||||
private running = false;
|
||||
private readonly listeners = new Set<(value: T) => void>();
|
||||
constructor(protected info: T) {}
|
||||
get_info(): T { return this.info; }
|
||||
on_update(callback: (value: T) => void): () => void {
|
||||
if (this.disposed) throw new Error('Resource info disposed');
|
||||
this.listeners.add(callback);
|
||||
return () => { this.listeners.delete(callback); };
|
||||
}
|
||||
protected publish(info: T): void {
|
||||
if (this.disposed) return;
|
||||
this.info = info;
|
||||
for (const listener of Array.from(this.listeners)) {
|
||||
if (this.disposed) break;
|
||||
try { listener(info); }
|
||||
catch (error) { console.error('Resource info listener failed', error); }
|
||||
}
|
||||
}
|
||||
protected start(interval: number, sample: () => void | Promise<void>, immediate = false): void {
|
||||
const run = async () => {
|
||||
if (this.running || this.disposed) return;
|
||||
this.running = true;
|
||||
try { await sample(); }
|
||||
catch (error) { if (!this.disposed) console.error('Resource sampling failed', error); }
|
||||
finally { this.running = false; }
|
||||
};
|
||||
this.timer = setInterval(() => { void run(); }, interval);
|
||||
this.timer.unref?.();
|
||||
if (immediate) void run();
|
||||
}
|
||||
dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
clearInterval(this.timer);
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {PluginSnapshot} from '../plugins/init.ts';
|
||||
import type {RunSnapshot, WorkflowRunEvent} from '../../types/bot/workflow/runtime.ts';
|
||||
|
||||
export interface InfoI18n {
|
||||
readonly lang: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly fields?: Readonly<Record<string, InfoI18n>>;
|
||||
readonly values?: Readonly<Record<string, InfoI18n>>;
|
||||
}
|
||||
|
||||
/** 一个信息对象绑定一个 Bot 实例;由信息管理器统一监听 end 并销毁,单独使用时由调用方负责销毁。 */
|
||||
export interface BaseInfo<T> {
|
||||
/** 稳定的模块标识,不使用翻译后的显示名称。 */
|
||||
readonly name: string;
|
||||
readonly i18n: InfoI18n;
|
||||
|
||||
/** 同步读取当前值;销毁后仍可读取最后保存的值。 */
|
||||
get_info(): T;
|
||||
|
||||
/**
|
||||
* 订阅后续变化,不立即回调;初始值通过 get_info() 获取。
|
||||
* 返回的取消函数可重复调用,销毁后禁止新增订阅并抛出错误。
|
||||
* 回调同步执行,订阅者应自行处理异常。
|
||||
*/
|
||||
on_update(callback: (data: T) => void): () => void;
|
||||
|
||||
/** 移除 Bot 事件监听和全部订阅;可重复调用,销毁后不再发布更新。 */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/** 构造签名单独约束类本身,不属于 BaseInfo 的实例成员。 */
|
||||
export interface InfoConstructor<T> {
|
||||
new (instance: Bot, options?: InfoContext): BaseInfo<T>;
|
||||
}
|
||||
|
||||
export interface InfoContext {
|
||||
readonly bot_id?: number;
|
||||
readonly plugin_source?: {
|
||||
get_all_plugins(): PluginSnapshot[];
|
||||
on_update(callback: (event: PluginSnapshot) => void): () => void;
|
||||
};
|
||||
readonly workflow_source?: {
|
||||
get_snapshots(): RunSnapshot[];
|
||||
get_runtime_snapshot?(): import('../../types/bot/workflow/runtime.ts').WorkflowRuntimeSnapshot;
|
||||
on_update(callback: (event: WorkflowRunEvent) => void): () => void;
|
||||
};
|
||||
}
|
||||
Executable
+626
@@ -0,0 +1,626 @@
|
||||
import { createBot } from "@wp2508/mineflayer";
|
||||
import type { Bot, BotEvents } from "@wp2508/mineflayer";
|
||||
import {bot_instance_config, bot_status} from "../../types/bot/instance/bot_runtime.ts";
|
||||
import {
|
||||
validate_host,
|
||||
validate_port,
|
||||
validate_proxy,
|
||||
validate_username,
|
||||
validate_version
|
||||
} from "../../utils/validate.ts";
|
||||
import {type bot_operation_code, type validate_code, validate_code_map} from "../../utils/validate_code_map.ts";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { Mutex } from "async-mutex";
|
||||
import {Logger} from "../../utils/logger.ts";
|
||||
import {get_project_root_path} from "../../utils/path_utils.ts";
|
||||
import * as path from 'path';
|
||||
import {log} from "../../types/utils/logger.ts";
|
||||
import {MinecraftJsonParser} from "../../utils/message_json_parser.ts";
|
||||
import {remember_command_tree} from '../../utils/command_completion.ts';
|
||||
import {create_info_manager, type InfoManager, type InfoUpdate} from '../info/init.ts';
|
||||
import {create_function_manager, type FunctionManager} from '../function/init.ts';
|
||||
import {PluginManager} from '../plugins/init.ts';
|
||||
import type {WorkflowManager, WorkflowRunEvent} from '../../types/bot/workflow/runtime.ts';
|
||||
|
||||
// 深度清理的最大递归深度与最大节点数,避免遍历无关的深层对象图
|
||||
const CLEANUP_MAX_DEPTH = 8;
|
||||
const CLEANUP_MAX_VISITED = 2000;
|
||||
// 共享 / 全局对象,禁止递归进入,避免误清理缓存或全局状态
|
||||
const CLEANUP_SKIP_KEYS = new Set(['registry', 'supportFeature', 'constructor']);
|
||||
// 需要显式清理的定时器与待发队列字段名
|
||||
const CLEANUP_TIMER_KEYS = ['closeTimer', '_keepAliveTimer', 'keepAliveTimer', 'doPhysicsTimer'];
|
||||
const CLEANUP_QUEUE_KEYS = ['_writeQueue', '_priorityWriteQueue', '_mcBundle'];
|
||||
// 全局对象,绝不能被遍历清理
|
||||
const CLEANUP_PROTECTED: unknown[] = [globalThis, process, console];
|
||||
// 'end' 事件未按时派发时的兜底清理延时(毫秒)
|
||||
const CLEANUP_FALLBACK_DELAY = 1000;
|
||||
|
||||
// 事件表:mineflayer 的全部事件 + 实例自有的 log 事件
|
||||
export interface bot_instance_event extends BotEvents {
|
||||
log: (log: log[]) => void;
|
||||
status_update: (status: bot_status) => void;
|
||||
instance_created: (instance: Bot) => void;
|
||||
info_update: (event: InfoUpdate) => void;
|
||||
connection_lost: () => void;
|
||||
connection_requested: () => void;
|
||||
reconnect_attempt: () => void;
|
||||
workflow_update: (event: WorkflowRunEvent) => void;
|
||||
}
|
||||
|
||||
export type bot_event_name = keyof bot_instance_event;
|
||||
// 单个事件的回调参数元组,例如 chat -> [username, message, translate, jsonMsg, matches]
|
||||
export type bot_event_args<E extends bot_event_name> = Parameters<bot_instance_event[E]>;
|
||||
export type bot_event_handler<E extends bot_event_name> = (...args: bot_event_args<E>) => void;
|
||||
// onAny / onceAny 在事件名之后接收对应事件的全部参数;
|
||||
// 事件名受联合类型约束,参数保持宽松以便统一处理任意事件
|
||||
export type bot_event_any_handler = (event: bot_event_name, ...args: any[]) => void;
|
||||
|
||||
export type bot_instance_context<T> = {
|
||||
readonly bot_instance: Bot | null,
|
||||
readonly info_manager: InfoManager | null,
|
||||
readonly function_manager: FunctionManager | null,
|
||||
workflow_manager: WorkflowManager | null,
|
||||
bot_config: bot_instance_config,
|
||||
readonly bot_status: bot_status,
|
||||
readonly plugin_manager: PluginManager,
|
||||
event_emitter: EventEmitter,
|
||||
logger: Logger | null,
|
||||
action_lock: boolean,
|
||||
instance_lock: Mutex,
|
||||
validate_config: (config: bot_instance_config) => validate_code,
|
||||
log: (log: log[]) => void,
|
||||
on: <E extends bot_event_name>(event: E, callback: bot_event_handler<E>) => void,
|
||||
onAny: (callback: bot_event_any_handler) => void,
|
||||
once: <E extends bot_event_name>(event: E, callback: bot_event_handler<E>) => void,
|
||||
onceAny: (callback: bot_event_any_handler) => void,
|
||||
off: <E extends bot_event_name>(event: E, callback: bot_event_handler<E>) => void,
|
||||
emit: <E extends bot_event_name>(event: E, ...args: bot_event_args<E>) => void,
|
||||
|
||||
connect: () => Promise<bot_operation_code>,
|
||||
create_instance: () => Promise<bot_operation_code>,
|
||||
disconnect: () => Promise<bot_operation_code>,
|
||||
schedule_reconnect: (delay: number) => Promise<void>,
|
||||
cancel_reconnect: () => void,
|
||||
inject: (callback: (context: bot_instance_context<T>) => void) => void,
|
||||
clear_this_context: () => Promise<void>
|
||||
}
|
||||
|
||||
export class BotContext implements bot_instance_context<any> {
|
||||
get is_disposed(): boolean {return this.disposal_requested;}
|
||||
public workflow_manager: WorkflowManager | null = null;
|
||||
private bot_instance_origin: Bot | null = null;
|
||||
public get bot_instance(): Bot | null {
|
||||
return this.bot_instance_origin;
|
||||
}
|
||||
private info_manager_origin: InfoManager | null = null;
|
||||
public get info_manager(): InfoManager | null {
|
||||
return this.info_manager_origin;
|
||||
}
|
||||
private info_unsubscribe: (() => void) | undefined;
|
||||
private function_manager_origin: FunctionManager | null = null;
|
||||
public get function_manager(): FunctionManager | null {
|
||||
return this.function_manager_origin;
|
||||
}
|
||||
private cleanup_task: Promise<void> | undefined;
|
||||
private dispose_task: Promise<void> | undefined;
|
||||
private disposal_requested = false;
|
||||
private operation_sequence = 0;
|
||||
private reconnect_timer: ReturnType<typeof setTimeout> | undefined;
|
||||
public readonly bot_config: bot_instance_config = {
|
||||
username: '',
|
||||
host: '',
|
||||
port: 0,
|
||||
version: '',
|
||||
user_id: 0,
|
||||
bot_id: 0,
|
||||
proxy: null
|
||||
};
|
||||
private bot_status_origin: bot_status = 'offline';
|
||||
public get bot_status(): bot_status {
|
||||
return this.bot_status_origin;
|
||||
}
|
||||
|
||||
private set_status(value: bot_status) {
|
||||
if (this.bot_status_origin === value) {
|
||||
return;
|
||||
}
|
||||
const transitions: Record<bot_status, readonly bot_status[]> = {
|
||||
offline: ['ready', 'reconnecting'],
|
||||
ready: ['connecting', 'disconnecting'],
|
||||
connecting: ['online', 'disconnecting'],
|
||||
online: ['disconnecting'],
|
||||
disconnecting: ['offline'],
|
||||
reconnecting: ['ready', 'offline'],
|
||||
};
|
||||
if (!transitions[this.bot_status_origin].includes(value)) {
|
||||
throw new Error(`无效的机器人状态转换: ${this.bot_status_origin} -> ${value}`);
|
||||
}
|
||||
this.bot_status_origin = value;
|
||||
this.notify('status_update', value);
|
||||
}
|
||||
|
||||
private notify<E extends bot_event_name>(event: E, ...args: bot_event_args<E>) {
|
||||
try {
|
||||
this.emit(event, ...args);
|
||||
} catch (error) {
|
||||
this.logger?.log([this.logger.error(`生命周期事件 ${String(event)} 处理失败: ${String(error)}`)]);
|
||||
}
|
||||
}
|
||||
public readonly plugin_manager: PluginManager;
|
||||
public event_emitter = new EventEmitter()
|
||||
// 记录每个原始回调对应的包装函数,off 时才能找回并移除同一个监听器
|
||||
private readonly event_wrappers = new Map<
|
||||
string,
|
||||
Map<(...args: any[]) => void, ((...args: any[]) => void)[]>
|
||||
>();
|
||||
public logger: Logger | null = null;
|
||||
public action_lock: boolean = false;
|
||||
// 串行化 create_instance / connect / disconnect,防止并发操作实例
|
||||
public readonly instance_lock : Mutex = new Mutex();
|
||||
|
||||
constructor(config: bot_instance_config) {
|
||||
this.bot_config = config;
|
||||
const log_path = path.join(get_project_root_path(), 'data/bot/', this.bot_config.bot_id.toString(), '/log');
|
||||
this.logger = new Logger(log_path);
|
||||
this.plugin_manager = new PluginManager(this);
|
||||
}
|
||||
|
||||
public log(log: log[]) {
|
||||
this.logger.log(log);
|
||||
this.emit('log', log);
|
||||
}
|
||||
|
||||
public create_instance(): Promise<bot_operation_code> {
|
||||
if (this.bot_status === 'reconnecting' || this.reconnect_timer !== undefined) this.cancel_reconnect();
|
||||
const sequence = this.operation_sequence;
|
||||
return this.instance_lock.runExclusive(async () => {
|
||||
await this.cleanup_task;
|
||||
if (this.disposal_requested) return validate_code_map.bot_context.disposed;
|
||||
if (sequence !== this.operation_sequence) return validate_code_map.bot_context.cancelled;
|
||||
if (this.bot_status === 'online') return validate_code_map.bot_context.instance_connected;
|
||||
if (this.bot_status === 'connecting') return validate_code_map.bot_context.instance_connecting;
|
||||
if (this.bot_instance) return validate_code_map.bot_context.ok;
|
||||
const result = await this.create_bot_instance(sequence);
|
||||
if (result !== validate_code_map.bot_context.ok) await this.cleanup_task;
|
||||
if (this.disposal_requested) return validate_code_map.bot_context.disposed;
|
||||
if (sequence !== this.operation_sequence) return validate_code_map.bot_context.cancelled;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
private async create_bot_instance(sequence: number): Promise<bot_operation_code> {
|
||||
const result = this.validate_config(this.bot_config);
|
||||
if (result !== validate_code_map.username.ok) return result;
|
||||
let instance: Bot | undefined;
|
||||
let pending_manager: InfoManager | undefined;
|
||||
let pending_functions: FunctionManager | undefined;
|
||||
let initialized = false;
|
||||
const failure = (): bot_operation_code | undefined => {
|
||||
if (this.disposal_requested) return validate_code_map.bot_context.disposed;
|
||||
if (sequence !== this.operation_sequence) return validate_code_map.bot_context.cancelled;
|
||||
if (this.bot_instance !== instance) return validate_code_map.bot_context.error;
|
||||
};
|
||||
try {
|
||||
const {port, plugins, workflow, config_revision, ...options} = this.bot_config;
|
||||
instance = createBot({...options, ...(port != null ? {port} : {}), autoConnect: false, hideErrors: true});
|
||||
(instance as Bot & {server_address: string}).server_address = `${options.host.includes(':') ? `[${options.host}]` : options.host}:${port ?? 25565}`;
|
||||
this.bot_instance_origin = instance;
|
||||
instance._client.on('declare_commands', packet => remember_command_tree(instance, packet));
|
||||
const terminate = <E extends 'end' | 'kicked' | 'error'>(event: E, ...args: bot_event_args<E>) => {
|
||||
if (this.bot_instance !== instance || this.disposal_requested) return;
|
||||
const reason = args[0];
|
||||
if (event === 'error') {
|
||||
this.log([this.logger.error(`连接错误: ${String(reason)}`)]);
|
||||
} else if (event === 'kicked') {
|
||||
let kickReason: Parameters<typeof MinecraftJsonParser.toLog>[0] = args[0] as Parameters<typeof MinecraftJsonParser.toLog>[0];
|
||||
if (typeof kickReason === 'string') {
|
||||
try { kickReason = JSON.parse(kickReason); }
|
||||
catch { /* Plain-text disconnect reasons are valid too. */ }
|
||||
}
|
||||
const reasonLog = MinecraftJsonParser.toLog(kickReason);
|
||||
if (!reasonLog.length) {
|
||||
let fallback = '未知原因';
|
||||
try { fallback = JSON.stringify(kickReason) ?? fallback; }
|
||||
catch { /* Keep a readable fallback for malformed data. */ }
|
||||
reasonLog.push(this.logger.error(fallback));
|
||||
}
|
||||
this.log([
|
||||
this.logger.custom('[被踢出] ', '#cf8e8e', ['bold']),
|
||||
...reasonLog,
|
||||
]);
|
||||
} else {
|
||||
this.log([this.logger.warning(`连接已断开: ${String(reason ?? '未知原因')}`)]);
|
||||
}
|
||||
const sequence = this.operation_sequence;
|
||||
// Initialization failures are handled by the caller, including retry attempts.
|
||||
const report_connection_lost = initialized;
|
||||
const cleanup = this.clear_bot_instance(event === 'end');
|
||||
this.notify(event, ...args);
|
||||
void cleanup.then(() => {
|
||||
if (report_connection_lost && !this.disposal_requested && sequence === this.operation_sequence) {
|
||||
this.notify('connection_lost');
|
||||
}
|
||||
}).catch(error => {
|
||||
this.logger?.log([this.logger.error(`清理实例失败: ${String(error)}`)]);
|
||||
});
|
||||
};
|
||||
instance.on('end', reason => terminate('end', reason));
|
||||
instance.on('kicked', (reason, logged_in) => terminate('kicked', reason, logged_in));
|
||||
instance.on('error', (error: Error) => terminate('error', error));
|
||||
instance.on('initialLogin', () => {
|
||||
if (this.bot_instance !== instance || this.bot_status !== 'connecting' || this.disposal_requested) return;
|
||||
this.set_status('online');
|
||||
});
|
||||
instance.on("message", (jsonMsg, position) => {
|
||||
const channel = {
|
||||
chat: {label: '聊天', color: '#8fb0c9'},
|
||||
system: {label: '系统', color: '#d1b478'},
|
||||
game_info: {label: '提示', color: '#8fb98f'},
|
||||
}[position] ?? {label: String(position), color: '#8fb0c9'};
|
||||
this.log([
|
||||
this.logger.custom(`[${channel.label}] `, channel.color, ['bold']),
|
||||
...MinecraftJsonParser.parse(jsonMsg).log,
|
||||
]);
|
||||
})
|
||||
instance.onAny((event: string, ...args: any[]) => {
|
||||
if (this.bot_instance === instance && !this.disposal_requested) this.emit(event, ...args);
|
||||
});
|
||||
this.set_status('ready');
|
||||
const before_loading = failure();
|
||||
if (before_loading !== undefined) return before_loading;
|
||||
const manager = pending_manager = await create_info_manager(instance, {
|
||||
bot_id: this.bot_config.bot_id, plugin_source: this.plugin_manager,
|
||||
workflow_source: this.workflow_manager,
|
||||
});
|
||||
const after_loading = failure();
|
||||
if (after_loading !== undefined) return after_loading;
|
||||
this.info_manager_origin = manager;
|
||||
pending_manager = undefined;
|
||||
this.info_unsubscribe = manager.on_update(event => {
|
||||
if (this.bot_instance === instance && this.info_manager === manager
|
||||
&& !this.disposal_requested) {
|
||||
this.notify('info_update', event);
|
||||
}
|
||||
});
|
||||
pending_functions = await create_function_manager(this);
|
||||
const after_functions = failure();
|
||||
if (after_functions !== undefined) return after_functions;
|
||||
this.function_manager_origin = pending_functions;
|
||||
pending_functions = undefined;
|
||||
this.notify('instance_created', instance);
|
||||
const after_notification = failure();
|
||||
if (after_notification !== undefined) return after_notification;
|
||||
this.log([this.logger.custom('[NODE] 创建机器人实例成功', '#8fb98f', ['bold'])]);
|
||||
const after_log = failure();
|
||||
if (after_log !== undefined) return after_log;
|
||||
initialized = true;
|
||||
return validate_code_map.bot_context.ok;
|
||||
} catch (error) {
|
||||
this.logger?.log([this.logger.error(`创建实例失败: ${String(error)}`)]);
|
||||
if (this.disposal_requested) return validate_code_map.bot_context.disposed;
|
||||
if (sequence !== this.operation_sequence) return validate_code_map.bot_context.cancelled;
|
||||
return validate_code_map.bot_context.error;
|
||||
} finally {
|
||||
if (!initialized) {
|
||||
pending_functions?.dispose();
|
||||
try { pending_manager?.dispose(); }
|
||||
catch (error) { this.logger?.log([this.logger.error(`清理信息管理器失败: ${String(error)}`)]); }
|
||||
if (instance && this.bot_instance === instance) await this.clear_bot_instance();
|
||||
else await this.cleanup_task;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public inject(callback: (context: bot_instance_context<any>) => void) {
|
||||
callback(this as bot_instance_context<any>);
|
||||
}
|
||||
|
||||
public connect(): Promise<bot_operation_code> {
|
||||
if (this.bot_status === 'reconnecting' || this.reconnect_timer !== undefined) this.cancel_reconnect();
|
||||
const sequence = this.operation_sequence;
|
||||
if (!this.disposal_requested) this.notify('connection_requested');
|
||||
return this.connect_instance(sequence);
|
||||
}
|
||||
|
||||
private connect_instance(sequence: number): Promise<bot_operation_code> {
|
||||
return this.instance_lock.runExclusive(async () => {
|
||||
await this.cleanup_task;
|
||||
if (this.disposal_requested) return validate_code_map.bot_context.disposed;
|
||||
if (sequence !== this.operation_sequence) return validate_code_map.bot_context.cancelled;
|
||||
if (this.bot_status === 'online') return validate_code_map.bot_context.instance_connected;
|
||||
if (this.bot_status === 'connecting') return validate_code_map.bot_context.instance_connecting;
|
||||
if (this.bot_status === 'reconnecting') this.set_status('offline');
|
||||
if (!this.bot_instance) {
|
||||
const result = await this.create_bot_instance(sequence);
|
||||
if (result !== validate_code_map.bot_context.ok) {
|
||||
await this.cleanup_task;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
if (this.disposal_requested) return validate_code_map.bot_context.disposed;
|
||||
if (sequence !== this.operation_sequence) return validate_code_map.bot_context.cancelled;
|
||||
const instance = this.bot_instance;
|
||||
if (!instance) return validate_code_map.bot_context.error;
|
||||
this.set_status('connecting');
|
||||
if (this.disposal_requested || sequence !== this.operation_sequence) {
|
||||
await this.clear_bot_instance();
|
||||
return validate_code_map.bot_context.cancelled;
|
||||
}
|
||||
try {
|
||||
instance.connect();
|
||||
if (this.bot_instance !== instance) {
|
||||
await this.cleanup_task;
|
||||
return validate_code_map.bot_context.error;
|
||||
}
|
||||
return validate_code_map.bot_context.ok;
|
||||
} catch (error) {
|
||||
this.logger?.log([this.logger.error(`连接实例失败: ${String(error)}`)]);
|
||||
await this.clear_bot_instance();
|
||||
return validate_code_map.bot_context.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public disconnect(): Promise<bot_operation_code> {
|
||||
this.cancel_reconnect();
|
||||
return this.instance_lock.runExclusive(async () => {
|
||||
if (this.disposal_requested) return validate_code_map.bot_context.disposed;
|
||||
await this.clear_bot_instance();
|
||||
return validate_code_map.bot_context.ok;
|
||||
});
|
||||
}
|
||||
|
||||
public cancel_reconnect(): void {
|
||||
this.operation_sequence++;
|
||||
clearTimeout(this.reconnect_timer);
|
||||
this.reconnect_timer = undefined;
|
||||
if (this.bot_status === 'reconnecting') this.set_status('offline');
|
||||
}
|
||||
|
||||
public async schedule_reconnect(delay: number): Promise<void> {
|
||||
if (!Number.isInteger(delay) || delay < 0 || delay > 2_147_483_647) {
|
||||
throw new Error('重连延时必须是有效的毫秒整数');
|
||||
}
|
||||
const sequence = this.operation_sequence;
|
||||
await this.instance_lock.runExclusive(() => {
|
||||
if (this.disposal_requested || sequence !== this.operation_sequence || this.bot_status !== 'offline') return;
|
||||
this.reconnect_timer = setTimeout(() => {
|
||||
this.reconnect_timer = undefined;
|
||||
if (this.disposal_requested || sequence !== this.operation_sequence) return;
|
||||
this.notify('reconnect_attempt');
|
||||
void this.connect_instance(sequence).then(result => {
|
||||
if (result !== validate_code_map.bot_context.ok && !this.disposal_requested
|
||||
&& sequence === this.operation_sequence && this.bot_status === 'offline') {
|
||||
this.notify('connection_lost');
|
||||
}
|
||||
}).catch(error => {
|
||||
this.logger?.log([this.logger.error(`自动重连失败: ${String(error)}`)]);
|
||||
});
|
||||
}, delay);
|
||||
this.set_status('reconnecting');
|
||||
});
|
||||
}
|
||||
|
||||
public on<E extends bot_event_name>(event: E, callback: bot_event_handler<E>) {
|
||||
const wrapper: bot_event_handler<E> = (...args) => callback(...args);
|
||||
this.remember_wrapper(event, callback, wrapper);
|
||||
this.event_emitter.on(event, wrapper);
|
||||
}
|
||||
|
||||
public onAny(callback: bot_event_any_handler) {
|
||||
const wrapper = (event: string, ...args: any[]) => callback(event as bot_event_name, ...args);
|
||||
this.remember_wrapper('any', callback, wrapper);
|
||||
this.event_emitter.on('any', wrapper);
|
||||
}
|
||||
|
||||
public once<E extends bot_event_name>(event: E, callback: bot_event_handler<E>) {
|
||||
const wrapper: bot_event_handler<E> = (...args) => callback(...args);
|
||||
this.remember_wrapper(event, callback, wrapper);
|
||||
this.event_emitter.once(event, wrapper);
|
||||
}
|
||||
|
||||
public onceAny(callback: bot_event_any_handler) {
|
||||
const wrapper = (event: string, ...args: any[]) => callback(event as bot_event_name, ...args);
|
||||
this.remember_wrapper('any', callback, wrapper);
|
||||
this.event_emitter.once('any', wrapper);
|
||||
}
|
||||
|
||||
public off<E extends bot_event_name>(event: E, callback: bot_event_handler<E>) {
|
||||
const callbacks = this.event_wrappers.get(event);
|
||||
const wrappers = callbacks?.get(callback);
|
||||
if (!callbacks || !wrappers) return;
|
||||
const wrapper = wrappers.pop();
|
||||
if (!wrapper) return;
|
||||
this.event_emitter.off(event, wrapper);
|
||||
if (wrappers.length === 0) {
|
||||
callbacks.delete(callback);
|
||||
}
|
||||
}
|
||||
|
||||
// 记录原始回调 -> 包装函数栈,off 时才能找回并移除同一个监听器;
|
||||
// 同一回调可多次注册,off 从栈顶移除最近一次
|
||||
private remember_wrapper(
|
||||
event: string,
|
||||
callback: (...args: any[]) => void,
|
||||
wrapper: (...args: any[]) => void,
|
||||
) {
|
||||
let callbacks = this.event_wrappers.get(event);
|
||||
if (!callbacks) {
|
||||
callbacks = new Map();
|
||||
this.event_wrappers.set(event, callbacks);
|
||||
}
|
||||
let wrappers = callbacks.get(callback);
|
||||
if (!wrappers) {
|
||||
wrappers = [];
|
||||
callbacks.set(callback, wrappers);
|
||||
}
|
||||
wrappers.push(wrapper);
|
||||
}
|
||||
|
||||
public emit<E extends bot_event_name>(event: E, ...args: bot_event_args<E>): void;
|
||||
public emit(event: string, ...args: any[]): void;
|
||||
public emit(event: string, ...args: any[]) {
|
||||
// 'error' 无监听器时 EventEmitter 会抛出;转发前先兜底,避免 bot 的错误
|
||||
// 变成未捕获异常,同时保证 'any' / onAny 仍能观察到该事件
|
||||
if (event !== 'error' || this.event_emitter.listenerCount('error') > 0) {
|
||||
this.event_emitter.emit(event, ...args);
|
||||
}
|
||||
this.event_emitter.emit('any', event, ...args);
|
||||
}
|
||||
|
||||
private clear_bot_instance(ended = false): Promise<void> {
|
||||
if (this.cleanup_task) return this.cleanup_task;
|
||||
const instance = this.bot_instance;
|
||||
if (!instance) return Promise.resolve();
|
||||
this.bot_instance_origin = null;
|
||||
const manager = this.info_manager_origin;
|
||||
const functions = this.function_manager_origin;
|
||||
this.function_manager_origin = null;
|
||||
const unsubscribe = this.info_unsubscribe;
|
||||
this.info_manager_origin = null;
|
||||
this.info_unsubscribe = undefined;
|
||||
let cleaned = false;
|
||||
let finish!: () => void;
|
||||
const task = new Promise<void>(resolve => { finish = resolve; });
|
||||
this.cleanup_task = task.then(() => {
|
||||
this.cleanup_task = undefined;
|
||||
if (!this.disposal_requested) this.set_status('offline');
|
||||
});
|
||||
const cleanup_task = this.cleanup_task;
|
||||
const run_cleanup = () => {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
clearTimeout(fallback_timer);
|
||||
try {
|
||||
this.deep_cleanup(instance, new Set(), 0);
|
||||
this.log([this.logger.custom('[NODE] 机器人实例已关闭', '#cf8e8e', ['bold'])]);
|
||||
} catch (error) {
|
||||
this.logger?.log([this.logger.error(`清理实例资源失败: ${String(error)}`)]);
|
||||
} finally {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
// 等 end 的所有监听器执行完毕后再摘除监听器,保留 mineflayer 的内部资源清理。
|
||||
const on_end = () => { queueMicrotask(run_cleanup); };
|
||||
const fallback_timer = setTimeout(run_cleanup, CLEANUP_FALLBACK_DELAY);
|
||||
for (const cleanup of [unsubscribe, manager ? () => manager.dispose() : undefined, functions ? () => functions.dispose() : undefined]) {
|
||||
try { cleanup?.(); }
|
||||
catch (error) { this.logger?.log([this.logger.error(`清理信息管理器失败: ${String(error)}`)]); }
|
||||
}
|
||||
this.set_status('disconnecting');
|
||||
if (ended) {
|
||||
on_end();
|
||||
} else {
|
||||
instance.once('end', on_end);
|
||||
try {
|
||||
instance.end(validate_code_map.bot_context.instance_end_by_user.toString());
|
||||
} catch {
|
||||
on_end();
|
||||
}
|
||||
}
|
||||
return cleanup_task;
|
||||
}
|
||||
|
||||
// 深度遍历对象图,释放监听器 / 定时器 / 流句柄等残留状态;
|
||||
// seen 去重,depth 限制递归深度,visited 限制遍历规模,避免开销失控
|
||||
private deep_cleanup(target: unknown, seen: Set<object>, depth: number, visited = { count: 0 }) {
|
||||
if (target === null || typeof target !== 'object') return;
|
||||
if (depth > CLEANUP_MAX_DEPTH || visited.count > CLEANUP_MAX_VISITED) return;
|
||||
const object = target as Record<string, any>;
|
||||
if (CLEANUP_PROTECTED.includes(object)) return;
|
||||
if (seen.has(object)) return;
|
||||
seen.add(object);
|
||||
visited.count++;
|
||||
|
||||
this.release_own_state(object);
|
||||
|
||||
for (const key of Object.keys(object)) {
|
||||
if (CLEANUP_SKIP_KEYS.has(key)) continue;
|
||||
let value: unknown;
|
||||
try {
|
||||
value = object[key];
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (value === null || typeof value !== 'object') continue;
|
||||
this.deep_cleanup(value, seen, depth + 1, visited);
|
||||
}
|
||||
}
|
||||
|
||||
// 释放单个对象自身持有的监听器 / 定时器 / 流句柄 / 待发队列
|
||||
private release_own_state(object: Record<string, any>) {
|
||||
for (const key of CLEANUP_TIMER_KEYS) {
|
||||
const timer = object[key];
|
||||
if (timer === null || timer === undefined) continue;
|
||||
clearInterval(timer as NodeJS.Timeout);
|
||||
clearTimeout(timer as NodeJS.Timeout);
|
||||
try {
|
||||
object[key] = null;
|
||||
} catch {
|
||||
// 只读 / 冻结对象无法赋值,忽略
|
||||
}
|
||||
}
|
||||
for (const key of CLEANUP_QUEUE_KEYS) {
|
||||
if (Array.isArray(object[key])) {
|
||||
(object[key] as unknown[]).length = 0;
|
||||
}
|
||||
}
|
||||
// 仅销毁流 / socket 类句柄,避免误调用无关对象的 destroy
|
||||
if (typeof object.destroy === 'function' && object.destroyed !== true
|
||||
&& (typeof object.pipe === 'function' || typeof object.write === 'function')) {
|
||||
try {
|
||||
object.destroy();
|
||||
} catch {
|
||||
// 忽略已关闭句柄在销毁时的异常
|
||||
}
|
||||
}
|
||||
if (typeof object.removeAllListeners === 'function') {
|
||||
object.removeAllListeners();
|
||||
}
|
||||
}
|
||||
|
||||
public validate_config(config: bot_instance_config): validate_code {
|
||||
const username_validate = validate_username(config.username);
|
||||
if (username_validate !== validate_code_map.username.ok) return username_validate;
|
||||
const host_validate = validate_host(config.host);
|
||||
if (host_validate !== validate_code_map.host.ok) return host_validate;
|
||||
if (config.port != null) {
|
||||
const port_validate = validate_port(config.port);
|
||||
if (port_validate !== validate_code_map.port.ok) return port_validate;
|
||||
}
|
||||
const version_validate = validate_version(config.version);
|
||||
if (version_validate !== validate_code_map.version.ok) return version_validate;
|
||||
if (config.proxy != null) {
|
||||
const proxy_validate = validate_proxy(config.proxy);
|
||||
if (proxy_validate !== validate_code_map.proxy.ok) return proxy_validate;
|
||||
}
|
||||
return validate_code_map.username.ok;
|
||||
}
|
||||
|
||||
public clear_this_context(): Promise<void> {
|
||||
if (this.dispose_task) return this.dispose_task;
|
||||
this.disposal_requested = true;
|
||||
this.cancel_reconnect();
|
||||
const plugins_disposed = this.plugin_manager.dispose().catch(error => {
|
||||
this.logger?.log([this.logger.error(`插件卸载失败: ${String(error)}`)]);
|
||||
});
|
||||
const workflow_disposed = this.workflow_manager?.dispose().catch(error => {
|
||||
this.logger?.log([this.logger.error(`Workflow cleanup failed: ${String(error)}`)]);
|
||||
});
|
||||
this.dispose_task = Promise.all([plugins_disposed, workflow_disposed]).then(() => this.instance_lock.runExclusive(async () => {
|
||||
await this.clear_bot_instance();
|
||||
this.action_lock = false;
|
||||
if (this.bot_status === 'offline') {
|
||||
this.notify('status_update', 'offline');
|
||||
} else {
|
||||
this.set_status('offline');
|
||||
}
|
||||
this.event_emitter.removeAllListeners();
|
||||
this.event_wrappers.clear();
|
||||
this.workflow_manager = null;
|
||||
await this.logger?.close();
|
||||
}));
|
||||
return this.dispose_task;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import {bot_instance_context, BotContext} from "./bot_instance_init";
|
||||
import {bot_config, bot_status} from "../../types/bot/instance/bot_runtime";
|
||||
import {validate_code_map} from "../../utils/validate_code_map";
|
||||
import {init_workflow} from "../workflow/init";
|
||||
import {update_one_bot_status} from "../../service/server/update/bot_status.ts";
|
||||
import type {Socket} from "socket.io-client";
|
||||
|
||||
|
||||
const bot_context_map: Map<number, bot_instance_context<any>> = new Map();
|
||||
const bot_action_lock_map = new Map<number, boolean>();
|
||||
type ContextObserver = (context: bot_instance_context<any> | undefined) => void;
|
||||
const bot_context_observers = new Map<number, Set<ContextObserver>>();
|
||||
|
||||
export function get_bot_context(bot_id: number): bot_instance_context<any> | undefined {
|
||||
return bot_context_map.get(bot_id);
|
||||
}
|
||||
|
||||
function notify_context_observer(callback: ContextObserver, context: bot_instance_context<any> | undefined) {
|
||||
try {
|
||||
callback(context);
|
||||
} catch {
|
||||
console.error('Bot context observer failed');
|
||||
}
|
||||
}
|
||||
|
||||
function notify_bot_context(bot_id: number) {
|
||||
for (const callback of Array.from(bot_context_observers.get(bot_id) ?? [])) {
|
||||
notify_context_observer(callback, bot_context_map.get(bot_id));
|
||||
}
|
||||
}
|
||||
|
||||
export function observe_bot_context(bot_id: number, callback: ContextObserver): () => void {
|
||||
let observers = bot_context_observers.get(bot_id);
|
||||
if (!observers) {
|
||||
observers = new Set();
|
||||
bot_context_observers.set(bot_id, observers);
|
||||
}
|
||||
observers.add(callback);
|
||||
notify_context_observer(callback, bot_context_map.get(bot_id));
|
||||
return () => {
|
||||
observers.delete(callback);
|
||||
if (observers.size === 0 && bot_context_observers.get(bot_id) === observers) {
|
||||
bot_context_observers.delete(bot_id);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function remove_bot_context(bot_id: number, context: bot_instance_context<any>) {
|
||||
if (bot_context_map.get(bot_id) === context) {
|
||||
bot_context_map.delete(bot_id);
|
||||
notify_bot_context(bot_id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function start_bot_context(main_server_socket: Socket, bot_config: bot_config, node_id: number) {
|
||||
if (!Number.isSafeInteger(node_id) || node_id <= 0
|
||||
|| !bot_config || typeof bot_config !== "object" || Array.isArray(bot_config)
|
||||
|| !bot_config.instance_config || typeof bot_config.instance_config !== "object"
|
||||
|| Array.isArray(bot_config.instance_config)
|
||||
|| !Number.isSafeInteger(bot_config.instance_config.bot_id) || bot_config.instance_config.bot_id <= 0
|
||||
|| !Number.isSafeInteger(bot_config.instance_config.user_id) || bot_config.instance_config.user_id <= 0
|
||||
|| !Array.isArray(bot_config.plugins) || !Array.isArray(bot_config.workflow)
|
||||
|| !bot_config.plugins.every(item => item && typeof item === "object" && !Array.isArray(item))
|
||||
|| !bot_config.workflow.every(item => item && typeof item === "object" && !Array.isArray(item))) {
|
||||
return validate_code_map.web.invalid_parameter;
|
||||
}
|
||||
const bot_id = bot_config.instance_config.bot_id;
|
||||
if (bot_action_lock_map.get(bot_id) ?? false) {
|
||||
return validate_code_map.bot_context.processing;
|
||||
}
|
||||
bot_action_lock_map.set(bot_id, true);
|
||||
let new_context: BotContext | undefined;
|
||||
let context_started = false;
|
||||
try {
|
||||
const temp_context = bot_context_map.get(bot_id);
|
||||
if (temp_context !== undefined) {
|
||||
if (temp_context.bot_status === "online" || temp_context.bot_status === "connecting"
|
||||
|| temp_context.bot_status === "reconnecting" || temp_context.bot_status === "ready") {
|
||||
const result = await temp_context.connect();
|
||||
return result === validate_code_map.bot_context.ok ? validate_code_map.bot_context.context_created : result;
|
||||
} else if (temp_context.bot_status === "disconnecting") {
|
||||
return validate_code_map.bot_context.processing;
|
||||
} else {
|
||||
await temp_context.clear_this_context();
|
||||
remove_bot_context(bot_id, temp_context);
|
||||
}
|
||||
}
|
||||
|
||||
const context = new_context = new BotContext(bot_config.instance_config);
|
||||
context.bot_config.plugins = structuredClone(bot_config.plugins);
|
||||
context.bot_config.workflow = structuredClone(bot_config.workflow);
|
||||
context.on("status_update", (status) => {
|
||||
void update_one_bot_status(main_server_socket, node_id, bot_id, bot_config.instance_config.user_id, status)
|
||||
.catch(error => console.error(`Bot ${bot_id} status update failed`, error));
|
||||
});
|
||||
const origin_clean = context.clear_this_context.bind(context);
|
||||
context.clear_this_context = async () => {
|
||||
try {
|
||||
await origin_clean();
|
||||
} finally {
|
||||
remove_bot_context(bot_id, context);
|
||||
}
|
||||
};
|
||||
const validation_result = context.validate_config(bot_config.instance_config);
|
||||
if (validation_result !== validate_code_map.bot_context.ok) {
|
||||
console.error(`Bot ${bot_id} configuration validation failed, code: ${validation_result}`);
|
||||
return validation_result;
|
||||
}
|
||||
await context.plugin_manager.init(bot_config.plugins);
|
||||
await init_workflow(context, bot_config.workflow);
|
||||
bot_context_map.set(bot_id, context);
|
||||
notify_bot_context(bot_id);
|
||||
const result = await context.connect();
|
||||
if (result !== validate_code_map.bot_context.ok
|
||||
&& result !== validate_code_map.bot_context.instance_connected
|
||||
&& result !== validate_code_map.bot_context.instance_connecting) {
|
||||
console.error(`Bot ${bot_id} startup failed, code: ${result}`);
|
||||
return result;
|
||||
}
|
||||
context_started = true;
|
||||
return result === validate_code_map.bot_context.ok ? validate_code_map.bot_context.context_created : result;
|
||||
} catch (error) {
|
||||
console.error(`Bot ${bot_id} startup failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return validate_code_map.bot_context.error;
|
||||
} finally {
|
||||
if (new_context && !context_started) {
|
||||
try {
|
||||
await new_context.clear_this_context();
|
||||
} catch (error) {
|
||||
console.error(`Bot ${bot_id} cleanup failed`, error);
|
||||
}
|
||||
}
|
||||
bot_action_lock_map.delete(bot_id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function get_bot_context_status() : Promise<{
|
||||
bot_id: number;
|
||||
status: bot_status;
|
||||
}[]> {
|
||||
return Array.from(bot_context_map).map(([bot_id, context]) => {
|
||||
return {
|
||||
bot_id,
|
||||
status: context.bot_status,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function get_bot_context_status_by_ids(bot_id: number[]) : Promise<{
|
||||
bot_id: number;
|
||||
status: bot_status;
|
||||
}[]> {
|
||||
return bot_id.map((id) => {
|
||||
return {
|
||||
bot_id: id,
|
||||
status: bot_context_map.get(id)?.bot_status ?? "offline",
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function stop_bot_context(bot_id: number) {
|
||||
if (bot_action_lock_map.get(bot_id) ?? false) {
|
||||
return validate_code_map.bot_context.processing;
|
||||
}
|
||||
bot_action_lock_map.set(bot_id, true);
|
||||
try {
|
||||
if (!bot_context_map.has(bot_id)) {
|
||||
return validate_code_map.bot_context.instance_end_by_user;
|
||||
}
|
||||
const context = bot_context_map.get(bot_id);
|
||||
if (context !== undefined) {
|
||||
await context.clear_this_context();
|
||||
}
|
||||
if (context) remove_bot_context(bot_id, context);
|
||||
return validate_code_map.bot_context.instance_end_by_user;
|
||||
} catch (error) {
|
||||
return validate_code_map.bot_context.error;
|
||||
} finally {
|
||||
bot_action_lock_map.delete(bot_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export type PluginErrorReason = 'INVALID_CONFIG' | 'ALREADY_INSTALLED' | 'NOT_INSTALLED'
|
||||
| 'MANAGER_DISPOSED' | 'LOAD_FAILED' | 'INIT_FAILED' | 'DISPOSE_FAILED';
|
||||
|
||||
export class PluginError extends Error {
|
||||
constructor(readonly reason: PluginErrorReason) {
|
||||
super({
|
||||
INVALID_CONFIG: 'Invalid plugin config', ALREADY_INSTALLED: 'Duplicate plugin',
|
||||
NOT_INSTALLED: 'Plugin not installed', MANAGER_DISPOSED: 'Plugin manager disposed',
|
||||
LOAD_FAILED: 'Plugin load failed', INIT_FAILED: 'Plugin initialization failed',
|
||||
DISPOSE_FAILED: 'Plugin disposal failed',
|
||||
}[reason]);
|
||||
this.name = 'PluginError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import {readdir} from 'node:fs/promises';
|
||||
import {isDeepStrictEqual} from 'node:util';
|
||||
import type {BasePlugin, PluginContext, base_define, bot_config_plugin, plugin} from '../../types/bot/plugins/plugin.ts';
|
||||
import type {data_type} from '../../types/bot/instance/bot_runtime.ts';
|
||||
import {PluginError} from './error.ts';
|
||||
import {plain_plugin_object, valid_plugin_config, valid_plugin_name, valid_plugin_path} from '../../utils/plugin_validation.ts';
|
||||
export {PluginError} from './error.ts';
|
||||
|
||||
const valid_name = valid_plugin_name;
|
||||
const data_types = new Set<data_type>(['string', 'boolean', 'number', 'datetime', 'string-select', 'number-select',
|
||||
'string-multi-select', 'number-multi-select', 'string-array', 'number-array']);
|
||||
const valid_i18n = (value: unknown): boolean => Array.isArray(value) && value.length > 0 && value.every(item =>
|
||||
item && typeof item.lang === 'string' && item.lang.length > 0 && typeof item.name === 'string' && typeof item.description === 'string');
|
||||
|
||||
function valid_value(value: unknown, type: data_type): boolean {
|
||||
switch (type) {
|
||||
case 'string': case 'string-select': return typeof value === 'string';
|
||||
case 'number': case 'number-select': return typeof value === 'number' && Number.isFinite(value);
|
||||
case 'boolean': return typeof value === 'boolean';
|
||||
case 'datetime': return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value) && Number.isFinite(Date.parse(value));
|
||||
case 'string-array': case 'string-multi-select': return Array.isArray(value) && Array.from(value).every(item => typeof item === 'string');
|
||||
case 'number-array': case 'number-multi-select': return Array.isArray(value) && Array.from(value).every(item => typeof item === 'number' && Number.isFinite(item));
|
||||
}
|
||||
}
|
||||
|
||||
function validate_item(item: bot_config_plugin<unknown>) {
|
||||
if (!plain_plugin_object(item) || !valid_name(item.name) || !valid_plugin_path(item.path)
|
||||
|| item.config !== undefined && !valid_plugin_config(item.config)) {
|
||||
throw new PluginError('INVALID_CONFIG');
|
||||
}
|
||||
}
|
||||
|
||||
async function import_module(url: URL): Promise<Record<string, unknown>> {
|
||||
url.searchParams.set('t', `${Date.now()}-${process.hrtime.bigint()}`);
|
||||
return import(url.href);
|
||||
}
|
||||
|
||||
function instantiate(module: Record<string, unknown>, name: string): BasePlugin {
|
||||
if (typeof module.init !== 'function') throw new Error(`Plugin ${name} must export an init class`);
|
||||
const instance = new (module.init as new () => BasePlugin)();
|
||||
const template = instance.config_template;
|
||||
if (instance.name !== name || template?.name !== name || !valid_i18n(template.i18n)
|
||||
|| typeof template.version !== 'string' || !template.version.length || !Array.isArray(template.config)
|
||||
|| typeof instance.init !== 'function' || typeof instance.get_status !== 'function'
|
||||
|| typeof instance.on_states_update !== 'function' || typeof instance.dispose !== 'function') {
|
||||
throw new Error(`Invalid plugin metadata: ${name}`);
|
||||
}
|
||||
const names = new Set<string>();
|
||||
for (const field of template.config) {
|
||||
if (!field || !valid_name(field.name) || names.has(field.name) || !valid_i18n(field.i18n)
|
||||
|| typeof field.required !== 'boolean' || !data_types.has(field.type) || !Object.hasOwn(field, 'default')
|
||||
|| field.default != null && !valid_value(field.default, field.type)) throw new Error(`Invalid plugin config metadata: ${name}`);
|
||||
names.add(field.name);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
function resolve_config(template: plugin, input: Record<string, unknown>): Record<string, unknown> {
|
||||
const names = new Set(template.config.map(field => field.name));
|
||||
for (const key of Object.keys(input)) if (!names.has(key)) throw new PluginError('INVALID_CONFIG');
|
||||
const config: Record<string, unknown> = {};
|
||||
for (const field of template.config) {
|
||||
const value = Object.hasOwn(input, field.name) ? input[field.name] : field.default;
|
||||
if (value == null ? field.required : !valid_value(value, field.type)) throw new PluginError('INVALID_CONFIG');
|
||||
Object.defineProperty(config, field.name, {value: structuredClone(value), enumerable: true, writable: true, configurable: true});
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export interface PluginSnapshot {
|
||||
name: string;
|
||||
path: string;
|
||||
config: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
lifecycle: 'initializing' | 'enabled' | 'disabling' | 'disabled' | 'error';
|
||||
/** Last observed internal state; disabled records retain the final disposal snapshot. */
|
||||
states: unknown;
|
||||
error?: {code: string; message: string};
|
||||
}
|
||||
|
||||
type Entry = {instance: BasePlugin; path: string; config: Record<string, unknown>; unsubscribe?: () => void; quarantined?: boolean};
|
||||
|
||||
export class PluginManager {
|
||||
private readonly entries = new Map<string, Entry>();
|
||||
private readonly records = new Map<string, PluginSnapshot>();
|
||||
private readonly listeners = new Set<(event: PluginSnapshot) => void>();
|
||||
private tail: Promise<void> = Promise.resolve();
|
||||
private disposed = false;
|
||||
private dispose_task?: Promise<void>;
|
||||
|
||||
constructor(private readonly context: PluginContext) {}
|
||||
|
||||
on_update(callback: (event: PluginSnapshot) => void): () => void {
|
||||
this.check_active();
|
||||
const listener = (event: PluginSnapshot) => callback(event);
|
||||
this.listeners.add(listener);
|
||||
return () => { this.listeners.delete(listener); };
|
||||
}
|
||||
|
||||
private publish(record: PluginSnapshot) {
|
||||
this.records.set(record.name, structuredClone(record));
|
||||
for (const listener of Array.from(this.listeners)) {
|
||||
if (!this.listeners.has(listener)) continue;
|
||||
try { listener(structuredClone(record)); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
private capture(entry: Entry): unknown {
|
||||
try { return structuredClone(entry.instance.get_status()); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private transition(entry: Entry, lifecycle: PluginSnapshot['lifecycle']) {
|
||||
this.publish({name: entry.instance.name, path: entry.path, config: entry.config,
|
||||
enabled: lifecycle === 'enabled', lifecycle, states: this.capture(entry),
|
||||
...(entry.quarantined ? {error: {code: 'DISPOSE_FAILED', message: 'Plugin disposal failed'}} : {})});
|
||||
}
|
||||
|
||||
private check_active() {
|
||||
if (this.disposed) throw new PluginError('MANAGER_DISPOSED');
|
||||
}
|
||||
|
||||
private enqueue(operation: () => Promise<void>): Promise<void> {
|
||||
if (this.disposed) return Promise.reject(new PluginError('MANAGER_DISPOSED'));
|
||||
const task = this.tail.then(async () => { this.check_active(); await operation(); });
|
||||
this.tail = task.catch(() => {});
|
||||
return task;
|
||||
}
|
||||
|
||||
private async prepare(item: bot_config_plugin<unknown>): Promise<Entry> {
|
||||
validate_item(item);
|
||||
if (this.entries.get(item.name)?.quarantined) throw new PluginError('DISPOSE_FAILED');
|
||||
let instance: BasePlugin;
|
||||
try {
|
||||
const module = await import_module(new URL(`./${item.path}/${item.name}.ts`, import.meta.url));
|
||||
this.check_active();
|
||||
instance = instantiate(module, item.name);
|
||||
} catch (error) {
|
||||
if (error instanceof PluginError) throw error;
|
||||
throw new PluginError('LOAD_FAILED');
|
||||
}
|
||||
return {instance, path: item.path, config: resolve_config(instance.config_template, (item.config ?? {}) as Record<string, unknown>)};
|
||||
}
|
||||
|
||||
private async install(entry: Entry) {
|
||||
try {
|
||||
this.check_active();
|
||||
this.transition(entry, 'initializing');
|
||||
entry.unsubscribe = entry.instance.on_states_update(() => {
|
||||
const record = this.records.get(entry.instance.name);
|
||||
if (record) this.transition(entry, record.lifecycle);
|
||||
});
|
||||
this.check_active();
|
||||
await entry.instance.init(this.context, structuredClone(entry.config));
|
||||
this.check_active();
|
||||
this.entries.set(entry.instance.name, entry);
|
||||
this.transition(entry, 'enabled');
|
||||
} catch (error) {
|
||||
this.entries.delete(entry.instance.name);
|
||||
this.transition(entry, 'disabling');
|
||||
let cleanup_failed = false;
|
||||
try { await entry.instance.dispose(); }
|
||||
catch { cleanup_failed = true; }
|
||||
finally {
|
||||
try { entry.unsubscribe?.(); } catch { cleanup_failed = true; }
|
||||
if (cleanup_failed) {
|
||||
entry.quarantined = true;
|
||||
this.entries.set(entry.instance.name, entry);
|
||||
}
|
||||
this.transition(entry, 'error');
|
||||
}
|
||||
if (cleanup_failed) throw new PluginError('DISPOSE_FAILED');
|
||||
throw error instanceof PluginError ? error : new PluginError('INIT_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
apply_desired(plugins: bot_config_plugin<unknown>[]): Promise<void> {
|
||||
return this.enqueue(async () => {
|
||||
if (!Array.isArray(plugins)) throw new PluginError('INVALID_CONFIG');
|
||||
const desired = new Map<string, bot_config_plugin<unknown>>();
|
||||
for (const item of plugins) {
|
||||
validate_item(item);
|
||||
if (desired.has(item.name)) throw new PluginError('ALREADY_INSTALLED');
|
||||
const current = this.entries.get(item.name);
|
||||
if (current && current.path !== item.path) throw new PluginError('INVALID_CONFIG');
|
||||
desired.set(item.name, structuredClone(item));
|
||||
}
|
||||
for (const entry of this.entries.values()) {
|
||||
if (entry.quarantined) throw new PluginError('DISPOSE_FAILED');
|
||||
}
|
||||
const candidates = new Map<string, Entry>();
|
||||
// Constructors own no resources. Prepare every replacement before disposing live plugins.
|
||||
for (const item of desired.values()) {
|
||||
const current = this.entries.get(item.name);
|
||||
if (current && isDeepStrictEqual(current.config,
|
||||
resolve_config(current.instance.config_template, (item.config ?? {}) as Record<string, unknown>))) continue;
|
||||
try { candidates.set(item.name, await this.prepare(item)); }
|
||||
catch (error) {
|
||||
if (!current && error instanceof PluginError && error.reason === 'LOAD_FAILED') {
|
||||
this.publish({name: item.name, path: item.path, config: (item.config ?? {}) as Record<string, unknown>,
|
||||
enabled: false, lifecycle: 'error', states: null});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
this.check_active();
|
||||
for (const name of Array.from(this.entries.keys())) {
|
||||
if (!desired.has(name) || candidates.has(name)) await this.remove(name);
|
||||
this.check_active();
|
||||
}
|
||||
for (const candidate of candidates.values()) await this.install(candidate);
|
||||
});
|
||||
}
|
||||
|
||||
init(plugins: bot_config_plugin<unknown>[]): Promise<void> {
|
||||
return this.enqueue(async () => {
|
||||
const names = new Set(this.entries.keys());
|
||||
for (const item of plugins) {
|
||||
validate_item(item);
|
||||
if (names.has(item.name)) throw new PluginError('ALREADY_INSTALLED');
|
||||
names.add(item.name);
|
||||
}
|
||||
const installed: string[] = [];
|
||||
try {
|
||||
for (const item of plugins) {
|
||||
let candidate: Entry;
|
||||
try { candidate = await this.prepare(item); }
|
||||
catch (error) {
|
||||
if (error instanceof PluginError && error.reason === 'LOAD_FAILED') {
|
||||
this.publish({name: item.name, path: item.path, config: (item.config ?? {}) as Record<string, unknown>,
|
||||
enabled: false, lifecycle: 'error', states: null});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await this.install(candidate);
|
||||
installed.push(item.name);
|
||||
}
|
||||
} catch (error) {
|
||||
const errors = [error];
|
||||
for (const name of installed.reverse()) {
|
||||
try { await this.remove(name); } catch (cleanup) { errors.push(cleanup); }
|
||||
}
|
||||
if (errors.length > 1) throw new PluginError('DISPOSE_FAILED');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get_plugin(name: string): PluginSnapshot | undefined {
|
||||
const record = this.records.get(name);
|
||||
return record && structuredClone(record);
|
||||
}
|
||||
|
||||
get_all_plugins(): PluginSnapshot[] {
|
||||
return Array.from(this.records.values(), record => structuredClone(record));
|
||||
}
|
||||
|
||||
inject(item: bot_config_plugin<unknown>): Promise<void> { return this.init([item]); }
|
||||
|
||||
re_inject(item: bot_config_plugin<unknown>): Promise<void> {
|
||||
return this.enqueue(async () => {
|
||||
const candidate = await this.prepare(item);
|
||||
await this.remove(item.name);
|
||||
await this.install(candidate);
|
||||
});
|
||||
}
|
||||
|
||||
update_config(name: string, config: Record<string, unknown>): Promise<void> {
|
||||
return this.enqueue(async () => {
|
||||
if (!valid_name(name) || !valid_plugin_config(config)) throw new PluginError('INVALID_CONFIG');
|
||||
const previous = this.entries.get(name);
|
||||
if (!previous || !this.records.get(name)?.enabled) throw new PluginError('NOT_INSTALLED');
|
||||
const candidate = await this.prepare({name, path: previous.path, config});
|
||||
this.check_active();
|
||||
await this.remove(name);
|
||||
await this.install(candidate);
|
||||
});
|
||||
}
|
||||
|
||||
private async remove(name: string) {
|
||||
const entry = this.entries.get(name);
|
||||
if (!entry) return;
|
||||
if (entry.quarantined) throw new PluginError('DISPOSE_FAILED');
|
||||
this.transition(entry, 'disabling');
|
||||
let failed = false;
|
||||
try { await entry.instance.dispose(); }
|
||||
catch { failed = true; }
|
||||
finally {
|
||||
try { entry.unsubscribe?.(); } catch { failed = true; }
|
||||
if (failed) entry.quarantined = true;
|
||||
else this.entries.delete(name);
|
||||
this.transition(entry, failed ? 'error' : 'disabled');
|
||||
}
|
||||
if (failed) throw new PluginError('DISPOSE_FAILED');
|
||||
}
|
||||
|
||||
uninject(name: string): Promise<void> { return this.enqueue(() => this.remove(name)); }
|
||||
|
||||
dispose(): Promise<void> {
|
||||
if (this.dispose_task) return this.dispose_task;
|
||||
this.disposed = true;
|
||||
this.dispose_task = this.tail.then(async () => {
|
||||
const errors: unknown[] = [];
|
||||
for (const name of Array.from(this.entries.keys()).reverse()) {
|
||||
try { await this.remove(name); } catch (error) { errors.push(error); }
|
||||
}
|
||||
this.listeners.clear();
|
||||
if (errors.length) throw new PluginError('DISPOSE_FAILED');
|
||||
});
|
||||
return this.dispose_task;
|
||||
}
|
||||
}
|
||||
|
||||
export async function create_plugin_manager(context: PluginContext, plugins: bot_config_plugin<unknown>[]): Promise<PluginManager> {
|
||||
const manager = new PluginManager(context);
|
||||
try { await manager.init(plugins); return manager; }
|
||||
catch (error) {
|
||||
try { await manager.dispose(); } catch { throw new PluginError('DISPOSE_FAILED'); }
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function get_plugin_list(): Promise<{path: string; name: string; path_config: base_define; plugin_config_template: plugin}[]> {
|
||||
const result: Awaited<ReturnType<typeof get_plugin_list>> = [];
|
||||
const directories = [''];
|
||||
const names = new Set<string>();
|
||||
for (const path of directories) {
|
||||
const directory = new URL(path ? `./${path}/` : './', import.meta.url);
|
||||
const entries = (await readdir(directory, {withFileTypes: true})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const entry of entries) if (entry.isDirectory() && valid_name(entry.name)) directories.push(path ? `${path}/${entry.name}` : entry.name);
|
||||
if (!path || !entries.some(entry => entry.isFile() && entry.name === 'define.ts')) continue;
|
||||
const {define} = await import_module(new URL('define.ts', directory)) as {define: base_define};
|
||||
if (define?.path !== path || !valid_i18n(define.i18n)) throw new Error(`Invalid plugin category: ${path}`);
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || entry.name === 'define.ts' || !/^[a-zA-Z0-9_-]+\.ts$/.test(entry.name)) continue;
|
||||
const module = await import_module(new URL(entry.name, directory));
|
||||
if (!Object.hasOwn(module, 'init')) continue;
|
||||
const name = entry.name.slice(0, -3);
|
||||
const instance = instantiate(module, name);
|
||||
if (names.has(name)) throw new Error(`Duplicate plugin: ${name}`);
|
||||
names.add(name);
|
||||
result.push({path, name, path_config: structuredClone(define), plugin_config_template: structuredClone(instance.config_template)});
|
||||
}
|
||||
}
|
||||
return result.sort((a, b) => a.path.localeCompare(b.path) || a.name.localeCompare(b.name));
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {bot_status} from "../../../types/bot/instance/bot_runtime";
|
||||
import {BasePlugin, type PluginContext, type plugin} from "../../../types/bot/plugins/plugin";
|
||||
|
||||
const config_template = {
|
||||
name: 'auto_reconnect' as const,
|
||||
i18n: [{lang: 'zh-CN', name: '自动重连', description: 'Bot 连接断开后自动重连'}],
|
||||
version: '1.0.0',
|
||||
config: [
|
||||
{
|
||||
name: 'reconnect_delay' as const,
|
||||
i18n: [{lang: 'zh-CN', name: '重连延时', description: '重连时的延时时间(单位:毫秒)'}],
|
||||
required: false,
|
||||
default: 1000,
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
name: 'reconnect_max_attempts' as const,
|
||||
i18n: [{lang: 'zh-CN', name: '最大重连次数', description: '最大重连次数,默认 10 次'}],
|
||||
required: false,
|
||||
default: 10,
|
||||
type: 'number',
|
||||
},
|
||||
],
|
||||
} satisfies plugin;
|
||||
|
||||
type auto_reconnect_status = {attempts: number};
|
||||
type auto_reconnect_config = {
|
||||
[K in typeof config_template['config'][number]['name']]: number;
|
||||
};
|
||||
|
||||
export class init extends BasePlugin<auto_reconnect_config, auto_reconnect_status> {
|
||||
readonly name = 'auto_reconnect';
|
||||
readonly config_template = config_template;
|
||||
private cleanup?: () => void;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.set_states({attempts: 0});
|
||||
}
|
||||
|
||||
async init(context: PluginContext, config: auto_reconnect_config): Promise<void> {
|
||||
const delay = Number.isInteger(config.reconnect_delay) && config.reconnect_delay >= 0
|
||||
&& config.reconnect_delay <= 2_147_483_647 ? config.reconnect_delay : 1000;
|
||||
const max_attempts = Number.isInteger(config.reconnect_max_attempts) && config.reconnect_max_attempts >= 0
|
||||
&& config.reconnect_max_attempts < 100 ? config.reconnect_max_attempts : 10;
|
||||
let uninjected = false;
|
||||
|
||||
const lost = () => {
|
||||
if (uninjected || this.get_status()!.attempts >= max_attempts) return;
|
||||
void context.schedule_reconnect(delay).catch(error => {
|
||||
if (context.logger) context.log([context.logger.error(`安排自动重连失败: ${String(error)}`)]);
|
||||
});
|
||||
};
|
||||
const attempt = () => { this.set_states({attempts: this.get_status()!.attempts + 1}); };
|
||||
const reset = () => {
|
||||
if (this.get_status()!.attempts !== 0) this.set_states({attempts: 0});
|
||||
};
|
||||
const updated = (value: bot_status) => {
|
||||
if (value === 'online') reset();
|
||||
};
|
||||
this.cleanup = () => {
|
||||
if (uninjected) return;
|
||||
uninjected = true;
|
||||
context.cancel_reconnect();
|
||||
context.off('connection_lost', lost);
|
||||
context.off('reconnect_attempt', attempt);
|
||||
context.off('connection_requested', reset);
|
||||
context.off('status_update', updated);
|
||||
};
|
||||
context.on('connection_lost', lost);
|
||||
context.on('reconnect_attempt', attempt);
|
||||
context.on('connection_requested', reset);
|
||||
context.on('status_update', updated);
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.cleanup?.();
|
||||
this.cleanup = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {base_define} from "../../../types/bot/plugins/plugin.ts";
|
||||
|
||||
export const define: base_define = {
|
||||
path: "runtime",
|
||||
i18n: [
|
||||
{
|
||||
lang: "zh-CN",
|
||||
name: "运行时插件",
|
||||
description: "Bot 运行时的插件,提供一些基础功能"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type {WorkflowCatalog} from '../../types/bot/workflow/metadata.ts';
|
||||
import {node_registry, query_registry, start_registry} from './registry.ts';
|
||||
import {event_registry} from './node_action/event_node/registry.ts';
|
||||
import {describe, edge_input_output, result_output} from './metadata.ts';
|
||||
import {node_fields, policy_fields} from './common_template.ts';
|
||||
import {assert_json} from './runtime/value.ts';
|
||||
import {validate_fields, validate_i18n, validate_output} from './runtime/template.ts';
|
||||
|
||||
export function validate_workflow_catalog(catalog: WorkflowCatalog): void {
|
||||
assert_json(catalog);
|
||||
if (catalog.catalog_version !== 1) throw new Error('Invalid workflow catalog version');
|
||||
for (const group of [catalog.nodes, catalog.start_nodes, catalog.events, catalog.queries]) {
|
||||
const names = new Set<string>();
|
||||
for (const item of group) {
|
||||
if (!/^[a-zA-Z0-9_-]{1,100}$/.test(item.name) || names.has(item.name) || !/^\d+\.\d+\.\d+$/.test(item.version)) {
|
||||
throw new Error('Invalid or duplicate workflow catalog entry');
|
||||
}
|
||||
names.add(item.name); validate_i18n(item.i18n); validate_fields(item.config); validate_output(item.output);
|
||||
}
|
||||
}
|
||||
for (const node of catalog.nodes) {
|
||||
const ports = new Set<string>();
|
||||
for (const port of node.ports) {
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(port.name) || ports.has(port.name)) throw new Error('Invalid workflow port');
|
||||
ports.add(port.name); validate_i18n(port.i18n);
|
||||
}
|
||||
if (!ports.has('failure')) throw new Error('Workflow node needs failure port');
|
||||
}
|
||||
validate_fields(catalog.common.node_fields); validate_fields(catalog.common.policy_fields);
|
||||
validate_output(catalog.common.result); validate_output(catalog.common.edge_input);
|
||||
for (const reference of catalog.common.references) validate_i18n(reference.i18n);
|
||||
}
|
||||
|
||||
/** Pure catalog inspection: no Bot, actor, event subscriptions or user code execution. */
|
||||
export function get_workflow_catalog(options: {allow_code?: boolean} = {}): WorkflowCatalog {
|
||||
const catalog: WorkflowCatalog = structuredClone({
|
||||
catalog_version: 1,
|
||||
nodes: Object.values(node_registry).map(module => module.config_template),
|
||||
start_nodes: Object.values(start_registry).map(module => module.config_template),
|
||||
events: Object.values(event_registry).map(module => module.config_template),
|
||||
queries: Object.values(query_registry).map(module => module.config_template),
|
||||
common: {
|
||||
node_fields, policy_fields, result: result_output, edge_input: edge_input_output,
|
||||
references: [
|
||||
{root: 'trigger', i18n: describe('触发参数', '本次运行的事件、手动输入或定时触发数据。')},
|
||||
{root: 'vars', i18n: describe('运行变量', '当前分支变量,合并后包含无冲突的分支更新。')},
|
||||
{root: 'outputs', i18n: describe('上游节点结果', '沿已执行连线传入的节点结果;条件分支和 any 合并不保证所有祖先都存在。')},
|
||||
{root: 'last_result', i18n: describe('最近结果', '当前路径最近一个节点的执行结果和节点 ID。')},
|
||||
{root: 'inputs', i18n: describe('当前入线', '以连线 ID 为键的直接上游结果;all 合并包含全部匹配入线。')},
|
||||
],
|
||||
},
|
||||
});
|
||||
const code = catalog.nodes.find(node => node.name === 'code');
|
||||
if (code) code.availability = options.allow_code
|
||||
? {available: true} : {available: false, reason: 'CODE_EXECUTION_DISABLED'};
|
||||
validate_workflow_catalog(catalog);
|
||||
return catalog;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type {WorkflowField} from '../../types/bot/workflow/metadata.ts';
|
||||
import {describe} from './metadata.ts';
|
||||
|
||||
export const node_fields: WorkflowField[] = [{
|
||||
name: 'timeout_ms', type: 'number', required: false, default: 30_000, integer: true, min: 1, max: 2_147_483_647,
|
||||
i18n: describe('节点超时', '节点最长执行时间,单位毫秒;超时会请求取消并等待操作清理。'),
|
||||
}];
|
||||
export const policy_fields: WorkflowField[] = [
|
||||
{name: 'max_concurrent_runs', type: 'number', required: false, default: 1, integer: true, min: 1, max: 256,
|
||||
i18n: describe('并行实例上限', '同一工作流同时运行的最大实例数,还受 bot 总并行上限限制。')},
|
||||
{name: 'on_busy', type: 'select', required: false, default: 'skip',
|
||||
i18n: describe('繁忙时策略', '达到 bot 总并行上限或本工作流实例上限时如何处理新触发。'), options: [
|
||||
{value: 'skip', i18n: describe('忽略', '直接忽略本次触发。')},
|
||||
{value: 'queue', i18n: describe('排队', '在等待队列未满时保存触发,空出名额后执行。')},
|
||||
]},
|
||||
{name: 'max_queue', type: 'number', required: false, default: 10, integer: true, min: 0, max: 1000,
|
||||
i18n: describe('等待队列上限', '本工作流最多保存的等待触发数量,0 表示不保存。')},
|
||||
{name: 'max_steps', type: 'number', required: false, default: 1000, integer: true, min: 1, max: 100000,
|
||||
i18n: describe('最大执行步数', '一次运行内所有分支累计执行的节点数上限。')},
|
||||
{name: 'timeout_ms', type: 'number', required: false, default: 60_000, integer: true, min: 1, max: 2_147_483_647,
|
||||
i18n: describe('运行超时', '从实际启动开始计算的整次运行时限,单位毫秒。')},
|
||||
];
|
||||
@@ -0,0 +1,18 @@
|
||||
import type {bot_instance_context} from '../instance/bot_instance_init.ts';
|
||||
import type {workflow_config} from '../../types/bot/workflow/workflow.ts';
|
||||
import {WorkflowManager} from './runtime/states_manager.ts';
|
||||
|
||||
export async function init_workflow(context: bot_instance_context<any>, workflow: workflow_config[]): Promise<void> {
|
||||
if (context.workflow_manager) throw new Error('Workflow manager is already initialized');
|
||||
const manager = new WorkflowManager(context, workflow, {source_config_revision: context.bot_config.config_revision ?? 0});
|
||||
context.workflow_manager = manager;
|
||||
try { manager.initialize(); }
|
||||
catch (error) {
|
||||
if (context.workflow_manager === manager) context.workflow_manager = null;
|
||||
await manager.dispose();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export {create_workflow_manager, WorkflowManager} from './runtime/states_manager.ts';
|
||||
export {get_workflow_catalog} from './catalog.ts';
|
||||
@@ -0,0 +1,35 @@
|
||||
import type {WorkflowI18n, WorkflowOutputTemplate, WorkflowPortTemplate} from '../../types/bot/workflow/metadata.ts';
|
||||
|
||||
export const describe = (name: string, description: string): WorkflowI18n[] => [{lang: 'zh-CN', name, description}];
|
||||
export const output = (type: WorkflowOutputTemplate['type'], name: string, description: string): WorkflowOutputTemplate =>
|
||||
({type, i18n: describe(name, description)});
|
||||
export const port = (name: string, label: string, description: string): WorkflowPortTemplate =>
|
||||
({name, i18n: describe(label, description)});
|
||||
export const failure_port = port('failure', '失败', '参数解析、执行或节点超时失败时执行此分支。');
|
||||
export const success_port = port('success', '成功', '节点执行成功后继续执行。');
|
||||
export const result_output: WorkflowOutputTemplate = {
|
||||
...output('object', '执行结果', '节点执行状态、输出和可选错误。'),
|
||||
properties: {
|
||||
status: output('string', '执行状态', 'success 或 failure。'),
|
||||
output: output('json', '节点输出', '具体结构由对应节点描述定义。'),
|
||||
execution: {...output('number', '执行序号', 'outputs 中保存的本次运行节点执行序号。'), optional: true},
|
||||
node_id: {...output('string', '节点 ID', 'last_result 中提供的最近执行节点 ID。'), optional: true},
|
||||
error: {...output('object', '错误', '失败时可能提供的错误详情。'), optional: true, properties: {
|
||||
code: output('string', '错误代码', '用于异常分流的稳定错误代码。'),
|
||||
message: output('string', '错误说明', '本次失败的可读说明。'),
|
||||
node_id: output('string', '错误节点', '产生错误的节点实例 ID。'),
|
||||
}},
|
||||
},
|
||||
};
|
||||
export const edge_input_output: WorkflowOutputTemplate = {
|
||||
...output('object', '入线数据', '一条连线传递的来源与执行结果。'),
|
||||
properties: {
|
||||
source: output('string', '来源节点', '上游节点实例 ID。'),
|
||||
port: output('string', '来源出口', '上游节点选择的出口名称。'),
|
||||
result: result_output,
|
||||
},
|
||||
};
|
||||
export const event_args_output: WorkflowOutputTemplate = {
|
||||
...output('array', '原始参数', '按事件参数顺序复制的安全 JSON 数据,不可序列化的参数为 null。'),
|
||||
items: {...output('json', '参数值', '事件原始参数的 JSON 副本。'), nullable: true},
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import {Worker} from 'node:worker_threads';
|
||||
import type {BranchState, NodeHandler} from '../../../types/bot/workflow/runtime.ts';
|
||||
import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts';
|
||||
import type {Json} from '../../../types/bot/workflow/workflow.ts';
|
||||
import {describe, failure_port, output, success_port} from '../metadata.ts';
|
||||
import {assert_json} from '../runtime/value.ts';
|
||||
|
||||
export const config_template: WorkflowNodeTemplate = {
|
||||
name: 'code', version: '1.0.0',
|
||||
i18n: describe('执行代码', '执行 JavaScript 异步函数体,通过 state 读取状态副本,并返回 JSON 值;是否可用由宿主配置决定。'),
|
||||
config: [
|
||||
{name: 'source', type: 'code', language: 'javascript', required: true, max_length: 100_000,
|
||||
i18n: describe('JavaScript 代码', '最多 100000 个字符,可使用 await;通过 return 返回有效 JSON 值,修改 state 副本不会写回工作流状态。')},
|
||||
],
|
||||
output: output('json', '代码返回值', '代码通过 return 返回并经校验的 JSON 值。'),
|
||||
ports: [success_port, failure_port],
|
||||
availability: {available: false, reason: 'CODE_EXECUTION_DISABLED'},
|
||||
};
|
||||
|
||||
const worker_source = `
|
||||
const {parentPort, workerData} = require('node:worker_threads');
|
||||
function serialize_output(output) {
|
||||
const ancestors = new Set();
|
||||
let entries = 0;
|
||||
let bytes = 0;
|
||||
function visit(value, depth) {
|
||||
if (depth > 64 || ++entries > 100000) throw new Error('Code output exceeds JSON limits');
|
||||
if (value === null || typeof value === 'boolean') bytes += 5;
|
||||
else if (typeof value === 'number' && Number.isFinite(value)) bytes += String(value).length;
|
||||
else if (typeof value === 'string') {
|
||||
if (value.length > 1048576) throw new Error('Code output exceeds JSON limits');
|
||||
bytes += Buffer.byteLength(JSON.stringify(value));
|
||||
} else if (typeof value === 'object') {
|
||||
const array = Array.isArray(value);
|
||||
if (!array && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
|
||||
throw new Error('Code output must contain plain JSON objects');
|
||||
if (ancestors.has(value)) throw new Error('Code output contains a cycle');
|
||||
ancestors.add(value);
|
||||
const keys = Reflect.ownKeys(value);
|
||||
if (keys.length > 100000 || (array && value.length > 100000)) throw new Error('Code output exceeds JSON limits');
|
||||
let count = 0;
|
||||
bytes += 2;
|
||||
for (const key of keys) {
|
||||
if (array && key === 'length') continue;
|
||||
if (typeof key !== 'string' || ['__proto__', 'prototype', 'constructor'].includes(key))
|
||||
throw new Error('Code output contains an unsafe key');
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (!descriptor.enumerable || !('value' in descriptor)) throw new Error('Code output must contain data properties');
|
||||
if (array) {
|
||||
const index = Number(key);
|
||||
if (!Number.isSafeInteger(index) || index < 0 || String(index) !== key || index >= value.length)
|
||||
throw new Error('Code output contains an invalid array');
|
||||
count++;
|
||||
} else {
|
||||
if (key.length > 1048576) throw new Error('Code output exceeds JSON limits');
|
||||
bytes += Buffer.byteLength(JSON.stringify(key)) + 1;
|
||||
}
|
||||
bytes++;
|
||||
visit(descriptor.value, depth + 1);
|
||||
}
|
||||
if (array && count !== value.length) throw new Error('Code output contains an array hole');
|
||||
ancestors.delete(value);
|
||||
} else throw new Error('Code output is not JSON');
|
||||
if (bytes > 1048576) throw new Error('Code output exceeds JSON limits');
|
||||
}
|
||||
try { visit(output, 0); }
|
||||
catch (error) { error.code = 'INVALID_JSON'; throw error; }
|
||||
return JSON.stringify(output);
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
||||
const execute = new AsyncFunction('state', '"use strict";\\n' + workerData.source);
|
||||
const output = await execute(JSON.parse(workerData.state));
|
||||
parentPort.postMessage({status: 'success', output: serialize_output(output)});
|
||||
} catch (error) {
|
||||
parentPort.postMessage({status: 'failure',
|
||||
code: typeof error?.code === 'string' ? error.code.slice(0, 256) : 'CODE_EXECUTION_FAILED',
|
||||
message: (typeof error?.message === 'string' ? error.message : String(error)).slice(0, 4096)});
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
export const execute: NodeHandler = async (node, state, signal, deps) => {
|
||||
if (node.type !== 'code') throw new Error('Expected a code node');
|
||||
signal.throwIfAborted();
|
||||
assert_json(state);
|
||||
const serialized = JSON.stringify(state);
|
||||
let output: Json;
|
||||
if (deps.execute_code) {
|
||||
output = await deps.execute_code(node.config.source, JSON.parse(serialized) as BranchState, signal);
|
||||
} else {
|
||||
const worker = new Worker(worker_source, {eval: true, execArgv: [],
|
||||
workerData: {source: node.config.source, state: serialized},
|
||||
stdout: true, stderr: true,
|
||||
resourceLimits: {maxOldGenerationSizeMb: 32, maxYoungGenerationSizeMb: 8, stackSizeMb: 4}});
|
||||
// Capture and discard worker output instead of forwarding it to the bot process.
|
||||
worker.stdout.resume();
|
||||
worker.stderr.resume();
|
||||
let on_abort: () => void;
|
||||
try {
|
||||
output = await new Promise<Json>((resolve, reject) => {
|
||||
on_abort = () => reject(signal.reason);
|
||||
worker.once('message', message => {
|
||||
if (message?.status === 'success') {
|
||||
try {
|
||||
if (typeof message.output !== 'string' || Buffer.byteLength(message.output) > 1_048_576) {
|
||||
throw Object.assign(new Error('Invalid code worker output'), {code: 'INVALID_JSON'});
|
||||
}
|
||||
resolve(JSON.parse(message.output));
|
||||
} catch (error) { reject(error); }
|
||||
}
|
||||
else reject(Object.assign(new Error(typeof message?.message === 'string'
|
||||
? message.message : 'Code execution failed'),
|
||||
{code: typeof message?.code === 'string' ? message.code : 'CODE_EXECUTION_FAILED'}));
|
||||
});
|
||||
worker.once('error', reject);
|
||||
worker.once('exit', code => reject(Object.assign(
|
||||
new Error(`Code worker exited before returning a result (${code})`), {code: 'CODE_WORKER_EXITED'})));
|
||||
signal.addEventListener('abort', on_abort, {once: true});
|
||||
if (signal.aborted) on_abort();
|
||||
});
|
||||
} finally {
|
||||
signal.removeEventListener('abort', on_abort!);
|
||||
await worker.terminate();
|
||||
worker.removeAllListeners();
|
||||
}
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
assert_json(output);
|
||||
return {port: 'success', result: {status: 'success', output}};
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import type {NodeHandler} from '../../../types/bot/workflow/runtime.ts';
|
||||
import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts';
|
||||
import {describe, failure_port, output, port} from '../metadata.ts';
|
||||
import {evaluate_condition} from '../runtime/value.ts';
|
||||
|
||||
export const config_template: WorkflowNodeTemplate = {
|
||||
name: 'condition', version: '1.0.0',
|
||||
i18n: describe('条件判断', '计算条件表达式,根据布尔结果选择为真或为假分支。'),
|
||||
config: [
|
||||
{name: 'condition', type: 'condition', required: true,
|
||||
i18n: describe('判断条件', '支持多个条件全部满足(and/且)、任一满足(or/或)及嵌套分组、取反;每项可使用 eq、contains、gt、lt 比较字面量或状态引用。')},
|
||||
],
|
||||
output: {...output('boolean', '判断结果', '条件表达式的布尔结果,true 和 false 均为成功执行;计算失败时输出 null。'), nullable: true},
|
||||
ports: [port('true', '为真', '条件结果为 true 时执行。'),
|
||||
port('false', '为假', '条件结果为 false 时执行。'), failure_port],
|
||||
};
|
||||
|
||||
export const execute: NodeHandler = async (node, state, signal) => {
|
||||
if (node.type !== 'condition') throw new Error('Expected a condition node');
|
||||
signal.throwIfAborted();
|
||||
const output = evaluate_condition(node.config.condition, state);
|
||||
return {port: output ? 'true' : 'false', result: {status: 'success', output}};
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import type {NodeHandler, NodeResult} from '../../../types/bot/workflow/runtime.ts';
|
||||
import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts';
|
||||
import {describe, failure_port, output, port} from '../metadata.ts';
|
||||
|
||||
export const config_template: WorkflowNodeTemplate = {
|
||||
name: 'error', version: '1.0.0',
|
||||
i18n: describe('异常分流', '检查上一节点的执行状态和错误代码,保留原执行结果并选择分支。'),
|
||||
config: [
|
||||
{name: 'codes', type: 'array', required: false,
|
||||
i18n: describe('错误代码', '省略此字段时匹配全部失败结果;提供列表时仅匹配其中的错误代码,显式空列表不匹配任何错误。'),
|
||||
item: {name: 'code', type: 'string', required: true,
|
||||
i18n: describe('错误代码', '与上一节点 error.code 精确匹配的字符串。')}},
|
||||
],
|
||||
output: output('json', '上一节点输出', '透传上一节点的 output,缺失时为 null;失败分支保留原失败状态及可选错误。'),
|
||||
ports: [port('normal', '正常', '上一节点不存在或状态不是 failure 时执行,节点状态为 success。'),
|
||||
port('error', '匹配异常', '上一节点失败且匹配错误代码规则时执行,保留 failure 状态。'),
|
||||
port('unmatched', '未匹配异常', '上一节点失败但不匹配错误代码规则时执行,保留 failure 状态。'), failure_port],
|
||||
};
|
||||
|
||||
export const execute: NodeHandler = async (node, state, signal) => {
|
||||
if (node.type !== 'error') throw new Error('Expected an error node');
|
||||
signal.throwIfAborted();
|
||||
const previous = state.last_result;
|
||||
if (!previous || previous.status !== 'failure') {
|
||||
return {port: 'normal', result: {status: 'success', output: previous?.output ?? null}};
|
||||
}
|
||||
const result: NodeResult = {status: previous.status, output: previous.output};
|
||||
if (previous.error) result.error = {...previous.error};
|
||||
const matches = node.config.codes === undefined
|
||||
|| (previous.error !== undefined && node.config.codes.includes(previous.error.code));
|
||||
return {port: matches ? 'error' : 'unmatched', result};
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import type {NodeHandler} from '../../../types/bot/workflow/runtime.ts';
|
||||
import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts';
|
||||
import {describe, failure_port, output, success_port} from '../metadata.ts';
|
||||
import {resolve_value} from '../runtime/value.ts';
|
||||
|
||||
export const config_template: WorkflowNodeTemplate = {
|
||||
name: 'function', version: '1.0.0',
|
||||
i18n: describe('调用函数', '解析参数值后调用机器人函数,返回 true 时成功,否则进入失败分支。'),
|
||||
provider: 'function',
|
||||
config: [
|
||||
{name: 'path', type: 'string', required: true,
|
||||
i18n: describe('函数路径', '函数所属目录,以 / 分隔;每段为 1 至 100 个字母、数字、下划线或连字符,禁止保留名称。')},
|
||||
{name: 'name', type: 'string', required: true, max_length: 100,
|
||||
i18n: describe('函数名称', '函数标识,由 1 至 100 个字母、数字、下划线或连字符组成,禁止保留名称。')},
|
||||
{name: 'args', type: 'array', required: true, default: [], max: 100,
|
||||
i18n: describe('函数参数', '按调用顺序提供参数,最多 100 项,每项可使用字面量或状态引用。'),
|
||||
item: {name: 'argument', type: 'value_source', required: true,
|
||||
i18n: describe('参数值', '解析字面量或状态引用,将得到的 JSON 值作为对应位置的函数参数。')}},
|
||||
],
|
||||
output: {...output('boolean', '函数返回值', '函数返回 true 时输出 true;返回其他值时输出 false,错误代码为 FUNCTION_RETURNED_FALSE;异常或超时输出 null。'), nullable: true},
|
||||
ports: [success_port, failure_port],
|
||||
};
|
||||
|
||||
export const execute: NodeHandler = async (node, state, signal, deps) => {
|
||||
if (node.type !== 'function') throw new Error('Expected a function node');
|
||||
signal.throwIfAborted();
|
||||
const output = await deps.run_function(node.config.path, node.config.name,
|
||||
node.config.args.map(source => resolve_value(source, state)), signal);
|
||||
signal.throwIfAborted();
|
||||
if (output !== true) {
|
||||
return {port: 'failure', result: {status: 'failure', output: false,
|
||||
error: {code: 'FUNCTION_RETURNED_FALSE', message: 'Function returned false', node_id: node.id}}};
|
||||
}
|
||||
return {port: 'success', result: {status: 'success', output}};
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type {NodeHandler} from '../../../types/bot/workflow/runtime.ts';
|
||||
import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts';
|
||||
import {describe, failure_port, output, port} from '../metadata.ts';
|
||||
import {assert_json} from '../runtime/value.ts';
|
||||
|
||||
export const config_template: WorkflowNodeTemplate = {
|
||||
name: 'goto', version: '1.0.0',
|
||||
i18n: describe('重试跳转', '按当前节点的重试计数限制跳转次数,重试目标由 retry 出口连线指定。'),
|
||||
config: [
|
||||
{name: 'max_retries', type: 'number', required: true, default: 2, integer: true, min: 0, max: 10_000,
|
||||
i18n: describe('最大重试次数', '允许经过 retry 出口的次数;0 表示直接进入次数耗尽分支,默认允许重试 2 次。')},
|
||||
{name: 'delay_ms', type: 'number', required: true, default: 0, integer: true, min: 0, max: 2_147_483_647,
|
||||
i18n: describe('重试间隔', '每次重试跳转前等待的毫秒数,0 表示不增加等待时长。')},
|
||||
],
|
||||
output: output('json', '上一节点输出', '重试或次数耗尽时均透传上一节点的 output,缺失时为 null。'),
|
||||
ports: [port('retry', '重试', '未达到最大重试次数时增加计数、等待指定间隔,再以 success 状态跳转;必须连接重试目标。'),
|
||||
port('exhausted', '次数耗尽', '已达到最大重试次数时以 failure 状态执行,错误代码为 RETRY_EXHAUSTED。'), failure_port],
|
||||
};
|
||||
|
||||
export const execute: NodeHandler = async (node, state, signal, deps) => {
|
||||
if (node.type !== 'goto') throw new Error('Expected a goto node');
|
||||
signal.throwIfAborted();
|
||||
const count = Object.hasOwn(state.retry_counts, node.id) ? state.retry_counts[node.id] : 0;
|
||||
if (!Number.isSafeInteger(count) || count < 0 || !Number.isSafeInteger(node.config.max_retries)
|
||||
|| node.config.max_retries < 0 || !Number.isFinite(node.config.delay_ms) || node.config.delay_ms < 0) {
|
||||
throw Object.assign(new Error('Invalid retry configuration or count'), {code: 'INVALID_RETRY'});
|
||||
}
|
||||
if (count >= node.config.max_retries) {
|
||||
return {port: 'exhausted', result: {status: 'failure', output: state.last_result?.output ?? null,
|
||||
error: {code: 'RETRY_EXHAUSTED', message: 'Retry limit exhausted', node_id: node.id}}};
|
||||
}
|
||||
const retry_counts = {[node.id]: count + 1};
|
||||
assert_json(retry_counts);
|
||||
await deps.sleep('milliseconds', node.config.delay_ms, signal);
|
||||
signal.throwIfAborted();
|
||||
return {port: 'retry', result: {status: 'success', output: state.last_result?.output ?? null}, retry_counts};
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import type {NodeHandler} from '../../../types/bot/workflow/runtime.ts';
|
||||
import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts';
|
||||
import {describe, failure_port, output, success_port} from '../metadata.ts';
|
||||
import {resolve_value} from '../runtime/value.ts';
|
||||
|
||||
export const config_template: WorkflowNodeTemplate = {
|
||||
name: 'log', version: '1.0.0',
|
||||
i18n: describe('记录日志', '解析消息值并按指定级别记录日志,同时输出解析后的原始值。'),
|
||||
config: [
|
||||
{name: 'level', type: 'select', required: true, default: 'info',
|
||||
i18n: describe('日志级别', '选择日志的记录级别;错误级别本身不会使节点执行失败。'),
|
||||
options: [
|
||||
{value: 'info', i18n: describe('信息', '记录一般运行信息。')},
|
||||
{value: 'warning', i18n: describe('警告', '记录需要关注的情况。')},
|
||||
{value: 'error', i18n: describe('错误', '记录错误信息,仍按正常日志节点继续执行。')},
|
||||
]},
|
||||
{name: 'message', type: 'value_source', required: true,
|
||||
i18n: describe('日志消息', '使用字面量或状态引用;字符串直接记录,其他 JSON 值序列化后记录。')},
|
||||
],
|
||||
output: output('json', '消息原值', '解析 message 得到的原始 JSON 值,非字符串值不会因日志序列化而改变输出类型。'),
|
||||
ports: [success_port, failure_port],
|
||||
};
|
||||
|
||||
export const execute: NodeHandler = async (node, state, signal, deps) => {
|
||||
if (node.type !== 'log') throw new Error('Expected a log node');
|
||||
signal.throwIfAborted();
|
||||
const output = resolve_value(node.config.message, state);
|
||||
deps.log(node.config.level, typeof output === 'string' ? output : JSON.stringify(output));
|
||||
signal.throwIfAborted();
|
||||
return {port: 'success', result: {status: 'success', output}};
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import type {NodeHandler} from '../../../types/bot/workflow/runtime.ts';
|
||||
import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts';
|
||||
import {describe, edge_input_output, failure_port, output, success_port} from '../metadata.ts';
|
||||
|
||||
export const config_template: WorkflowNodeTemplate = {
|
||||
name: 'merge', version: '1.0.0',
|
||||
i18n: describe('分支合流', '按全部入线汇合或每次到达独立继续的方式处理分支,输出本次入线数据。'),
|
||||
config: [
|
||||
{name: 'mode', type: 'select', required: true, default: 'all',
|
||||
i18n: describe('合流方式', '选择是否等待每一条入线;全部入线模式至少需要两条入线且不能作为入口节点。'),
|
||||
options: [
|
||||
{value: 'all', i18n: describe('全部入线', '等待同一轮次的每一条入线均到达后合并状态并执行一次;互斥分支可能导致入线无法到齐。')},
|
||||
{value: 'any', i18n: describe('每次到达', '每条入线每次到达都独立执行,不等待其他入线,也不会只执行首次到达。')},
|
||||
]},
|
||||
],
|
||||
output: {...output('object', '入线数据', '以连线 ID 为键的 inputs 对象;all 包含本轮全部入线,any 包含本次到达的入线。'),
|
||||
nullable: true, additional_properties: edge_input_output},
|
||||
ports: [success_port, failure_port],
|
||||
};
|
||||
|
||||
export const execute: NodeHandler = async (node, state, signal) => {
|
||||
if (node.type !== 'merge') throw new Error('Expected a merge node');
|
||||
signal.throwIfAborted();
|
||||
return {port: 'success', result: {status: 'success', output: state.inputs}};
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type {NodeHandler} from '../../../types/bot/workflow/runtime.ts';
|
||||
import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts';
|
||||
import {describe, failure_port, output, success_port} from '../metadata.ts';
|
||||
import {assert_json, resolve_value} from '../runtime/value.ts';
|
||||
|
||||
export const config_template: WorkflowNodeTemplate = {
|
||||
name: 'query', version: '1.0.0',
|
||||
i18n: describe('查询数据', '解析命名参数后调用查询提供方,返回 JSON 数据,并可将结果保存到工作流变量。'),
|
||||
provider: 'query',
|
||||
config: [
|
||||
{name: 'source', type: 'string', required: true, provider: 'query', max_length: 100,
|
||||
i18n: describe('查询来源', '选择查询提供方,标识由 1 至 100 个字母、数字、下划线或连字符组成,禁止保留名称。')},
|
||||
{name: 'args', type: 'map', required: true, default: {},
|
||||
i18n: describe('查询参数', '按名称提供参数;键为 1 至 100 个字母、数字、下划线或连字符,禁止保留名称。'),
|
||||
item: {name: 'argument', type: 'value_source', required: true,
|
||||
i18n: describe('参数值', '解析字面量或状态引用,将得到的 JSON 值传给对应名称的查询参数。')}},
|
||||
{name: 'save_as', type: 'string', required: false, max_length: 100,
|
||||
i18n: describe('保存变量', '可选,将查询输出保存到 vars 中的此名称;名称为 1 至 100 个字母、数字、下划线或连字符,禁止保留名称。')},
|
||||
],
|
||||
output: output('json', '查询结果', '查询提供方返回的原始 JSON 值,与可选保存到 vars 的值相同。'),
|
||||
ports: [success_port, failure_port],
|
||||
};
|
||||
|
||||
export const execute: NodeHandler = async (node, state, signal, deps) => {
|
||||
if (node.type !== 'query') throw new Error('Expected a query node');
|
||||
signal.throwIfAborted();
|
||||
const args = Object.fromEntries(Object.entries(node.config.args)
|
||||
.map(([key, source]) => [key, resolve_value(source, state)]));
|
||||
assert_json(args);
|
||||
const output = await deps.query(node.config.source, args, signal);
|
||||
signal.throwIfAborted();
|
||||
assert_json(output);
|
||||
const result = {status: 'success' as const, output};
|
||||
if (node.config.save_as !== undefined) {
|
||||
const vars = {[node.config.save_as]: output};
|
||||
assert_json(vars);
|
||||
return {port: 'success', result, vars};
|
||||
}
|
||||
return {port: 'success', result};
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type {NodeHandler} from '../../../types/bot/workflow/runtime.ts';
|
||||
import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts';
|
||||
import {describe, failure_port, output, success_port} from '../metadata.ts';
|
||||
|
||||
export const config_template: WorkflowNodeTemplate = {
|
||||
name: 'sleep', version: '1.0.0',
|
||||
i18n: describe('等待', '按毫秒或游戏刻等待指定时长,再继续执行。'),
|
||||
config: [
|
||||
{name: 'mode', type: 'select', required: true, default: 'milliseconds',
|
||||
i18n: describe('计时方式', '选择等待时长的单位。'),
|
||||
options: [
|
||||
{value: 'milliseconds', i18n: describe('毫秒', '按毫秒等待,1000 毫秒为 1 秒。')},
|
||||
{value: 'ticks', i18n: describe('游戏刻', '按游戏刻等待,实际时长取决于游戏刻推进速度。')},
|
||||
]},
|
||||
{name: 'value', type: 'number', required: true, default: 1000, integer: true, min: 0, max: 2_147_483_647,
|
||||
i18n: describe('等待时长', '使用所选单位的非负整数时长,0 表示不增加等待时长。')},
|
||||
],
|
||||
output: output('null', '等待结果', '等待完成后输出 null。'),
|
||||
ports: [success_port, failure_port],
|
||||
};
|
||||
|
||||
export const execute: NodeHandler = async (node, _state, signal, deps) => {
|
||||
if (node.type !== 'sleep') throw new Error('Expected a sleep node');
|
||||
signal.throwIfAborted();
|
||||
await deps.sleep(node.config.mode, node.config.value, signal);
|
||||
signal.throwIfAborted();
|
||||
return {port: 'success', result: {status: 'success', output: null}};
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import type {Json} from '../../../../types/bot/workflow/workflow.ts';
|
||||
import type {WorkflowEventTemplate} from '../../../../types/bot/workflow/metadata.ts';
|
||||
import {describe, output, event_args_output} from '../../metadata.ts';
|
||||
import {normalize_custom_event} from './custom.ts';
|
||||
|
||||
export const config_template: WorkflowEventTemplate = {
|
||||
name: 'chat', version: '1.0.0', readiness: 'spawned',
|
||||
i18n: describe('聊天事件', '接收玩家公开聊天,提供用户名、消息内容及安全的原始参数。'),
|
||||
config: [],
|
||||
output: {
|
||||
...output('object', '聊天数据', '从聊天事件前两个字符串参数提取的数据。'),
|
||||
properties: {
|
||||
args: event_args_output,
|
||||
event: output('string', '事件名称', '固定为 chat。'),
|
||||
username: output('string', '用户名', '发送公开聊天消息的玩家名称。'),
|
||||
message: output('string', '消息内容', '玩家发送的聊天文本。'),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function normalize(args: unknown[]): Record<string, Json> | undefined {
|
||||
if (args.length > 32 || typeof args[0] !== 'string' || typeof args[1] !== 'string') return;
|
||||
const original = normalize_custom_event('chat', args);
|
||||
if (!original) return;
|
||||
const trigger = {...original, username: args[0], message: args[1]};
|
||||
if (Buffer.byteLength(JSON.stringify(trigger)) <= 65_536) return trigger;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type {Json} from '../../../../types/bot/workflow/workflow.ts';
|
||||
import type {WorkflowEventTemplate} from '../../../../types/bot/workflow/metadata.ts';
|
||||
import {assert_json} from '../../runtime/value.ts';
|
||||
import {describe, output} from '../../metadata.ts';
|
||||
|
||||
export const config_template: WorkflowEventTemplate = {
|
||||
name: 'custom', version: '1.0.0', readiness: 'spawned', custom: true,
|
||||
i18n: describe('自定义事件', '监听自定义名称的事件,以有界 JSON 参数数组输出。any 事件保留原始事件名称作为首个参数。'),
|
||||
config: [{name: 'event', type: 'string', required: true,
|
||||
i18n: describe('事件名称', '填写要监听的事件名称;workflow_update 及 any 转发的 workflow_update 不触发工作流。')}],
|
||||
output: {
|
||||
...output('object', '自定义事件数据', '最多 32 个参数,输出 JSON 最大 64 KiB;无法安全转换的参数替换为 null。'),
|
||||
properties: {
|
||||
event: output('string', '事件名称', '订阅的事件名称;订阅 any 时为 any。'),
|
||||
args: {...output('array', '事件参数', '按原始顺序复制的 JSON 参数;非 JSON 对象或不安全值为 null。'),
|
||||
items: {...output('json', '参数值', '事件参数的 JSON 副本,无法安全转换时为 null。'), nullable: true}},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function normalize_custom_event(event: string, args: unknown[]): Record<string, Json> | undefined {
|
||||
try {
|
||||
if (args.length > 32) return;
|
||||
const trigger = {event, args: args.map(value => {
|
||||
try { assert_json(value); return structuredClone(value); }
|
||||
catch { return null; }
|
||||
})};
|
||||
assert_json(trigger);
|
||||
if (Buffer.byteLength(JSON.stringify(trigger)) <= 65_536) return trigger;
|
||||
} catch { /* Unsupported or oversized payloads skip this occurrence. */ }
|
||||
}
|
||||
|
||||
export function normalize(args: unknown[]): Record<string, Json> | undefined {
|
||||
return normalize_custom_event('custom', args);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type {Json} from '../../../../types/bot/workflow/workflow.ts';
|
||||
import type {WorkflowEventTemplate} from '../../../../types/bot/workflow/metadata.ts';
|
||||
import {describe, output, event_args_output} from '../../metadata.ts';
|
||||
import {normalize_custom_event} from './custom.ts';
|
||||
|
||||
export const config_template: WorkflowEventTemplate = {
|
||||
name: 'health', version: '1.0.0', readiness: 'spawned',
|
||||
i18n: describe('生命状态事件', '生命状态更新时发出通知。生命值和饥饿值需通过 info 查询单独读取。'),
|
||||
config: [],
|
||||
output: {
|
||||
...output('object', '生命状态通知', '包含事件名称和原始参数,不包含生命值或饥饿值快照。'),
|
||||
properties: {event: output('string', '事件名称', '固定为 health。'), args: event_args_output},
|
||||
},
|
||||
};
|
||||
|
||||
export function normalize(args: unknown[] = []): Record<string, Json> | undefined {
|
||||
return normalize_custom_event('health', args);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user