chore: initial commit
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
import {createHash} from 'node:crypto';
|
||||
import {types} from 'node:util';
|
||||
import type {Socket} from 'socket.io-client';
|
||||
import type {bot_instance_context} from '../bot/instance/bot_instance_init.ts';
|
||||
import {get_bot_context} from '../bot/instance/index.ts';
|
||||
import type {bot_config_plugin} from '../types/bot/plugins/plugin.ts';
|
||||
import type {workflow_config} from '../types/bot/workflow/workflow.ts';
|
||||
import {compile_workflow} from '../bot/workflow/runtime/compile.ts';
|
||||
import {plain_plugin_object, valid_plugin_config, valid_plugin_name, valid_plugin_path} from './plugin_validation.ts';
|
||||
import {console_print} from './console_print.ts';
|
||||
|
||||
type Context = bot_instance_context<unknown> & {readonly is_disposed?: boolean};
|
||||
interface Request {
|
||||
command_id: string;
|
||||
payload_version: 1;
|
||||
node_session_id: string;
|
||||
bot_id: number;
|
||||
config_revision: number;
|
||||
plugins: bot_config_plugin<unknown>[];
|
||||
workflows: workflow_config[];
|
||||
}
|
||||
type Reply = {status: 'success'; data: Pick<Request, 'command_id' | 'bot_id' | 'config_revision'> & {
|
||||
state: 'completed' | 'processing' | 'no_context';
|
||||
}} | {status: 'error'; data: {code: string; message: string}};
|
||||
type State = {revision: number; fingerprint?: string; applied_revision: number; applied_fingerprint?: string; tail: Promise<void>};
|
||||
type Job = {fingerprint: string; context: Context | undefined; reply: Reply; pending: boolean; failure_reported?: boolean};
|
||||
const max_bytes = 256 * 1024;
|
||||
const fields = ['command_id', 'payload_version', 'node_session_id', 'bot_id', 'config_revision', 'plugins', 'workflows'];
|
||||
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const positive = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
||||
const failure = (code: string, message: string): Reply => ({status: 'error', data: {code, message}});
|
||||
class ApplyGuardError extends Error {
|
||||
constructor(readonly code: string, message: string) { super(message); }
|
||||
}
|
||||
|
||||
// Reject accessors, proxies, sparse arrays and unsafe keys before hashing or cloning input.
|
||||
function bounded_json(value: unknown): void {
|
||||
let bytes = 0, entries = 0;
|
||||
const ancestors = new Set<object>();
|
||||
const visit = (item: unknown, depth: number): void => {
|
||||
if (depth > 64 || ++entries > 200000) throw new Error('Invalid JSON');
|
||||
if (item === null || typeof item === 'boolean' || typeof item === 'number' && Number.isFinite(item)) {
|
||||
bytes += JSON.stringify(item).length;
|
||||
} else if (typeof item === 'string') {
|
||||
if (item.length > max_bytes) throw new Error('Invalid JSON');
|
||||
bytes += Buffer.byteLength(JSON.stringify(item));
|
||||
} else if (item && typeof item === 'object') {
|
||||
if (types.isProxy(item) || ancestors.has(item)) throw new Error('Invalid JSON');
|
||||
const array = Array.isArray(item);
|
||||
if (!array && !plain_plugin_object(item)) throw new Error('Invalid JSON');
|
||||
const keys = Reflect.ownKeys(item);
|
||||
if (keys.length > 200000 || array && keys.length !== item.length + 1) throw new Error('Invalid JSON');
|
||||
ancestors.add(item);
|
||||
bytes += 2;
|
||||
let count = 0;
|
||||
for (const key of keys) {
|
||||
if (array && key === 'length') continue;
|
||||
if (typeof key !== 'string' || ['__proto__', 'prototype', 'constructor'].includes(key)) throw new Error('Invalid JSON');
|
||||
const descriptor = Object.getOwnPropertyDescriptor(item, key)!;
|
||||
if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) throw new Error('Invalid JSON');
|
||||
if (array && (!/^(0|[1-9]\d*)$/.test(key) || Number(key) >= item.length)) throw new Error('Invalid JSON');
|
||||
if (!array) bytes += Buffer.byteLength(JSON.stringify(key)) + 1;
|
||||
if (count++) bytes++;
|
||||
visit(descriptor.value, depth + 1);
|
||||
}
|
||||
ancestors.delete(item);
|
||||
} else throw new Error('Invalid JSON');
|
||||
if (bytes > max_bytes) throw new Error('Invalid JSON');
|
||||
};
|
||||
visit(value, 0);
|
||||
}
|
||||
|
||||
function fingerprint(value: unknown): string {
|
||||
const canonical = (item: any): any => Array.isArray(item) ? item.map(canonical)
|
||||
: item && typeof item === 'object' ? Object.fromEntries(Object.keys(item).sort().map(key => [key, canonical(item[key])])) : item;
|
||||
return createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex');
|
||||
}
|
||||
|
||||
function parse(value: unknown): Request {
|
||||
bounded_json(value);
|
||||
if (!plain_plugin_object(value) || Object.keys(value).length !== fields.length
|
||||
|| !fields.every(field => Object.hasOwn(value, field)) || typeof value.command_id !== 'string' || !uuid.test(value.command_id)
|
||||
|| value.payload_version !== 1 || typeof value.node_session_id !== 'string' || !value.node_session_id.trim()
|
||||
|| Buffer.byteLength(value.node_session_id) > 256 || !positive(value.bot_id) || !positive(value.config_revision)
|
||||
|| !Array.isArray(value.plugins) || !Array.isArray(value.workflows)) throw new Error('Invalid payload');
|
||||
const names = new Set<string>();
|
||||
for (const item of value.plugins) {
|
||||
if (!plain_plugin_object(item) || Object.keys(item).length !== 3 || !valid_plugin_name(item.name)
|
||||
|| !valid_plugin_path(item.path) || !valid_plugin_config(item.config) || names.has(item.name)) throw new Error('Invalid plugins');
|
||||
names.add(item.name);
|
||||
}
|
||||
// Compile the entire graph set before any plugin mutation, including duplicate IDs.
|
||||
const ids = new Set<string>();
|
||||
for (const item of value.workflows) {
|
||||
const {definition} = compile_workflow(item);
|
||||
if (ids.has(definition.id)) throw new Error('Duplicate workflow');
|
||||
ids.add(definition.id);
|
||||
}
|
||||
return structuredClone(value) as unknown as Request;
|
||||
}
|
||||
|
||||
export function create_bot_config_apply_handler(lookup: (bot_id: number) => Context | undefined = get_bot_context) {
|
||||
const jobs = new Map<string, Job>();
|
||||
const states = new WeakMap<Context, State>();
|
||||
let pending = 0;
|
||||
const trim = () => {
|
||||
let completed = jobs.size - pending;
|
||||
for (const [id, job] of jobs) {
|
||||
if (completed <= 100) break;
|
||||
if (!job.pending) { jobs.delete(id); completed--; }
|
||||
}
|
||||
};
|
||||
const success = (request: Request, state: 'completed' | 'processing' | 'no_context'): Reply => ({status: 'success', data: {
|
||||
command_id: request.command_id, bot_id: request.bot_id, config_revision: request.config_revision, state,
|
||||
}});
|
||||
const current = (request: Request, context: Context) => lookup(request.bot_id) === context && !context.is_disposed;
|
||||
const session = (socket: Socket, request: Request) => socket.connected && socket.id === request.node_session_id;
|
||||
|
||||
return async (socket: Socket, data: unknown, callback: unknown): Promise<void> => {
|
||||
if (typeof callback !== 'function') return;
|
||||
const ack = (reply: Reply) => { try { callback(structuredClone(reply)); } catch {} };
|
||||
let request: Request;
|
||||
try { request = parse(data); }
|
||||
catch { ack(failure('INVALID_PAYLOAD', '配置应用请求格式无效')); return; }
|
||||
if (!session(socket, request)) { ack(failure('SESSION_MISMATCH', '节点会话已失效')); return; }
|
||||
const content = fingerprint({plugins: request.plugins, workflows: request.workflows});
|
||||
const identity = fingerprint({bot_id: request.bot_id, config_revision: request.config_revision, content});
|
||||
const previous = jobs.get(request.command_id);
|
||||
if (previous) {
|
||||
if (previous.fingerprint !== identity) { ack(failure('COMMAND_CONFLICT', '命令标识已用于其他配置')); return; }
|
||||
if (previous.context && !current(request, previous.context)) { ack(failure('CONTEXT_CHANGED', '机器人上下文已失效')); return; }
|
||||
const retryable = !previous.pending && previous.reply.status === 'error'
|
||||
&& ['APPLY_FAILED', 'PARTIAL_APPLY_FAILED', 'SESSION_MISMATCH'].includes(previous.reply.data.code);
|
||||
if (!retryable || !previous.failure_reported) {
|
||||
ack(previous.reply);
|
||||
// Report the settled failure once, then allow the same command to retry safely.
|
||||
if (retryable) previous.failure_reported = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const context = lookup(request.bot_id);
|
||||
if (!context) {
|
||||
const reply = success(request, 'no_context');
|
||||
jobs.set(request.command_id, {fingerprint: identity, context, reply, pending: false});
|
||||
trim(); ack(reply); return;
|
||||
}
|
||||
if (!current(request, context)) { ack(failure('CONTEXT_CHANGED', '机器人上下文已失效')); return; }
|
||||
if (!context.plugin_manager || !context.workflow_manager) {
|
||||
ack(failure('MANAGER_UNAVAILABLE', '机器人配置管理器尚未就绪')); return;
|
||||
}
|
||||
let state = states.get(context);
|
||||
if (!state) {
|
||||
const config = context.bot_config;
|
||||
const revision = positive(config.config_revision) ? config.config_revision : 0;
|
||||
const initial = Array.isArray(config.plugins) && Array.isArray(config.workflow)
|
||||
? fingerprint({plugins: config.plugins, workflows: config.workflow}) : undefined;
|
||||
state = {revision, fingerprint: initial, applied_revision: revision, applied_fingerprint: initial, tail: Promise.resolve()};
|
||||
states.set(context, state);
|
||||
}
|
||||
if (request.config_revision < state.revision) { ack(failure('STALE_REVISION', '配置版本已过期')); return; }
|
||||
if (request.config_revision === state.revision && state.fingerprint && state.fingerprint !== content) {
|
||||
ack(failure('REVISION_CONFLICT', '相同配置版本的内容不一致')); return;
|
||||
}
|
||||
if (pending >= 32) { ack(failure('APPLY_BUSY', '配置应用队列已满')); return; }
|
||||
state.revision = request.config_revision;
|
||||
state.fingerprint = content;
|
||||
const job: Job = {fingerprint: identity, context, reply: success(request, 'processing'), pending: true};
|
||||
jobs.set(request.command_id, job);
|
||||
pending++;
|
||||
const installed_state = state;
|
||||
const manager = context.workflow_manager;
|
||||
const guard = () => {
|
||||
if (!current(request, context)) throw new ApplyGuardError('CONTEXT_CHANGED', '机器人上下文已失效');
|
||||
if (!session(socket, request)) throw new ApplyGuardError('SESSION_MISMATCH', '节点会话已失效');
|
||||
if (context.workflow_manager !== manager) throw new ApplyGuardError('CONTEXT_CHANGED', '机器人配置管理器已失效');
|
||||
};
|
||||
state.tail = state.tail.then(async () => {
|
||||
let plugin_applied = false;
|
||||
try {
|
||||
guard();
|
||||
if (request.config_revision < installed_state.revision) throw new ApplyGuardError('STALE_REVISION', '配置版本已过期');
|
||||
if (installed_state.applied_revision !== request.config_revision || installed_state.applied_fingerprint !== content) {
|
||||
await context.plugin_manager.apply_desired(request.plugins);
|
||||
plugin_applied = true;
|
||||
guard();
|
||||
await manager.apply(request.workflows, request.config_revision);
|
||||
guard();
|
||||
context.bot_config.plugins = structuredClone(request.plugins);
|
||||
context.bot_config.workflow = structuredClone(request.workflows);
|
||||
context.bot_config.config_revision = request.config_revision;
|
||||
installed_state.applied_revision = request.config_revision;
|
||||
installed_state.applied_fingerprint = content;
|
||||
}
|
||||
job.reply = success(request, 'completed');
|
||||
const message = `机器人 ${request.bot_id} 配置版本 ${request.config_revision} 应用完成`;
|
||||
try { console_print.log(console_print.success(message)); } catch {}
|
||||
try { if (context.logger) context.log([context.logger.info(message)]); } catch {}
|
||||
} catch (error) {
|
||||
// Transport replies and logs must never contain thrown plugin parameters or code.
|
||||
job.reply = error instanceof ApplyGuardError ? failure(error.code, error.message)
|
||||
: failure(plugin_applied ? 'PARTIAL_APPLY_FAILED' : 'APPLY_FAILED',
|
||||
plugin_applied ? '插件已应用,工作流应用失败,请重试完整配置' : '配置应用失败,请重试完整配置');
|
||||
const message = `机器人 ${request.bot_id} 配置版本 ${request.config_revision} 应用失败`;
|
||||
try { console_print.log(console_print.error(message)); } catch {}
|
||||
try { if (context.logger) context.log([context.logger.error(message)]); } catch {}
|
||||
} finally {
|
||||
job.pending = false;
|
||||
pending--;
|
||||
trim();
|
||||
}
|
||||
});
|
||||
ack(job.reply);
|
||||
};
|
||||
}
|
||||
|
||||
export const bot_config_apply_handler = create_bot_config_apply_handler();
|
||||
@@ -0,0 +1,63 @@
|
||||
interface CommandNode {
|
||||
flags?: {command_node_type?: number};
|
||||
children?: number[];
|
||||
redirectNode?: number;
|
||||
extraNodeData?: {name?: string; parser?: string; properties?: {type?: number | string}};
|
||||
}
|
||||
interface CommandTree {nodes: CommandNode[]; rootIndex: number}
|
||||
const trees = new WeakMap<object, CommandTree>();
|
||||
|
||||
export function remember_command_tree(instance: object, packet: unknown): void {
|
||||
const tree = packet as CommandTree;
|
||||
if (tree && Array.isArray(tree.nodes) && Number.isSafeInteger(tree.rootIndex)
|
||||
&& tree.nodes.length <= 100_000 && tree.nodes[tree.rootIndex]) trees.set(instance, tree);
|
||||
}
|
||||
|
||||
export function local_command_completion(instance: object, input: string, players: string[]): {matches: string[]; start: number; length: number} {
|
||||
const start = input.search(/\S*$/);
|
||||
const offset = start === 0 && input.startsWith('/') ? 1 : start;
|
||||
const result = {matches: [] as string[], start: offset, length: input.length - offset};
|
||||
const tree = trees.get(instance);
|
||||
if (!tree || !input.startsWith('/')) return result;
|
||||
// Walk complete arguments; only the final (possibly empty) token is being completed.
|
||||
const tokens = input.slice(1, start || 1).trim().split(/\s+/).filter(Boolean);
|
||||
let parents = [tree.rootIndex];
|
||||
const children = (index: number): number[] => {
|
||||
const node = tree.nodes[index];
|
||||
const redirect = node?.redirectNode;
|
||||
return Number.isSafeInteger(redirect) ? tree.nodes[redirect!]?.children ?? [] : node?.children ?? [];
|
||||
};
|
||||
for (const token of tokens) {
|
||||
const next: number[] = [];
|
||||
for (const parent of parents) {
|
||||
for (const index of children(parent)) {
|
||||
const node = tree.nodes[index];
|
||||
if (node?.flags?.command_node_type === 1 && node.extraNodeData?.name === token) next.push(index);
|
||||
else if (node?.flags?.command_node_type === 2) {
|
||||
const parser = node.extraNodeData?.parser;
|
||||
const kind = node.extraNodeData?.properties?.type;
|
||||
// Quoted and greedy strings need Brigadier parsing; leave their suggestions to the server.
|
||||
if (token.includes('"') || parser === 'minecraft:message' || parser === 'brigadier:string' && (kind === 2 || kind === 'greedy_phrase')) continue;
|
||||
next.push(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
parents = [...new Set(next)].slice(0, 256);
|
||||
}
|
||||
const prefix = input.slice(offset).toLowerCase();
|
||||
for (const parent of parents) {
|
||||
for (const index of children(parent)) {
|
||||
const node = tree.nodes[index];
|
||||
const data = node?.extraNodeData;
|
||||
const suggestions = node?.flags?.command_node_type === 1 ? [data?.name]
|
||||
: data?.parser === 'brigadier:bool' ? ['true', 'false']
|
||||
: data?.parser === 'minecraft:entity' || data?.parser === 'minecraft:game_profile' ? players : [];
|
||||
for (const match of suggestions) {
|
||||
if (typeof match === 'string' && match.length && match.length <= 256
|
||||
&& match.toLowerCase().startsWith(prefix) && !result.matches.includes(match)) result.matches.push(match);
|
||||
if (result.matches.length >= 100) return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import * as readline from 'node:readline';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { log, font_style } from '../types/utils/logger.ts';
|
||||
|
||||
// 将 font_style 转为 ANSI 参数;normal 不产生参数
|
||||
function style_codes(font_style: font_style[]): number[] {
|
||||
const codes: number[] = [];
|
||||
for (const style of font_style ?? []) {
|
||||
switch (style) {
|
||||
case 'bold':
|
||||
codes.push(1);
|
||||
break;
|
||||
case 'light':
|
||||
codes.push(2);
|
||||
break;
|
||||
case 'italic':
|
||||
codes.push(3);
|
||||
break;
|
||||
case 'underline':
|
||||
case 'url':
|
||||
codes.push(4);
|
||||
break;
|
||||
case 'normal':
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
// 解析 #rgb / #rrggbb,失败返回 null
|
||||
function parse_hex_color(color: string): [number, number, number] | null {
|
||||
if (typeof color !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const value = color.trim().replace(/^#/, '');
|
||||
if (/^[0-9a-fA-F]{3}$/.test(value)) {
|
||||
return [
|
||||
parseInt(value[0] + value[0], 16),
|
||||
parseInt(value[1] + value[1], 16),
|
||||
parseInt(value[2] + value[2], 16),
|
||||
];
|
||||
}
|
||||
if (/^[0-9a-fA-F]{6}$/.test(value)) {
|
||||
return [
|
||||
parseInt(value.slice(0, 2), 16),
|
||||
parseInt(value.slice(2, 4), 16),
|
||||
parseInt(value.slice(4, 6), 16),
|
||||
];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 将一段 log 渲染为带颜色的字符串
|
||||
function to_ansi(segment: log): string {
|
||||
const message = segment.message === undefined
|
||||
? 'undefined'
|
||||
: typeof segment.message === 'string'
|
||||
? segment.message
|
||||
: String(segment.message);
|
||||
|
||||
const codes = style_codes(segment.font_style ?? []);
|
||||
const rgb = parse_hex_color(segment.font_color);
|
||||
if (rgb !== null) {
|
||||
codes.push(38, 2, rgb[0], rgb[1], rgb[2]);
|
||||
}
|
||||
if (codes.length === 0) {
|
||||
return message;
|
||||
}
|
||||
return `\x1b[${codes.join(';')}m${message}\x1b[0m`;
|
||||
}
|
||||
|
||||
export type console_segment = log | string | number | boolean | null | undefined;
|
||||
export type console_input = console_segment | console_segment[];
|
||||
|
||||
export interface _ConsolePrint {
|
||||
info(message: any): log;
|
||||
success(message: any): log;
|
||||
warning(message: any): log;
|
||||
error(message: any): log;
|
||||
tip(message: any): log;
|
||||
custom(message: any, font_color: string, font_style: font_style[]): log;
|
||||
log(input: console_input): void;
|
||||
on(event: string | symbol, listener: (...args: any[]) => void): this;
|
||||
once(event: string | symbol, listener: (...args: any[]) => void): this;
|
||||
enable_input(prompt?: string): void;
|
||||
disable_input(): void;
|
||||
set_prompt(prompt: string): void;
|
||||
}
|
||||
|
||||
export class ConsolePrint extends EventEmitter implements _ConsolePrint {
|
||||
private rl: readline.Interface | null = null;
|
||||
private prompt = '> ';
|
||||
private input_enabled = false;
|
||||
// 输入行提交后是否需要重新显示提示符(write_line 已恢复时置为 false)
|
||||
private need_prompt = false;
|
||||
|
||||
// 监听 input 时自动开启控制台输入
|
||||
public on(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
if (event === 'input') {
|
||||
this.enable_input();
|
||||
}
|
||||
return super.on(event, listener);
|
||||
}
|
||||
|
||||
public once(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
if (event === 'input') {
|
||||
this.enable_input();
|
||||
}
|
||||
return super.once(event, listener);
|
||||
}
|
||||
|
||||
public info(message: any): log { // 白色
|
||||
return {
|
||||
message,
|
||||
font_color: '#ffffff',
|
||||
font_style: ['normal']
|
||||
};
|
||||
}
|
||||
|
||||
public success(message: any): log { // 柔和绿
|
||||
return {
|
||||
message,
|
||||
font_color: '#8fb98f',
|
||||
font_style: ['normal']
|
||||
};
|
||||
}
|
||||
|
||||
public warning(message: any): log { // 柔和沙黄
|
||||
return {
|
||||
message,
|
||||
font_color: '#d1b478',
|
||||
font_style: ['normal']
|
||||
};
|
||||
}
|
||||
|
||||
public error(message: any): log { // 柔和玫红
|
||||
return {
|
||||
message,
|
||||
font_color: '#cf8e8e',
|
||||
font_style: ['normal']
|
||||
};
|
||||
}
|
||||
|
||||
public tip(message: any): log { // 柔和钢蓝
|
||||
return {
|
||||
message,
|
||||
font_color: '#8fb0c9',
|
||||
font_style: ['normal']
|
||||
};
|
||||
}
|
||||
|
||||
public custom(message: any, font_color: string, font_style: font_style[]): log {
|
||||
return {
|
||||
message,
|
||||
font_color,
|
||||
font_style
|
||||
};
|
||||
}
|
||||
|
||||
// 多段拼接后在同一行输出,每段可拥有独立的颜色与样式;支持单段或数组
|
||||
public log(input: console_input): void {
|
||||
const segments: console_segment[] = Array.isArray(input) ? input : [input];
|
||||
if (segments.length === 0) {
|
||||
return;
|
||||
}
|
||||
const body = segments.map(segment => this.format(segment)).join('');
|
||||
this.write_line(this.format(this.timestamp()) + body);
|
||||
}
|
||||
|
||||
// 自动前缀:[month_day hh:mm:ss],统一使用 success 色
|
||||
private timestamp(): log {
|
||||
const now = new Date();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const hour = String(now.getHours()).padStart(2, '0');
|
||||
const minute = String(now.getMinutes()).padStart(2, '0');
|
||||
const second = String(now.getSeconds()).padStart(2, '0');
|
||||
return this.success(`[${month}-${day} ${hour}:${minute}:${second}] `);
|
||||
}
|
||||
|
||||
private format(segment: log | string | number | boolean | null | undefined): string {
|
||||
if (segment !== null && segment !== undefined && typeof segment === 'object' && 'message' in segment) {
|
||||
return to_ansi(segment as log);
|
||||
}
|
||||
return String(segment);
|
||||
}
|
||||
|
||||
// 有输入时先清掉当前输入行,打印日志后再把输入行恢复,避免输入被打断
|
||||
private write_line(text: string): void {
|
||||
if (this.rl !== null && this.input_enabled && process.stdout.isTTY === true) {
|
||||
readline.cursorTo(process.stdout, 0);
|
||||
readline.clearLine(process.stdout, 0);
|
||||
process.stdout.write(text + '\n');
|
||||
this.rl.prompt(true);
|
||||
this.need_prompt = false;
|
||||
return;
|
||||
}
|
||||
process.stdout.write(text + '\n');
|
||||
}
|
||||
|
||||
// 开启控制台输入,按回车后通过 'input' 事件抛出整行内容
|
||||
public enable_input(prompt?: string): void {
|
||||
if (prompt !== undefined) {
|
||||
this.prompt = prompt;
|
||||
}
|
||||
if (this.rl !== null) {
|
||||
this.rl.setPrompt(this.prompt);
|
||||
return;
|
||||
}
|
||||
this.rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: process.stdout.isTTY === true
|
||||
});
|
||||
this.rl.setPrompt(this.prompt);
|
||||
this.rl.on('line', (line: string) => {
|
||||
// 若输入处理期间没有输出日志,readline 已在换行后清空输入行,这里补回提示符;
|
||||
// 若期间输出过日志,write_line 已恢复提示符,避免重复
|
||||
this.need_prompt = true;
|
||||
this.emit('input', line);
|
||||
if (this.rl !== null && this.need_prompt) {
|
||||
this.rl.prompt();
|
||||
}
|
||||
this.need_prompt = false;
|
||||
});
|
||||
this.rl.on('close', () => {
|
||||
this.rl = null;
|
||||
this.input_enabled = false;
|
||||
});
|
||||
this.input_enabled = true;
|
||||
this.rl.prompt();
|
||||
}
|
||||
|
||||
public disable_input(): void {
|
||||
if (this.rl === null) {
|
||||
return;
|
||||
}
|
||||
const rl = this.rl;
|
||||
this.rl = null;
|
||||
this.input_enabled = false;
|
||||
rl.close();
|
||||
}
|
||||
|
||||
public set_prompt(prompt: string): void {
|
||||
this.prompt = prompt;
|
||||
this.rl?.setPrompt(prompt);
|
||||
if (this.rl !== null && this.input_enabled) {
|
||||
this.rl.prompt(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const console_print = new ConsolePrint();
|
||||
@@ -0,0 +1,219 @@
|
||||
import type {EnsureForward, ForwardAck, ForwardErrorCode, JoinReady, RelayRequest, WorkflowPayloads} from '../types/forward.ts';
|
||||
import {assert_json} from '../bot/workflow/runtime/value.ts';
|
||||
|
||||
const errors: Record<ForwardErrorCode, [string, boolean]> = {
|
||||
INVALID_REQUEST: ['Invalid request', false],
|
||||
UNAUTHORIZED: ['Authorization required', false],
|
||||
FORBIDDEN: ['Operation forbidden', false],
|
||||
NODE_OFFLINE: ['Node unavailable', true],
|
||||
RELAY_UNAVAILABLE: ['Relay unavailable', true],
|
||||
SESSION_STALE: ['Session is no longer current', false],
|
||||
TOKEN_EXPIRED: ['Authorization expired', false],
|
||||
TIMEOUT: ['Request deadline exceeded', true],
|
||||
CANCELLED: ['Request cancelled', false],
|
||||
NO_CONTEXT: ['Bot context unavailable', false],
|
||||
BOT_NOT_ONLINE: ['Bot is not online', false],
|
||||
RATE_LIMITED: ['Request limit exceeded', true],
|
||||
RESYNC_REQUIRED: ['Synchronization required', true],
|
||||
INTERNAL_ERROR: ['Operation failed', false],
|
||||
};
|
||||
|
||||
export class ForwardFailure extends Error {
|
||||
constructor(public readonly code: ForwardErrorCode) {
|
||||
super(errors[code][0]);
|
||||
}
|
||||
}
|
||||
|
||||
export function error_ack(code: ForwardErrorCode): ForwardAck<never> {
|
||||
const [message, retryable] = errors[code];
|
||||
return {status: 'error', data: {code, message, retryable}};
|
||||
}
|
||||
|
||||
export function failure_code(error: unknown): ForwardErrorCode {
|
||||
if (error !== null && typeof error === 'object' && 'code' in error
|
||||
&& typeof error.code === 'string' && Object.hasOwn(errors, error.code)) {
|
||||
return error.code as ForwardErrorCode;
|
||||
}
|
||||
return 'INTERNAL_ERROR';
|
||||
}
|
||||
|
||||
export function object(value: unknown): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === null || Object.getPrototypeOf(prototype) === null;
|
||||
}
|
||||
|
||||
export function exact(value: unknown, required: readonly string[], optional: readonly string[] = []): value is Record<string, unknown> {
|
||||
return object(value) && required.every(key => Object.hasOwn(value, key))
|
||||
&& Reflect.ownKeys(value).every(key => {
|
||||
if (typeof key !== 'string' || (!required.includes(key) && !optional.includes(key))) return false;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
|
||||
return descriptor.enumerable && 'value' in descriptor;
|
||||
});
|
||||
}
|
||||
|
||||
export const uuid = (value: unknown): value is string => typeof value === 'string'
|
||||
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
||||
export const integer = (value: unknown, min = 0): value is number => Number.isSafeInteger(value) && (value as number) >= min;
|
||||
const text = (value: unknown, max: number): value is string => typeof value === 'string' && value.length > 0 && value.length <= max;
|
||||
|
||||
export function validate_command(value: unknown): EnsureForward {
|
||||
if (!exact(value, ['forward_id', 'forward_session_id', 'node_session_id', 'node_id', 'bot_id',
|
||||
'url', 'path', 'namespace', 'token', 'expires_at', 'deadline_at'])
|
||||
|| !integer(value.forward_id) || !integer(value.node_id, 1) || !integer(value.bot_id, 1)
|
||||
|| !uuid(value.forward_session_id) || !text(value.node_session_id, 256) || !value.node_session_id.trim()
|
||||
|| Buffer.byteLength(value.node_session_id) > 256
|
||||
|| !text(value.token, 8192) || Buffer.byteLength(value.token) > 8192
|
||||
|| !integer(value.expires_at, 1) || !integer(value.deadline_at, 1)
|
||||
|| !text(value.url, 2048) || !text(value.path, 256) || !text(value.namespace, 256)
|
||||
|| !/^\/[a-zA-Z0-9_./-]*$/.test(value.path)
|
||||
|| !/^\/[a-zA-Z0-9_/-]*$/.test(value.namespace)) {
|
||||
throw new ForwardFailure('INVALID_REQUEST');
|
||||
}
|
||||
try {
|
||||
const url = new URL(value.url);
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash || url.search
|
||||
|| (url.pathname !== '' && url.pathname !== '/')) throw new Error();
|
||||
} catch {
|
||||
throw new ForwardFailure('INVALID_REQUEST');
|
||||
}
|
||||
return value as unknown as EnsureForward;
|
||||
}
|
||||
|
||||
export function success_data(value: unknown): unknown {
|
||||
if (!exact(value, ['status', 'data'])) throw new ForwardFailure('RELAY_UNAVAILABLE');
|
||||
if (value.status === 'error') {
|
||||
if (exact(value.data, ['code', 'message', 'retryable']) && typeof value.data.code === 'string'
|
||||
&& Object.hasOwn(errors, value.data.code) && typeof value.data.message === 'string'
|
||||
&& typeof value.data.retryable === 'boolean') {
|
||||
throw new ForwardFailure(value.data.code as ForwardErrorCode);
|
||||
}
|
||||
throw new ForwardFailure('RELAY_UNAVAILABLE');
|
||||
}
|
||||
if (value.status !== 'success') throw new ForwardFailure('RELAY_UNAVAILABLE');
|
||||
return value.data;
|
||||
}
|
||||
|
||||
export function validate_join(value: unknown, command: EnsureForward, joining: boolean): JoinReady {
|
||||
const data = success_data(value);
|
||||
if (!exact(data, ['forward_id', 'forward_session_id', 'node_session_id', 'node_id', 'bot_id', 'role', 'node_ready', 'expires_at'])
|
||||
|| data.forward_id !== command.forward_id || data.forward_session_id !== command.forward_session_id
|
||||
|| data.node_session_id !== command.node_session_id
|
||||
|| data.node_id !== command.node_id || data.bot_id !== command.bot_id || data.role !== 'node'
|
||||
|| typeof data.node_ready !== 'boolean' || (joining && data.node_ready !== false)
|
||||
|| data.expires_at !== command.expires_at || (data.expires_at as number) <= Date.now()) {
|
||||
throw new ForwardFailure('SESSION_STALE');
|
||||
}
|
||||
return data as unknown as JoinReady;
|
||||
}
|
||||
|
||||
export function validate_rpc(value: unknown): RelayRequest {
|
||||
if (!exact(value, ['request_id', 'method', 'payload', 'correlation_id', 'forward_session_id', 'deadline_at'])
|
||||
|| !uuid(value.request_id) || !uuid(value.correlation_id) || !uuid(value.forward_session_id)
|
||||
|| !integer(value.deadline_at, 1)) throw new ForwardFailure('INVALID_REQUEST');
|
||||
switch (value.method) {
|
||||
case 'workflow.start':
|
||||
case 'workflow.stop':
|
||||
case 'workflow.snapshot':
|
||||
case 'workflow.catalog':
|
||||
case 'workflow.runtime':
|
||||
case 'workflow.control':
|
||||
case 'workflow.command':
|
||||
validate_workflow_payload(value.method, value.payload);
|
||||
break;
|
||||
case 'execute_function':
|
||||
if (!exact(value.payload, ['function', 'name', 'args'])
|
||||
|| typeof value.payload.function !== 'string'
|
||||
|| !/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(value.payload.function)
|
||||
|| typeof value.payload.name !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(value.payload.name)
|
||||
|| !Array.isArray(value.payload.args)) throw new ForwardFailure('INVALID_REQUEST');
|
||||
break;
|
||||
case 'bot.snapshot':
|
||||
case 'get_log_history':
|
||||
case 'get_context_info':
|
||||
case 'get_data_file_list':
|
||||
if (!exact(value.payload, [])) throw new ForwardFailure('INVALID_REQUEST');
|
||||
break;
|
||||
case 'get_file_data': {
|
||||
const payload = value.payload;
|
||||
if (!exact(payload, ['id'], ['offset', 'length']) || !uuid(payload.id)
|
||||
|| (Object.hasOwn(payload, 'offset') && !integer(payload.offset))
|
||||
|| (Object.hasOwn(payload, 'length') && (!integer(payload.length, 1) || payload.length > 262_144))) {
|
||||
throw new ForwardFailure('INVALID_REQUEST');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'bot.logs': {
|
||||
const payload = value.payload;
|
||||
if (!exact(payload, ['limit'], ['stream_id', 'after_seq']) || !integer(payload.limit, 1) || payload.limit > 200
|
||||
|| (Object.hasOwn(payload, 'stream_id') && !uuid(payload.stream_id))
|
||||
|| (Object.hasOwn(payload, 'after_seq') && (!uuid(payload.stream_id) || !integer(payload.after_seq)))) {
|
||||
throw new ForwardFailure('INVALID_REQUEST');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'bot.chat':
|
||||
if (!exact(value.payload, ['message']) || !text(value.payload.message, 256)
|
||||
|| !value.payload.message.trim() || /[\r\n\u0000]/.test(value.payload.message)) throw new ForwardFailure('INVALID_REQUEST');
|
||||
break;
|
||||
case 'tab_complete':
|
||||
if (!exact(value.payload, ['text']) || typeof value.payload.text !== 'string'
|
||||
|| value.payload.text.length > 256 || /[\r\n\u0000]/.test(value.payload.text)) throw new ForwardFailure('INVALID_REQUEST');
|
||||
break;
|
||||
default:
|
||||
throw new ForwardFailure('INVALID_REQUEST');
|
||||
}
|
||||
return value as unknown as RelayRequest;
|
||||
}
|
||||
|
||||
export function validate_workflow_payload<M extends keyof WorkflowPayloads>(method: M, payload: unknown): WorkflowPayloads[M] {
|
||||
if (method === 'workflow.start') {
|
||||
if (!exact(payload, ['workflow_id'], ['trigger']) || typeof payload.workflow_id !== 'string'
|
||||
|| !/^[a-zA-Z0-9_-]{1,100}$/.test(payload.workflow_id)
|
||||
|| ['__proto__', 'prototype', 'constructor'].includes(payload.workflow_id)) {
|
||||
throw new ForwardFailure('INVALID_REQUEST');
|
||||
}
|
||||
if (Object.hasOwn(payload, 'trigger')) {
|
||||
try {
|
||||
if (!object(payload.trigger)) throw new Error();
|
||||
assert_json(payload.trigger);
|
||||
} catch { throw new ForwardFailure('INVALID_REQUEST'); }
|
||||
}
|
||||
} else if (method === 'workflow.catalog' || method === 'workflow.runtime') {
|
||||
if (!exact(payload, [])) throw new ForwardFailure('INVALID_REQUEST');
|
||||
} else if (method === 'workflow.command') {
|
||||
if (!exact(payload, ['command_id', 'context_generation']) || !uuid(payload.command_id)
|
||||
|| !uuid(payload.context_generation)) throw new ForwardFailure('INVALID_REQUEST');
|
||||
} else if (method === 'workflow.control') {
|
||||
try {assert_json(payload);} catch {throw new ForwardFailure('INVALID_REQUEST');}
|
||||
if (!object(payload) || !uuid(payload.command_id) || !uuid(payload.context_generation)
|
||||
|| !['run_once', 'trigger_start', 'trigger_stop', 'stop', 'stop_run', 'reload'].includes(payload.action as string)) {
|
||||
throw new ForwardFailure('INVALID_REQUEST');
|
||||
}
|
||||
const run = payload.action === 'stop_run';
|
||||
if (!exact(payload, ['command_id', 'context_generation', 'action', run ? 'run_id' : 'workflow_id'],
|
||||
[...(!run ? ['expected_generation'] : []), ...(payload.action === 'run_once' ? ['input'] : [])])
|
||||
|| run && !uuid(payload.run_id)
|
||||
|| !run && (typeof payload.workflow_id !== 'string' || !/^[a-zA-Z0-9_-]{1,100}$/.test(payload.workflow_id)
|
||||
|| ['__proto__', 'prototype', 'constructor'].includes(payload.workflow_id))
|
||||
|| Object.hasOwn(payload, 'expected_generation') && !integer(payload.expected_generation, 1)) {
|
||||
throw new ForwardFailure('INVALID_REQUEST');
|
||||
}
|
||||
if (Object.hasOwn(payload, 'input')) {
|
||||
try {
|
||||
if (!object(payload.input)) throw new Error();
|
||||
assert_json(payload.input);
|
||||
if (Buffer.byteLength(JSON.stringify(payload.input)) > 65_536) throw new Error();
|
||||
} catch {throw new ForwardFailure('INVALID_REQUEST');}
|
||||
}
|
||||
} else if (method === 'workflow.stop' || method === 'workflow.snapshot') {
|
||||
if (!exact(payload, method === 'workflow.stop' ? ['run_id'] : [], method === 'workflow.snapshot' ? ['run_id', 'include_branches'] : [])
|
||||
|| Object.hasOwn(payload, 'run_id') && !uuid(payload.run_id)
|
||||
|| Object.hasOwn(payload, 'include_branches') && (!Object.hasOwn(payload, 'run_id') || typeof payload.include_branches !== 'boolean')) {
|
||||
throw new ForwardFailure('INVALID_REQUEST');
|
||||
}
|
||||
} else {
|
||||
throw new ForwardFailure('INVALID_REQUEST');
|
||||
}
|
||||
return payload as WorkflowPayloads[M];
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {log} from '../types/utils/logger.ts';
|
||||
import zhCn from '../data/runtime/language/zh_cn.json';
|
||||
import {MinecraftJsonParser} from './message_json_parser.ts';
|
||||
|
||||
export type ItemValue = null | boolean | number | string | ItemValue[] | {[key: string]: ItemValue};
|
||||
export interface InventoryItem {
|
||||
slot: number | null;
|
||||
type: number;
|
||||
name: string;
|
||||
i18n: {lang: string; name: string; description: string}[];
|
||||
count: number;
|
||||
metadata: number;
|
||||
displayName: log[];
|
||||
lore: log[][];
|
||||
enchants: {id?: number; name?: string; i18n: InventoryItem['i18n']; level: number; hidden: boolean}[];
|
||||
durability: {used: number; max: number; remaining: number} | null;
|
||||
unbreakable: boolean;
|
||||
glint: boolean | null;
|
||||
book: {title: string; author: string; pages: log[][]} | null;
|
||||
details: Record<string, ItemValue>;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
const tags = new Set(['compound', 'list', 'string', 'byte', 'short', 'int', 'long', 'float', 'double', 'byteArray', 'intArray', 'longArray']);
|
||||
const record = (value: unknown): Record<string, unknown> => value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown> : {};
|
||||
const array = (value: unknown): unknown[] => Array.isArray(value) ? value : [];
|
||||
const number = (value: unknown, fallback = 0): number => typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
const identifier = (value: unknown): string => typeof value === 'string' ? value.replace(/^minecraft:/, '') : '';
|
||||
|
||||
/** Projects both legacy NBT and modern components; never returns raw tag wrappers or live Item objects. */
|
||||
export function parse_inventory_item(input: unknown, slot: number | null, registry: Bot['registry']): InventoryItem | null {
|
||||
if (!input || typeof input !== 'object') return null;
|
||||
let truncated = false;
|
||||
let nodes = 0;
|
||||
let text_left = 24_576;
|
||||
const ancestors = new Set<object>();
|
||||
const normalize = (value: unknown, depth = 0): ItemValue => {
|
||||
if (++nodes > 8192 || depth > 32) { truncated = true; return null; }
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'bigint') return value.toString();
|
||||
if (typeof value === 'string') {
|
||||
let text = '';
|
||||
for (const character of value) {
|
||||
const bytes = Buffer.byteLength(character);
|
||||
if (bytes > text_left) break;
|
||||
text += character;
|
||||
text_left -= bytes;
|
||||
}
|
||||
if (text !== value) truncated = true;
|
||||
return text;
|
||||
}
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value !== 'object') { truncated = true; return null; }
|
||||
if (ancestors.has(value)) { truncated = true; return null; }
|
||||
ancestors.add(value);
|
||||
try {
|
||||
if (Array.isArray(value) || Buffer.isBuffer(value)) {
|
||||
if (value.length > 1024) truncated = true;
|
||||
return Array.from(value as unknown[]).slice(0, 1024).map(entry => normalize(entry, depth + 1));
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
const type = descriptors.type?.value;
|
||||
if (typeof type === 'string' && tags.has(type) && descriptors.value && 'value' in descriptors.value
|
||||
&& Object.keys(descriptors).every(key => ['type', 'value', 'name'].includes(key))) {
|
||||
return normalize(descriptors.value.value, depth + 1);
|
||||
}
|
||||
const entries = Object.entries(descriptors).filter(([, descriptor]) => descriptor.enumerable);
|
||||
if (entries.length > 256) truncated = true;
|
||||
return Object.fromEntries(entries.slice(0, 256).map(([key, descriptor]) => {
|
||||
if (!('value' in descriptor)) { truncated = true; return [key, null]; }
|
||||
return [key, normalize(descriptor.value, depth + 1)];
|
||||
}));
|
||||
} finally { ancestors.delete(value); }
|
||||
};
|
||||
const text = (value: unknown): log[] => {
|
||||
let component = value;
|
||||
if (typeof component === 'string' && /^[\s]*[\[{\"]/.test(component)) {
|
||||
try { component = JSON.parse(component); } catch { /* Plain item names need not be JSON. */ }
|
||||
}
|
||||
const logs = MinecraftJsonParser.toLog(component as Parameters<typeof MinecraftJsonParser.toLog>[0], false);
|
||||
const colors = ['#000000', '#0000AA', '#00AA00', '#00AAAA', '#AA0000', '#AA00AA', '#FFAA00', '#AAAAAA',
|
||||
'#555555', '#5555FF', '#55FF55', '#55FFFF', '#FF5555', '#FF55FF', '#FFFF55', '#FFFFFF'];
|
||||
return logs.flatMap(segment => {
|
||||
let color = segment.font_color;
|
||||
let styles = segment.font_style.filter(style => style !== 'normal');
|
||||
const result: log[] = [];
|
||||
const pieces = String(segment.message).split(/(§[0-9a-fk-or])/gi);
|
||||
for (const piece of pieces) {
|
||||
if (/^§[0-9a-fk-or]$/i.test(piece)) {
|
||||
const code = piece[1].toLowerCase();
|
||||
const color_index = '0123456789abcdef'.indexOf(code);
|
||||
if (color_index >= 0) { color = colors[color_index]; styles = []; }
|
||||
else if (code === 'r') { color = '#FFFFFF'; styles = []; }
|
||||
else {
|
||||
const style = ({l: 'bold', o: 'italic', n: 'underline'} as const)[code as 'l' | 'o' | 'n'];
|
||||
if (style && !styles.includes(style)) styles = [...styles, style];
|
||||
}
|
||||
} else if (piece) result.push({message: piece, font_color: color, font_style: styles.length ? [...styles] : ['normal']});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
};
|
||||
const parse = (raw: unknown, item_slot: number | null, nesting: number): InventoryItem | null => {
|
||||
const item = record(raw);
|
||||
const raw_id = item.type ?? item.itemId ?? item.id;
|
||||
const lookup = typeof raw_id === 'string' ? registry?.itemsByName?.[identifier(raw_id)]
|
||||
: registry?.items?.[number(raw_id, -1)];
|
||||
const type = typeof raw_id === 'number' ? raw_id : lookup?.id;
|
||||
const count = number(item.count ?? item.itemCount ?? item.Count, 1);
|
||||
if (type == null || type < 0 || count <= 0) return null;
|
||||
const legacy = record(normalize(item.nbt ?? item.tag));
|
||||
const raw_components = new Map<string, unknown>();
|
||||
for (const entry of array(item.components)) {
|
||||
const component = record(entry);
|
||||
const key = identifier(component.type);
|
||||
if (key) raw_components.set(key, component.data);
|
||||
}
|
||||
// Item setters can update componentMap before rebuilding the components array.
|
||||
if (item.componentMap instanceof Map) for (const [key, component] of item.componentMap) {
|
||||
raw_components.set(identifier(key), record(component).data);
|
||||
}
|
||||
const components = Object.fromEntries(Array.from(raw_components, ([key, value]) => [key, normalize(value)]));
|
||||
const display = record(legacy.display);
|
||||
const name = typeof item.name === 'string' ? item.name : lookup?.name ?? (identifier(raw_id) || 'unknown');
|
||||
let displayName: log[] = [];
|
||||
for (const candidate of [components.custom_name, display.Name, components.item_name, item.displayName, lookup?.displayName, name]) {
|
||||
if (candidate == null) continue;
|
||||
displayName = text(candidate);
|
||||
if (displayName.length) break;
|
||||
}
|
||||
const lore = array(components.lore ?? display.Lore).slice(0, 128).map(text);
|
||||
if (array(components.lore ?? display.Lore).length > 128) truncated = true;
|
||||
const flags = number(legacy.HideFlags);
|
||||
const tooltip = record(components.tooltip_display);
|
||||
const hidden_components = array(tooltip.hiddenComponents);
|
||||
const component_types = registry?.protocol?.types?.SlotComponentType as unknown as [string, {mappings?: Record<string, string>}] | undefined;
|
||||
const hidden_names = hidden_components.map(value => typeof value === 'number'
|
||||
? component_types?.[1]?.mappings?.[String(value)] ?? value : identifier(value));
|
||||
const hidden_tooltip = Object.hasOwn(components, 'hide_tooltip') || tooltip.hideTooltip === true;
|
||||
const enchants: InventoryItem['enchants'] = [];
|
||||
const enchant_sources = [
|
||||
{source: components.enchantments ?? legacy.Enchantments ?? legacy.ench, hidden: Boolean(flags & 1)},
|
||||
{source: components.stored_enchantments ?? legacy.StoredEnchantments, hidden: Boolean(flags & 32)},
|
||||
];
|
||||
for (const {source, hidden} of enchant_sources) {
|
||||
const data = record(source);
|
||||
const entries = Array.isArray(source) ? source : Array.isArray(data.enchantments) ? data.enchantments
|
||||
: Object.entries(record(data.levels)).map(([id, level]) => ({id, level}));
|
||||
for (const entry of entries) {
|
||||
const enchant = record(entry);
|
||||
const raw_id = enchant.id ?? enchant.name;
|
||||
const id = typeof raw_id === 'number' ? raw_id : undefined;
|
||||
const name = typeof raw_id === 'string' ? identifier(raw_id) : registry?.enchantments?.[id ?? -1]?.name;
|
||||
enchants.push({...(id === undefined ? {} : {id}), ...(name ? {name} : {}),
|
||||
i18n: [{lang: 'zh-CN', name: (zhCn as Record<string, string>)[`enchantment.minecraft.${name}`]
|
||||
|| name || String(id ?? 'unknown'), description: ''}],
|
||||
level: number(enchant.level ?? enchant.lvl),
|
||||
hidden: hidden_tooltip || hidden || hidden_names.includes('enchantments') || hidden_names.includes('stored_enchantments')
|
||||
|| data.showTooltip === false || data.showInTooltip === false});
|
||||
}
|
||||
}
|
||||
const max = number(components.max_damage ?? item.maxDurability ?? lookup?.maxDurability);
|
||||
let used = number(components.damage ?? legacy.Damage ?? item.metadata);
|
||||
if (!Object.hasOwn(components, 'damage') && legacy.Damage == null && max > 0) {
|
||||
try { used = number(item.durabilityUsed, used); } catch { truncated = true; }
|
||||
}
|
||||
const book_data = record(components.written_book_content ?? components.writable_book_content
|
||||
?? (['written_book', 'writable_book'].includes(name) ? legacy : null));
|
||||
const written = name === 'written_book' || Object.hasOwn(components, 'written_book_content');
|
||||
const book = Array.isArray(book_data.pages) ? {
|
||||
title: String(book_data.rawTitle ?? book_data.title ?? ''), author: String(book_data.author ?? ''),
|
||||
pages: book_data.pages.slice(0, 100).map(page => {
|
||||
const content = record(page).content ?? page;
|
||||
// Writable books contain literal text, even when a page looks like JSON.
|
||||
return written ? text(content) : text({text: String(content ?? '')});
|
||||
}),
|
||||
} : null;
|
||||
if (array(book_data.pages).length > 100) truncated = true;
|
||||
const details: Record<string, ItemValue> = {};
|
||||
if (components.potion_contents != null || legacy.Potion != null || legacy.CustomPotionEffects != null) {
|
||||
const potion = record(components.potion_contents);
|
||||
details.potion = {
|
||||
type: (potion.potionId ?? legacy.Potion ?? null) as ItemValue,
|
||||
color: (potion.customColor ?? legacy.CustomPotionColor ?? null) as ItemValue,
|
||||
effects: array(potion.customEffects ?? legacy.CustomPotionEffects).map(entry => {
|
||||
const effect = record(entry);
|
||||
const id = effect.id ?? effect.Id;
|
||||
const data = record(effect.details ?? effect);
|
||||
const name = typeof id === 'string' ? identifier(id) : registry?.effects?.[number(id, -1)]?.name;
|
||||
return {id: id as ItemValue ?? null, name: name ?? null, amplifier: number(data.amplifier ?? data.Amplifier),
|
||||
duration: number(data.duration ?? data.Duration), ambient: Boolean(data.ambient ?? data.Ambient),
|
||||
showParticles: (data.showParticles ?? data.ShowParticles ?? true) as ItemValue};
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (components.attribute_modifiers != null || legacy.AttributeModifiers != null) {
|
||||
const modifiers = record(components.attribute_modifiers);
|
||||
details.attributes = {hidden: hidden_tooltip || Boolean(flags & 2) || hidden_names.includes('attribute_modifiers') || modifiers.showTooltip === false,
|
||||
modifiers: array(Array.isArray(components.attribute_modifiers) ? components.attribute_modifiers : modifiers.attributes ?? legacy.AttributeModifiers).map(entry => {
|
||||
const attribute = record(entry);
|
||||
return {
|
||||
type: (attribute.typeId ?? attribute.type ?? attribute.AttributeName ?? null) as ItemValue,
|
||||
name: String(attribute.name ?? attribute.Name ?? attribute.id ?? ''),
|
||||
amount: number(attribute.value ?? attribute.amount ?? attribute.Amount),
|
||||
operation: (attribute.operation ?? attribute.Operation ?? null) as ItemValue,
|
||||
slot: (attribute.slot ?? attribute.Slot ?? null) as ItemValue,
|
||||
hidden: record(attribute.display).type === 'hidden',
|
||||
displayName: text(record(attribute.display).component) as unknown as ItemValue,
|
||||
};
|
||||
})};
|
||||
}
|
||||
const nested = (entries: unknown[], preserve_slot: boolean): ItemValue[] => {
|
||||
if (nesting >= 4) { truncated = true; return []; }
|
||||
if (entries.length > 256) truncated = true;
|
||||
return entries.slice(0, 256).map((entry, index) => {
|
||||
const wrapper = record(entry);
|
||||
const raw = wrapper.item ?? entry;
|
||||
return parse(raw, preserve_slot ? number(wrapper.slot ?? wrapper.Slot, index) : null, nesting + 1) as unknown as ItemValue;
|
||||
});
|
||||
};
|
||||
const container = components.container ?? record(legacy.BlockEntityTag).Items;
|
||||
if (container != null) details.container = nested(array(record(container).contents ?? container), true);
|
||||
if (components.bundle_contents != null || name === 'bundle' && legacy.Items != null) {
|
||||
const contents = components.bundle_contents ?? legacy.Items;
|
||||
details.bundle = nested(array(record(contents).contents ?? contents), false);
|
||||
}
|
||||
const known_components = new Set(['custom_name', 'item_name', 'lore', 'enchantments', 'stored_enchantments', 'damage', 'max_damage',
|
||||
'unbreakable', 'enchantment_glint_override', 'written_book_content', 'writable_book_content', 'potion_contents', 'attribute_modifiers',
|
||||
'container', 'bundle_contents', 'tooltip_display', 'hide_tooltip', 'hide_additional_tooltip']);
|
||||
const known_legacy = new Set(['display', 'Enchantments', 'ench', 'StoredEnchantments', 'Damage', 'Unbreakable', 'HideFlags',
|
||||
'Potion', 'CustomPotionEffects', 'CustomPotionColor', 'AttributeModifiers']);
|
||||
if (book) for (const key of ['pages', 'title', 'author']) known_legacy.add(key);
|
||||
if (name === 'bundle') known_legacy.add('Items');
|
||||
const extra_legacy = Object.fromEntries(Object.entries(legacy).filter(([key]) => !known_legacy.has(key)));
|
||||
if (container != null) {
|
||||
const block = {...record(extra_legacy.BlockEntityTag)};
|
||||
delete block.Items;
|
||||
if (Object.keys(block).length) extra_legacy.BlockEntityTag = block;
|
||||
else delete extra_legacy.BlockEntityTag;
|
||||
}
|
||||
const extra_display = Object.fromEntries(Object.entries(display).filter(([key]) => !['Name', 'Lore'].includes(key)));
|
||||
if (Object.keys(extra_display).length) extra_legacy.display = extra_display;
|
||||
const extra: Record<string, unknown> = Object.fromEntries(Object.entries(components).filter(([key]) => !known_components.has(key)));
|
||||
if (Object.keys(extra_legacy).length) extra.legacy = extra_legacy;
|
||||
if (Object.keys(extra).length) details.extra = extra as ItemValue;
|
||||
if (flags || hidden_tooltip || Object.hasOwn(components, 'hide_additional_tooltip') || hidden_components.length) {
|
||||
details.tooltip = {hideAll: hidden_tooltip, hideAdditional: Object.hasOwn(components, 'hide_additional_tooltip'),
|
||||
hiddenComponents: hidden_names as ItemValue[], legacyFlags: flags};
|
||||
}
|
||||
return {
|
||||
slot: item_slot, type, name, count, metadata: number(item.metadata), displayName, lore, enchants,
|
||||
i18n: [{lang: 'zh-CN', name: (zhCn as Record<string, string>)[`block.minecraft.${name}`]
|
||||
|| (zhCn as Record<string, string>)[`item.minecraft.${name}`] || name, description: ''}],
|
||||
durability: max > 0 ? {used, max, remaining: Math.max(0, max - used)} : null,
|
||||
unbreakable: Object.hasOwn(components, 'unbreakable') ? components.unbreakable !== false : Boolean(legacy.Unbreakable),
|
||||
glint: typeof components.enchantment_glint_override === 'boolean' ? components.enchantment_glint_override : null,
|
||||
book, details, truncated,
|
||||
};
|
||||
};
|
||||
const result = parse(input, slot, 0);
|
||||
if (result) result.truncated = truncated;
|
||||
return result;
|
||||
}
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
import { WriteStream } from "fs";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {log, font_style} from "../types/utils/logger";
|
||||
|
||||
export interface LogHistoryRecord {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
log: log[];
|
||||
}
|
||||
|
||||
export interface _Logger {
|
||||
info(msg: string): void;
|
||||
success(msg: string): void;
|
||||
error(msg: string): void;
|
||||
warning(msg: string): void;
|
||||
tip(msg: string): void;
|
||||
custom(msg: string, font_color: string, font_style: font_style[]): void;
|
||||
log(log: log[]): void;
|
||||
get_recent_log(count: number): log[][];
|
||||
read_recent_log(count: number): Promise<LogHistoryRecord[]>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export class Logger implements _Logger {
|
||||
// 单个日志文件的最大字节数(50M)
|
||||
private static readonly MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||
// 同一目录下保留的最大日志文件数
|
||||
private static readonly MAX_FILES = 10;
|
||||
// 日志文件名格式:year-month-day_log.log / year-month-day_log_{index}.log
|
||||
private static readonly LOG_FILE_REGEX = /^\d{4}-\d{2}-\d{2}_log(_\d+)?\.log$/;
|
||||
// 重建写入流的最大失败次数与重试延时
|
||||
private static readonly MAX_RECREATE_FAILURES = 10;
|
||||
private static readonly RECREATE_DELAY = 1000;
|
||||
|
||||
private readonly log_dir: string;
|
||||
private writer!: WriteStream;
|
||||
// 当前日志所属日期(year-month-day)
|
||||
private day = '';
|
||||
// 当天的文件序号,0 为当天首个文件
|
||||
private index = 0;
|
||||
private size = 0;
|
||||
private write_queue: string[] = [];
|
||||
private in_flight_chunk: string | undefined;
|
||||
private writing = false;
|
||||
private recreate_failures = 0;
|
||||
private recreate_timer: NodeJS.Timeout | null = null;
|
||||
private stopped = false;
|
||||
private close_task: Promise<void> | undefined;
|
||||
constructor(log_dir: string) {
|
||||
this.log_dir = log_dir;
|
||||
this.day = this.format_day(new Date());
|
||||
this.open_writer();
|
||||
}
|
||||
|
||||
private bind_writer_error(): void {
|
||||
this.writer.on('error', (err: NodeJS.ErrnoException) => {
|
||||
this.writing = false;
|
||||
// 仅处理文件不存在,重建写入流;其他错误交由后续写入重试
|
||||
if (err.code === 'ENOENT') {
|
||||
this.schedule_recreate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 合并并发错误,避免同一时刻重复创建
|
||||
private schedule_recreate(): void {
|
||||
if (this.stopped || this.recreate_timer !== null) {
|
||||
return;
|
||||
}
|
||||
this.recreate_timer = setTimeout(() => {
|
||||
this.recreate_timer = null;
|
||||
this.recreate_writer();
|
||||
}, Logger.RECREATE_DELAY);
|
||||
this.recreate_timer.unref();
|
||||
}
|
||||
|
||||
// 重新创建写入流;累计失败达到上限后停止创建
|
||||
private recreate_writer(): void {
|
||||
if (this.stopped) {
|
||||
return;
|
||||
}
|
||||
this.recreate_failures++;
|
||||
if (this.recreate_failures >= Logger.MAX_RECREATE_FAILURES) {
|
||||
this.stopped = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.open_writer();
|
||||
this.recreate_failures = 0;
|
||||
this.flush();
|
||||
} catch {
|
||||
// 创建失败,排到下一次重试
|
||||
this.schedule_recreate();
|
||||
}
|
||||
}
|
||||
|
||||
private format_day(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// 当天首个文件为 year-month-day_log.log,超出大小后追加序号
|
||||
private build_filename(day: string, index: number): string {
|
||||
return index === 0 ? `${day}_log.log` : `${day}_log_${index}.log`;
|
||||
}
|
||||
|
||||
// 打开(或创建)当前应写入的文件,并接管已有大小
|
||||
private open_writer(): void {
|
||||
if (!fs.existsSync(this.log_dir)) {
|
||||
fs.mkdirSync(this.log_dir, { recursive: true });
|
||||
}
|
||||
const filename = this.build_filename(this.day, this.index);
|
||||
const file_path = path.join(this.log_dir, filename);
|
||||
try {
|
||||
this.size = fs.statSync(file_path).size;
|
||||
} catch {
|
||||
this.size = 0;
|
||||
}
|
||||
const broken = this.writer;
|
||||
if (broken) {
|
||||
try {
|
||||
broken.destroy();
|
||||
} catch {
|
||||
// 忽略已销毁流抛出的异常
|
||||
}
|
||||
}
|
||||
this.writer = fs.createWriteStream(file_path, {flags: 'a'});
|
||||
this.bind_writer_error();
|
||||
this.prune_files(filename);
|
||||
}
|
||||
|
||||
// 超出保留数量时删除最旧的日志文件(文件名按时间递增,保留当前文件)
|
||||
private prune_files(keep_filename: string): void {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(this.log_dir)
|
||||
.filter(name => Logger.LOG_FILE_REGEX.test(name))
|
||||
.sort();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
let excess = entries.length - Logger.MAX_FILES;
|
||||
for (const name of entries) {
|
||||
if (excess <= 0) break;
|
||||
if (name === keep_filename) continue;
|
||||
try {
|
||||
fs.rmSync(path.join(this.log_dir, name));
|
||||
excess--;
|
||||
} catch {
|
||||
// 删除失败则跳过,保留文件
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 跨天或写满时切换到新文件
|
||||
private ensure_writer(): void {
|
||||
const today = this.format_day(new Date());
|
||||
if (today !== this.day) {
|
||||
this.day = today;
|
||||
this.index = 0;
|
||||
this.open_writer();
|
||||
return;
|
||||
}
|
||||
if (this.size >= Logger.MAX_FILE_SIZE) {
|
||||
this.index++;
|
||||
this.open_writer();
|
||||
}
|
||||
}
|
||||
|
||||
public info(message: any): log { // 白色
|
||||
return {
|
||||
message,
|
||||
font_color: '#ffffff',
|
||||
font_style: ['normal']
|
||||
};
|
||||
}
|
||||
|
||||
public success(message: any): log { // 柔和绿
|
||||
return {
|
||||
message,
|
||||
font_color: '#8fb98f',
|
||||
font_style: ['normal']
|
||||
};
|
||||
}
|
||||
|
||||
public warning(message: any): log { // 柔和沙黄
|
||||
return {
|
||||
message,
|
||||
font_color: '#d1b478',
|
||||
font_style: ['normal']
|
||||
};
|
||||
}
|
||||
|
||||
public error(message: any): log { // 柔和玫红
|
||||
return {
|
||||
message,
|
||||
font_color: '#cf8e8e',
|
||||
font_style: ['normal']
|
||||
};
|
||||
}
|
||||
|
||||
public tip(message: any): log { // 柔和钢蓝
|
||||
return {
|
||||
message,
|
||||
font_color: '#8fb0c9',
|
||||
font_style: ['normal']
|
||||
};
|
||||
}
|
||||
|
||||
public custom(message: any, font_color: string, font_style: font_style[]): log {
|
||||
return {
|
||||
message,
|
||||
font_color,
|
||||
font_style
|
||||
};
|
||||
}
|
||||
|
||||
public log(log: log[]): void {
|
||||
if (this.stopped || log.length === 0) {
|
||||
return;
|
||||
}
|
||||
const data = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
log
|
||||
}
|
||||
this.write_queue.push(`${JSON.stringify(data)}\n`);
|
||||
this.flush();
|
||||
}
|
||||
|
||||
// 串行写入:上一条落盘后才写下一批,保证大量日志的顺序
|
||||
private flush(): void {
|
||||
if (this.writing || this.stopped) {
|
||||
return;
|
||||
}
|
||||
const chunk = this.write_queue.shift();
|
||||
if (chunk === undefined) {
|
||||
return;
|
||||
}
|
||||
this.ensure_writer();
|
||||
this.writing = true;
|
||||
this.in_flight_chunk = chunk;
|
||||
this.writer.write(chunk, (err) => {
|
||||
if (this.in_flight_chunk === chunk) this.in_flight_chunk = undefined;
|
||||
this.writing = false;
|
||||
if (err) {
|
||||
if (this.stopped) return;
|
||||
// 写入失败则回队,等待重建写入流后重试,避免丢日志
|
||||
this.write_queue.unshift(chunk);
|
||||
return;
|
||||
}
|
||||
this.size += Buffer.byteLength(chunk);
|
||||
this.flush();
|
||||
});
|
||||
}
|
||||
|
||||
public close(): Promise<void> {
|
||||
if (this.close_task) return this.close_task;
|
||||
this.stopped = true;
|
||||
if (this.recreate_timer) clearTimeout(this.recreate_timer);
|
||||
this.recreate_timer = null;
|
||||
this.close_task = new Promise<void>(resolve => {
|
||||
if (this.writer.closed) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
this.writer.once('close', () => {
|
||||
this.write_queue.length = 0;
|
||||
resolve();
|
||||
});
|
||||
this.writer.end(this.write_queue.join(''));
|
||||
});
|
||||
return this.close_task;
|
||||
}
|
||||
|
||||
private static parse_history_record(line: string): LogHistoryRecord | undefined {
|
||||
try {
|
||||
const record = JSON.parse(line);
|
||||
if (record && typeof record.id === 'string' &&
|
||||
typeof record.timestamp === 'number' && Number.isFinite(record.timestamp) &&
|
||||
Array.isArray(record.log)) {
|
||||
return record;
|
||||
}
|
||||
} catch {
|
||||
// Ignore corrupt or incomplete records.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public static async read_recent_log(log_dir: string, count: number): Promise<LogHistoryRecord[]> {
|
||||
if (!Number.isFinite(count) || count < 1) return [];
|
||||
count = Math.floor(count);
|
||||
let names: string[];
|
||||
try {
|
||||
names = (await fs.promises.readdir(log_dir))
|
||||
.filter(name => Logger.LOG_FILE_REGEX.test(name))
|
||||
.sort((a, b) => {
|
||||
const date_order = a.slice(0, 10).localeCompare(b.slice(0, 10));
|
||||
return date_order || Number(a.match(/_(\d+)\.log$/)?.[1] ?? 0) -
|
||||
Number(b.match(/_(\d+)\.log$/)?.[1] ?? 0);
|
||||
}).reverse();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const records: LogHistoryRecord[] = [];
|
||||
const max_line_bytes = 1024 * 1024;
|
||||
let budget = 16 * max_line_bytes;
|
||||
for (const name of names) {
|
||||
if (records.length >= count || budget <= 0) break;
|
||||
let file: fs.promises.FileHandle | undefined;
|
||||
try {
|
||||
file = await fs.promises.open(path.join(log_dir, name), 'r');
|
||||
let position = (await file.stat()).size;
|
||||
let pending = Buffer.alloc(0);
|
||||
let skipping = false;
|
||||
const accept = (line: Buffer) => {
|
||||
if (line.length > max_line_bytes) return;
|
||||
const record = Logger.parse_history_record(line.toString('utf8'));
|
||||
if (record) records.push(record);
|
||||
};
|
||||
while (position > 0 && records.length < count && budget > 0) {
|
||||
const length = Math.min(position, 64 * 1024, budget);
|
||||
position -= length;
|
||||
budget -= length;
|
||||
const chunk = Buffer.allocUnsafe(length);
|
||||
const {bytesRead} = await file.read(chunk, 0, length, position);
|
||||
const data = Buffer.concat([chunk.subarray(0, bytesRead), pending]);
|
||||
let end = data.length;
|
||||
let newline: number;
|
||||
while ((newline = data.lastIndexOf(10, end - 1)) >= 0 && end > 0) {
|
||||
if (!skipping) accept(data.subarray(newline + 1, end));
|
||||
skipping = false;
|
||||
end = newline;
|
||||
if (records.length >= count) break;
|
||||
}
|
||||
// Discard oversized lines until their preceding newline is found.
|
||||
if (skipping || end > max_line_bytes) {
|
||||
pending = Buffer.alloc(0);
|
||||
skipping = true;
|
||||
} else {
|
||||
pending = Buffer.from(data.subarray(0, end));
|
||||
}
|
||||
}
|
||||
if (position === 0 && !skipping && records.length < count) accept(pending);
|
||||
} catch {
|
||||
// Rotation or removal can race a history read; continue with older files.
|
||||
} finally {
|
||||
if (file) await file.close();
|
||||
}
|
||||
}
|
||||
return records.reverse();
|
||||
}
|
||||
|
||||
public async read_recent_log(count: number): Promise<LogHistoryRecord[]> {
|
||||
if (!Number.isFinite(count) || count < 1) return [];
|
||||
count = Math.floor(count);
|
||||
const snapshot = [this.in_flight_chunk, ...this.write_queue]
|
||||
.filter((chunk): chunk is string => chunk !== undefined)
|
||||
.map(chunk => Logger.parse_history_record(chunk))
|
||||
.filter((record): record is LogHistoryRecord => record !== undefined);
|
||||
const snapshot_ids = new Set(snapshot.map(record => record.id));
|
||||
const records = (await Logger.read_recent_log(this.log_dir, count))
|
||||
.filter(record => !snapshot_ids.has(record.id));
|
||||
const ids = new Set(records.map(record => record.id));
|
||||
for (const record of snapshot) {
|
||||
if (ids.has(record.id)) continue;
|
||||
ids.add(record.id);
|
||||
records.push(record);
|
||||
}
|
||||
return records.slice(-count);
|
||||
}
|
||||
|
||||
// 读取最近的 count 条日志,跨文件时自动按时间顺序拼接
|
||||
public get_recent_log(count: number): log[][] {
|
||||
if (count <= 0) {
|
||||
return [];
|
||||
}
|
||||
let names: string[];
|
||||
try {
|
||||
names = fs.readdirSync(this.log_dir)
|
||||
.filter(name => Logger.LOG_FILE_REGEX.test(name))
|
||||
.sort()
|
||||
.reverse();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const lines: string[] = [];
|
||||
for (const name of names) {
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(path.join(this.log_dir, name), 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const file_lines = content.split('\n').filter(line => line.length > 0);
|
||||
for (let i = file_lines.length - 1; i >= 0 && lines.length < count; i--) {
|
||||
lines.push(file_lines[i]);
|
||||
}
|
||||
if (lines.length >= count) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 收集顺序为从新到旧,反转恢复为时间正序
|
||||
lines.reverse();
|
||||
return lines.map(line => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return line;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import zhCn from '../data/runtime/language/zh_cn.json';
|
||||
import type {font_style, log} from '../types/utils/logger';
|
||||
|
||||
type TextComponent = MinecraftTextComponent | NbtTag | string | number | boolean | TextComponent[];
|
||||
interface NbtTag {
|
||||
type: string;
|
||||
value: unknown;
|
||||
}
|
||||
type Format = 'bold' | 'italic' | 'underlined' | 'strikethrough' | 'obfuscated';
|
||||
const formats: Format[] = ['bold', 'italic', 'underlined', 'strikethrough', 'obfuscated'];
|
||||
const maxDepth = 64;
|
||||
|
||||
interface MinecraftTextComponent {
|
||||
text?: string;
|
||||
translate?: string;
|
||||
fallback?: string;
|
||||
with?: TextComponent[];
|
||||
color?: string;
|
||||
bold?: boolean | number;
|
||||
italic?: boolean | number;
|
||||
underlined?: boolean | number;
|
||||
strikethrough?: boolean | number;
|
||||
obfuscated?: boolean | number;
|
||||
extra?: TextComponent[];
|
||||
hoverEvent?: HoverEvent;
|
||||
hover_event?: HoverEvent;
|
||||
clickEvent?: ClickEvent;
|
||||
click_event?: ClickEvent;
|
||||
insertion?: string;
|
||||
font?: string;
|
||||
json?: TextComponent;
|
||||
warn?: Function;
|
||||
}
|
||||
|
||||
interface HoverEvent {
|
||||
action: 'show_text' | 'show_item' | 'show_entity';
|
||||
contents?: TextComponent | Record<string, unknown>;
|
||||
value?: TextComponent | Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ClickEvent {
|
||||
action: 'open_url' | 'open_file' | 'run_command' | 'suggest_command' | 'change_page' | 'copy_to_clipboard' | 'show_dialog' | 'custom';
|
||||
value?: string;
|
||||
command?: string;
|
||||
url?: string;
|
||||
page?: string | number;
|
||||
path?: string;
|
||||
dialog?: string;
|
||||
id?: string;
|
||||
payload?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface ParsedMessage {
|
||||
username: string;
|
||||
message: string;
|
||||
log: log[];
|
||||
normalized: MinecraftTextComponent | null;
|
||||
plainText: string;
|
||||
}
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
black: '#000000', dark_blue: '#0000AA', dark_green: '#00AA00', dark_aqua: '#00AAAA',
|
||||
dark_red: '#AA0000', dark_purple: '#AA00AA', gold: '#FFAA00', gray: '#AAAAAA',
|
||||
dark_gray: '#555555', blue: '#5555FF', green: '#55FF55', aqua: '#55FFFF',
|
||||
red: '#FF5555', light_purple: '#FF55FF', yellow: '#FFFF55', white: '#FFFFFF'
|
||||
};
|
||||
|
||||
/** Minecraft / prismarine-chat 文本组件解析器。 */
|
||||
export class MinecraftJsonParser {
|
||||
static parse(jsonData: TextComponent | null | undefined): ParsedMessage {
|
||||
const normalized = this.normalizeJson(jsonData);
|
||||
const entries = this.toLog(normalized);
|
||||
const plainText = entries.map(entry => entry.message).join('');
|
||||
return {
|
||||
...this.parseUsernameAndMessage(plainText),
|
||||
plainText,
|
||||
log: entries,
|
||||
normalized
|
||||
};
|
||||
}
|
||||
|
||||
static normalizeJson(node: TextComponent | null | undefined, depth = 0): MinecraftTextComponent | null {
|
||||
if (node == null || depth > maxDepth) return null;
|
||||
if (typeof node !== 'object') return {text: String(node)};
|
||||
if (!Array.isArray(node) && 'type' in node && 'value' in node) {
|
||||
const unwrap = (tag: unknown, level: number): unknown => {
|
||||
if (level > maxDepth || tag == null) return null;
|
||||
if (Array.isArray(tag)) return tag.map(value => unwrap(value, level + 1));
|
||||
if (typeof tag !== 'object') return tag;
|
||||
const value = tag as NbtTag;
|
||||
if (value.type === 'compound' && value.value && typeof value.value === 'object') {
|
||||
return Object.fromEntries(Object.entries(value.value).map(([key, child]) => [key, unwrap(child, level + 1)]));
|
||||
}
|
||||
if (value.type === 'list' && value.value && typeof value.value === 'object') {
|
||||
const list = value.value as {type: string; value: unknown[]};
|
||||
return Array.isArray(list.value) ? list.value.map(child => unwrap({type: list.type, value: child}, level + 1)) : [];
|
||||
}
|
||||
if ('value' in value) return value.value;
|
||||
return tag;
|
||||
};
|
||||
return this.normalizeJson(unwrap(node, depth) as TextComponent, depth + 1);
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
// Minecraft 数组中的后续组件继承第一个组件的样式。
|
||||
const [first, ...rest] = node;
|
||||
const root = this.normalizeJson(first, depth + 1);
|
||||
if (!root) return null;
|
||||
const extra = rest.map(child => this.normalizeJson(child, depth + 1))
|
||||
.filter((child): child is MinecraftTextComponent => child !== null);
|
||||
if (extra.length) root.extra = [...(root.extra ?? []), ...extra];
|
||||
return root;
|
||||
}
|
||||
|
||||
// ChatMessage 的原始协议组件保留了 font、事件和完整的 Unicode 文本。
|
||||
if (node.json != null && typeof node.warn === 'function') {
|
||||
return this.normalizeJson(node.json, depth + 1);
|
||||
}
|
||||
|
||||
const result: MinecraftTextComponent = {};
|
||||
if (node.text !== undefined) result.text = String(node.text ?? '');
|
||||
if (node.translate !== undefined) result.translate = node.translate;
|
||||
if (node.fallback !== undefined) result.fallback = node.fallback;
|
||||
if (node.color) result.color = node.color;
|
||||
if (node.font) result.font = node.font;
|
||||
for (const format of formats) {
|
||||
// false / 0 必须保留,用于关闭父组件继承下来的样式。
|
||||
if (node[format] !== undefined) result[format] = !!node[format];
|
||||
}
|
||||
const hoverEvent = this.extractHoverEvent(node);
|
||||
if (hoverEvent) result.hoverEvent = hoverEvent;
|
||||
const clickEvent = this.extractClickEvent(node);
|
||||
if (clickEvent) result.clickEvent = clickEvent;
|
||||
const insertion = node.insertion ?? (node.json as MinecraftTextComponent)?.insertion;
|
||||
if (insertion !== undefined) result.insertion = insertion;
|
||||
|
||||
if (Array.isArray(node.with)) {
|
||||
result.with = node.with.map(child => this.normalizeJson(child, depth + 1) ?? {text: ''});
|
||||
}
|
||||
if (Array.isArray(node.extra)) {
|
||||
result.extra = node.extra.map(child => this.normalizeJson(child, depth + 1))
|
||||
.filter((child): child is MinecraftTextComponent => child !== null);
|
||||
}
|
||||
return Object.keys(result).length ? result : null;
|
||||
}
|
||||
|
||||
static extractHoverEvent(node: MinecraftTextComponent): HoverEvent | undefined {
|
||||
const raw = node.json as MinecraftTextComponent | undefined;
|
||||
return node.hoverEvent ?? node.hover_event ?? raw?.hoverEvent ?? raw?.hover_event;
|
||||
}
|
||||
|
||||
static extractClickEvent(node: MinecraftTextComponent): ClickEvent | undefined {
|
||||
const raw = node.json as MinecraftTextComponent | undefined;
|
||||
const event = node.clickEvent ?? node.click_event ?? raw?.clickEvent ?? raw?.click_event;
|
||||
if (!event || typeof event.action !== 'string') return undefined;
|
||||
const value = event.value ?? (() => {
|
||||
switch (event.action) {
|
||||
case 'open_url': return event.url;
|
||||
case 'run_command':
|
||||
case 'suggest_command': return event.command;
|
||||
case 'change_page': return event.page;
|
||||
case 'open_file': return event.path;
|
||||
case 'show_dialog': return event.dialog;
|
||||
case 'custom': return event.payload ?? event.id;
|
||||
case 'copy_to_clipboard': return event.text;
|
||||
default: return undefined;
|
||||
}
|
||||
})();
|
||||
if (typeof value === 'string') return {action: event.action, value};
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return {action: event.action, value: String(value)};
|
||||
return undefined;
|
||||
}
|
||||
|
||||
static expandTranslate(node: MinecraftTextComponent): MinecraftTextComponent | null {
|
||||
if (node.translate === undefined) return null;
|
||||
const template = (zhCn as Record<string, string>)[node.translate] ?? node.fallback ?? node.translate;
|
||||
const params = node.with ?? [];
|
||||
const extra: TextComponent[] = [];
|
||||
let lastIndex = 0;
|
||||
let seqIndex = 0;
|
||||
for (const match of template.matchAll(/%%|%(?:(\d+)\$)?s/g)) {
|
||||
if (match.index > lastIndex) extra.push({text: template.slice(lastIndex, match.index)});
|
||||
if (match[0] === '%%') {
|
||||
extra.push({text: '%'});
|
||||
} else {
|
||||
const index = match[1] ? Number(match[1]) - 1 : seqIndex++;
|
||||
extra.push(params[index] ?? {text: match[0]});
|
||||
}
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
if (lastIndex < template.length) extra.push({text: template.slice(lastIndex)});
|
||||
// 在同一父组件下展开,保留事件、样式继承以及翻译文本后的 extra。
|
||||
const {translate, fallback, with: withParams, ...base} = node;
|
||||
return {...base, text: '', extra: [...extra, ...(node.extra ?? [])]};
|
||||
}
|
||||
|
||||
static extractPlainText(node: TextComponent | null | undefined): string {
|
||||
const visit = (component: MinecraftTextComponent | null, depth: number): string => {
|
||||
if (!component || depth > maxDepth) return '';
|
||||
const current = component.translate !== undefined ? this.expandTranslate(component)! : component;
|
||||
return (current.text ?? '') + (current.extra ?? [])
|
||||
.map(child => visit(child as MinecraftTextComponent, depth + 1)).join('');
|
||||
};
|
||||
return visit(this.normalizeJson(node), 0);
|
||||
}
|
||||
|
||||
static parseUsernameAndMessage(plainText: string): {username: string; message: string} {
|
||||
const text = plainText.trim();
|
||||
const vanilla = text.match(/^<([^>]+)>\s*([\s\S]*)$/);
|
||||
if (vanilla) return {username: vanilla[1], message: vanilla[2]};
|
||||
const arrowIndex = text.indexOf('»');
|
||||
if (arrowIndex === -1) return {username: '', message: text};
|
||||
const beforeArrow = text.slice(0, arrowIndex).trim();
|
||||
const username = beforeArrow.match(/(\S+)\s*$/)?.[1] ?? '';
|
||||
return {username, message: text.slice(arrowIndex + 1).trim()};
|
||||
}
|
||||
|
||||
/** 返回可直接传给 Logger.log() 的日志片段。 */
|
||||
static toLog(node: TextComponent | null | undefined, includeEvents = true): log[] {
|
||||
const result: log[] = [];
|
||||
const visit = (component: MinecraftTextComponent | null, inherited: MinecraftTextComponent, depth: number): void => {
|
||||
if (!component || depth > maxDepth) return;
|
||||
const current = component.translate !== undefined ? this.expandTranslate(component)! : component;
|
||||
const style: MinecraftTextComponent = {
|
||||
color: current.color ?? inherited.color,
|
||||
clickEvent: current.clickEvent ?? inherited.clickEvent,
|
||||
hoverEvent: current.hoverEvent ?? inherited.hoverEvent,
|
||||
insertion: current.insertion ?? inherited.insertion
|
||||
};
|
||||
for (const format of formats) style[format] = current[format] ?? inherited[format];
|
||||
if (current.text) {
|
||||
const font_style: font_style[] = [];
|
||||
if (style.bold) font_style.push('bold');
|
||||
if (style.italic) font_style.push('italic');
|
||||
if (style.underlined) font_style.push('underline');
|
||||
if (style.clickEvent?.action === 'open_url') font_style.push('url');
|
||||
if (!font_style.length) font_style.push('normal');
|
||||
const font_color = this.normalizeColor(style.color);
|
||||
const entry: log = {message: current.text, font_color, font_style};
|
||||
if (includeEvents && style.clickEvent) entry.clickEvent = {...style.clickEvent} as log['clickEvent'];
|
||||
if (includeEvents && style.insertion !== undefined) entry.insertion = style.insertion;
|
||||
if (includeEvents && style.hoverEvent) {
|
||||
const {action} = style.hoverEvent;
|
||||
const content = style.hoverEvent.contents ?? style.hoverEvent.value;
|
||||
if (content !== undefined && ['show_text', 'show_item', 'show_entity'].includes(action)) {
|
||||
let value: string | undefined;
|
||||
if (action === 'show_text') value = this.extractPlainText(content as TextComponent);
|
||||
else {
|
||||
try { value = typeof content === 'string' ? content : JSON.stringify(content); }
|
||||
catch { /* Ignore malformed hover payloads without losing message text. */ }
|
||||
}
|
||||
if (value !== undefined) {
|
||||
entry.hoverEvent = {action, value};
|
||||
if (action === 'show_text') {
|
||||
let remaining = 4096;
|
||||
entry.hoverEvent.segments = this.toLog(content as TextComponent, false).slice(0, 64)
|
||||
.map(({message, font_color, font_style}) => {
|
||||
const text = String(message).slice(0, remaining);
|
||||
remaining -= text.length;
|
||||
return {message: text, font_color, font_style};
|
||||
}).filter(segment => segment.message.length > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const previous = result[result.length - 1];
|
||||
if (previous && previous.font_color === font_color
|
||||
&& previous.font_style.join(',') === font_style.join(',')
|
||||
&& JSON.stringify(previous.hoverEvent) === JSON.stringify(entry.hoverEvent)
|
||||
&& JSON.stringify(previous.clickEvent) === JSON.stringify(entry.clickEvent)
|
||||
&& previous.insertion === entry.insertion) {
|
||||
previous.message += current.text;
|
||||
} else {
|
||||
result.push(entry);
|
||||
}
|
||||
}
|
||||
for (const child of current.extra ?? []) visit(child as MinecraftTextComponent, style, depth + 1);
|
||||
};
|
||||
visit(this.normalizeJson(node), {}, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
static normalizeColor(color: string | undefined): string {
|
||||
if (color && /^#[\da-f]{6}$/i.test(color)) return color;
|
||||
return color && Object.hasOwn(colorMap, color) ? colorMap[color] : '#FFFFFF';
|
||||
}
|
||||
|
||||
static debug(jsonData: TextComponent | null | undefined): MinecraftTextComponent | null {
|
||||
return this.normalizeJson(jsonData);
|
||||
}
|
||||
}
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
import * as path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import * as fs from "node:fs";
|
||||
|
||||
export function get_project_root_path() {
|
||||
// @ts-ignore
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
return path.resolve(__dirname, '..')
|
||||
}
|
||||
|
||||
|
||||
export function get_path_dir_list(dir: string): string[] {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter(item => item.isDirectory())
|
||||
.map(item => path.join(dir, item.name));
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type {Socket} from 'socket.io-client';
|
||||
import {get_bot_context} from '../bot/instance/index.ts';
|
||||
import {PluginError, type PluginErrorReason} from '../bot/plugins/error.ts';
|
||||
import {plain_plugin_object, valid_plugin_config, valid_plugin_name, valid_plugin_path} from './plugin_validation.ts';
|
||||
import {validate_code_map} from './validate_code_map.ts';
|
||||
|
||||
const codes = validate_code_map.plugin;
|
||||
const errors: Record<PluginErrorReason, number> = {
|
||||
INVALID_CONFIG: codes.invalid_config, ALREADY_INSTALLED: codes.already_installed,
|
||||
NOT_INSTALLED: codes.not_installed, MANAGER_DISPOSED: codes.manager_disposed,
|
||||
LOAD_FAILED: codes.load_failed, INIT_FAILED: codes.init_failed, DISPOSE_FAILED: codes.dispose_failed,
|
||||
};
|
||||
|
||||
export async function plugin_event_handler(operation: 'inject' | 'uninject' | 'update_config', socket: Socket, data: unknown, callback: unknown) {
|
||||
if (typeof callback !== 'function') {
|
||||
socket.emit('error', {error: validate_code_map.web.method_not_allowed});
|
||||
return;
|
||||
}
|
||||
let code: number = validate_code_map.web.invalid_parameter;
|
||||
try {
|
||||
const fields = operation === 'inject' ? ['bot_id', 'path', 'name', 'config']
|
||||
: operation === 'update_config' ? ['bot_id', 'name', 'config'] : ['bot_id', 'name'];
|
||||
if (plain_plugin_object(data) && Object.keys(data).length === fields.length
|
||||
&& fields.every(field => Object.hasOwn(data, field))
|
||||
&& typeof data.bot_id === 'number' && Number.isSafeInteger(data.bot_id) && data.bot_id > 0
|
||||
&& valid_plugin_name(data.name)
|
||||
&& (operation !== 'inject' || valid_plugin_path(data.path))
|
||||
&& (operation === 'uninject' || valid_plugin_config(data.config))) {
|
||||
const context = get_bot_context(data.bot_id);
|
||||
const manager = context?.plugin_manager;
|
||||
if (!context) code = codes.context_missing;
|
||||
else if (!manager) code = codes.manager_missing;
|
||||
else {
|
||||
if (operation === 'inject') await manager.inject({name: data.name, path: data.path as string, config: data.config});
|
||||
else if (operation === 'uninject') await manager.uninject(data.name);
|
||||
else await manager.update_config(data.name, data.config as Record<string, unknown>);
|
||||
code = codes.ok;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
code = error instanceof PluginError ? errors[error.reason] ?? codes.error : codes.error;
|
||||
}
|
||||
// A transport callback can throw; never retry it or expose its exception to the dispatcher.
|
||||
try { callback({status: code === codes.ok ? 'success' : 'error', data: code}); }
|
||||
catch {}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {types} from 'node:util';
|
||||
|
||||
export const valid_plugin_name = (value: unknown): value is string =>
|
||||
typeof value === 'string' && /^[a-zA-Z0-9_-]+$/.test(value);
|
||||
export const valid_plugin_path = (value: unknown): value is string =>
|
||||
typeof value === 'string' && /^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(value);
|
||||
|
||||
export function plain_plugin_object(value: unknown): value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || types.isProxy(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (!prototype || types.isProxy(prototype)) return false;
|
||||
const constructor = Object.getOwnPropertyDescriptor(prototype, 'constructor');
|
||||
// Accept ordinary objects from other realms, but never custom prototypes.
|
||||
if (Object.getPrototypeOf(prototype) !== null || !constructor
|
||||
|| typeof constructor.value !== 'function'
|
||||
|| types.isProxy(constructor.value) || constructor.value.prototype !== prototype
|
||||
|| Function.prototype.toString.call(constructor.value) !== 'function Object() { [native code] }') return false;
|
||||
return Reflect.ownKeys(value).every(key => typeof key === 'string'
|
||||
&& Object.getOwnPropertyDescriptor(value, key)?.enumerable
|
||||
&& Object.hasOwn(Object.getOwnPropertyDescriptor(value, key)!, 'value'));
|
||||
}
|
||||
|
||||
export function valid_plugin_config(value: unknown): value is Record<string, unknown> {
|
||||
if (!plain_plugin_object(value)) return false;
|
||||
return Object.values(value).every(item => {
|
||||
if (item === null || typeof item === 'string' || typeof item === 'boolean') return true;
|
||||
if (typeof item === 'number') return Number.isFinite(item);
|
||||
if (!Array.isArray(item) || types.isProxy(item)) return false;
|
||||
const prototype = Object.getPrototypeOf(item);
|
||||
if (!prototype || types.isProxy(prototype)) return false;
|
||||
const constructor = Object.getOwnPropertyDescriptor(prototype, 'constructor');
|
||||
if (!constructor || typeof constructor.value !== 'function'
|
||||
|| types.isProxy(constructor.value) || constructor.value.prototype !== prototype
|
||||
|| Function.prototype.toString.call(constructor.value) !== 'function Array() { [native code] }') return false;
|
||||
const keys = Reflect.ownKeys(item);
|
||||
return keys.length === item.length + 1 && keys.every(key => key === 'length'
|
||||
|| typeof key === 'string' && /^(0|[1-9]\d*)$/.test(key)
|
||||
&& Object.hasOwn(Object.getOwnPropertyDescriptor(item, key)!, 'value'))
|
||||
&& Array.from(item).every(value => typeof value === 'string' || typeof value === 'number' && Number.isFinite(value));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {SocketResponse} from "../types/utils/socket_response.ts";
|
||||
import {console_print} from "./console_print.ts";
|
||||
|
||||
export function handle_socket_response(response: SocketResponse) :{next: boolean, data?: any} {
|
||||
if (response.status === "error") {
|
||||
console_print.log(console_print.error(response.data));
|
||||
return {next: false, data: response.data};
|
||||
}
|
||||
return {next: true, data: response.data};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
const IV_LEN = 12;
|
||||
const TAG_LEN = 16;
|
||||
|
||||
function derive_key(secret: string): Buffer {
|
||||
return createHash('sha256').update(secret).digest();
|
||||
}
|
||||
|
||||
export function encode_token(secret: string, data: unknown): string {
|
||||
const key = derive_key(secret);
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||
const plain = Buffer.from(JSON.stringify(data), 'utf8');
|
||||
const ciphertext = Buffer.concat([cipher.update(plain), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, tag, ciphertext]).toString('base64url');
|
||||
}
|
||||
|
||||
export function decode_token<T = unknown>(secret: string, token: string): T {
|
||||
const key = derive_key(secret);
|
||||
const raw = Buffer.from(token, 'base64url');
|
||||
if (raw.length < IV_LEN + TAG_LEN) throw new Error('invalid token');
|
||||
const iv = raw.subarray(0, IV_LEN);
|
||||
const tag = raw.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
||||
const ciphertext = raw.subarray(IV_LEN + TAG_LEN);
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
return JSON.parse(plain.toString('utf8')) as T;
|
||||
}
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
import minecraftProtocol from "@wp2508/minecraft-protocol";
|
||||
import type { ProxyOptions } from '@wp2508/mineflayer';
|
||||
import {
|
||||
validate_code_map,
|
||||
type validate_username_code,
|
||||
type validate_host_code,
|
||||
type validate_port_code,
|
||||
type validate_version_code,
|
||||
type validate_proxy_code,
|
||||
} from './validate_code_map';
|
||||
|
||||
const supportedVersions = minecraftProtocol.supportedVersions;
|
||||
|
||||
const username_code = validate_code_map.username;
|
||||
const host_code = validate_code_map.host;
|
||||
const port_code = validate_code_map.port;
|
||||
const version_code = validate_code_map.version;
|
||||
const proxy_code = validate_code_map.proxy;
|
||||
|
||||
// 检查 username 是否符合 Minecraft 用户名规则,返回错误码;0 表示通过
|
||||
export function validate_username(username: string): validate_username_code {
|
||||
if (typeof username !== 'string') return username_code.invalid_type;
|
||||
if (username.length === 0) return username_code.empty;
|
||||
if (username.length < 3) return username_code.too_short;
|
||||
if (username.length > 16) return username_code.over_length;
|
||||
if (!/^[A-Za-z0-9_]+$/.test(username)) return username_code.invalid_char;
|
||||
return username_code.ok;
|
||||
}
|
||||
|
||||
const IPV4_REGEX = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
||||
const DOMAIN_REGEX = /^(?=.{1,253}$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$/;
|
||||
|
||||
function is_valid_ipv6(host: string): boolean {
|
||||
const value = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
|
||||
if (!value.includes(':')) return false;
|
||||
try {
|
||||
const url = new URL(`http://[${value}]`);
|
||||
return url.hostname.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否为 ip 地址或者域名,返回错误码;0 表示通过
|
||||
export function validate_host(host: string): validate_host_code {
|
||||
if (typeof host !== 'string') return host_code.invalid_type;
|
||||
if (host.length === 0) return host_code.empty;
|
||||
if (host.length > 253) return host_code.over_length;
|
||||
if (IPV4_REGEX.test(host)) return host_code.ok;
|
||||
if (is_valid_ipv6(host)) return host_code.ok;
|
||||
if (DOMAIN_REGEX.test(host)) return host_code.ok;
|
||||
return host_code.invalid_format;
|
||||
}
|
||||
|
||||
// 检查端口是否在 0-65535 之间,返回错误码;0 表示通过
|
||||
export function validate_port(port: number): validate_port_code {
|
||||
if (typeof port !== 'number') return port_code.invalid_type;
|
||||
if (Number.isNaN(port)) return port_code.not_number;
|
||||
if (!Number.isInteger(port)) return port_code.not_integer;
|
||||
if (port < 0 || port > 65535) return port_code.out_of_range;
|
||||
return port_code.ok;
|
||||
}
|
||||
|
||||
// 检查版本是否在版本列表中,返回错误码;0 表示通过
|
||||
export function validate_version(version: string): validate_version_code {
|
||||
if (typeof version !== 'string') return version_code.invalid_type;
|
||||
if (version.length === 0) return version_code.empty;
|
||||
if (!supportedVersions.includes(version)) return version_code.unsupported;
|
||||
return version_code.ok;
|
||||
}
|
||||
|
||||
const PROXY_TYPES = ['socks5', 'sock5', 'http', 'https'] as const;
|
||||
|
||||
// 检查代理配置是否合理,返回错误码;0 表示通过
|
||||
export function validate_proxy(proxy: ProxyOptions): validate_proxy_code {
|
||||
if (typeof proxy !== 'object' || proxy === null) return proxy_code.invalid_type;
|
||||
if (!proxy.type) return proxy_code.missing_type;
|
||||
if (!PROXY_TYPES.includes(proxy.type)) return proxy_code.invalid_type_value;
|
||||
if (!proxy.host) return proxy_code.missing_host;
|
||||
if (validate_host(proxy.host) !== host_code.ok) return proxy_code.invalid_host;
|
||||
if (proxy.port === undefined || proxy.port === null) return proxy_code.missing_port;
|
||||
if (validate_port(proxy.port) !== port_code.ok) return proxy_code.invalid_port;
|
||||
if (proxy.username != null && typeof proxy.username !== 'string') return proxy_code.invalid_username;
|
||||
if (proxy.password != null && typeof proxy.password !== 'string') return proxy_code.invalid_password;
|
||||
return proxy_code.ok;
|
||||
}
|
||||
Executable
+124
@@ -0,0 +1,124 @@
|
||||
// 校验错误码映射;每个分组的 ok 均为 0,表示校验通过
|
||||
export const validate_code_map = {
|
||||
username: {
|
||||
ok: 0,
|
||||
over_length: 400,
|
||||
too_short: 401,
|
||||
invalid_char: 402,
|
||||
invalid_type: 403,
|
||||
empty: 404,
|
||||
},
|
||||
host: {
|
||||
ok: 0,
|
||||
invalid_type: 410,
|
||||
empty: 411,
|
||||
over_length: 412,
|
||||
invalid_ip: 413,
|
||||
invalid_format: 414,
|
||||
},
|
||||
port: {
|
||||
ok: 0,
|
||||
invalid_type: 420,
|
||||
not_integer: 421,
|
||||
not_number: 422,
|
||||
out_of_range: 423,
|
||||
},
|
||||
version: {
|
||||
ok: 0,
|
||||
invalid_type: 430,
|
||||
empty: 431,
|
||||
unsupported: 432,
|
||||
},
|
||||
proxy: {
|
||||
ok: 0,
|
||||
invalid_type: 440,
|
||||
missing_type: 441,
|
||||
invalid_type_value: 442,
|
||||
missing_host: 443,
|
||||
invalid_host: 444,
|
||||
missing_port: 445,
|
||||
invalid_port: 446,
|
||||
invalid_username: 447,
|
||||
invalid_password: 448,
|
||||
},
|
||||
bot_status: {
|
||||
online: 0,
|
||||
offline: 449,
|
||||
connecting: 450
|
||||
},
|
||||
bot_context: {
|
||||
ok: 0,
|
||||
instance_created: 460,
|
||||
instance_end_by_user: 461,
|
||||
instance_connected: 462,
|
||||
instance_connecting: 463,
|
||||
context_created: 464,
|
||||
processing: 465,
|
||||
error: 466,
|
||||
disposed: 467,
|
||||
cancelled: 468,
|
||||
},
|
||||
forward: {
|
||||
ok: 0,
|
||||
invalid_token: 470,
|
||||
missing_key: 471,
|
||||
},
|
||||
plugin: {
|
||||
ok: 0,
|
||||
context_missing: 500,
|
||||
manager_missing: 501,
|
||||
invalid_config: 502,
|
||||
already_installed: 503,
|
||||
not_installed: 504,
|
||||
manager_disposed: 505,
|
||||
load_failed: 506,
|
||||
init_failed: 507,
|
||||
dispose_failed: 508,
|
||||
error: 509,
|
||||
},
|
||||
web: {
|
||||
ok: 0,
|
||||
database_error: 480,
|
||||
method_not_allowed: 481,
|
||||
invalid_json: 482,
|
||||
missing_parameter: 483,
|
||||
invalid_parameter: 484,
|
||||
not_found: 485,
|
||||
internal_server_error: 486,
|
||||
spam: 487,
|
||||
timeout: 488,
|
||||
account: {
|
||||
ok: 0,
|
||||
invalid_username: 489,
|
||||
invalid_password: 490,
|
||||
invalid_register_code: 491,
|
||||
invalid_email: 492,
|
||||
invalid_code: 493,
|
||||
invalid_minecraft_username: 494,
|
||||
same_username: 495,
|
||||
same_email: 496,
|
||||
same_minecraft_username: 497,
|
||||
not_login: 498,
|
||||
}
|
||||
}
|
||||
} as const;
|
||||
|
||||
type value_of<T> = T[keyof T];
|
||||
|
||||
export type validate_username_code = value_of<typeof validate_code_map.username>;
|
||||
export type validate_host_code = value_of<typeof validate_code_map.host>;
|
||||
export type validate_port_code = value_of<typeof validate_code_map.port>;
|
||||
export type validate_version_code = value_of<typeof validate_code_map.version>;
|
||||
export type validate_proxy_code = value_of<typeof validate_code_map.proxy>;
|
||||
export type bot_status_code = value_of<typeof validate_code_map.bot_status>;
|
||||
|
||||
// 所有校验错误码的联合类型
|
||||
export type validate_code =
|
||||
| validate_username_code
|
||||
| validate_host_code
|
||||
| validate_port_code
|
||||
| validate_version_code
|
||||
| validate_proxy_code
|
||||
| bot_status_code;
|
||||
|
||||
export type bot_operation_code = validate_code | value_of<typeof validate_code_map.bot_context>;
|
||||
Reference in New Issue
Block a user