levtor
Version:
Levtor — a smart, fuzzy, AI-powered chat command handler and guardian for wallets and AI integration.
163 lines (131 loc) • 4.69 kB
text/typescript
import { parseArgs, fuzzyMatch } from './utils';
type PositionalArgs = string[];
type NamedArgs = Record<string, string | boolean>;
type FormatFn<TCtx> = (
rawOutput: string,
context: {
command: string;
args: PositionalArgs;
named: NamedArgs;
ctx?: TCtx;
meta?: Record<string, unknown>;
}
) => Promise<string>;
type PreprocessFn<TCtx> = (
rawMessage: string,
ctx?: TCtx
) => Promise<{ message: string; meta?: Record<string, unknown> }>;
type CommandHandlerFn<TDeps, TCtx> = (
args: PositionalArgs,
named: NamedArgs,
deps: TDeps,
ctx?: TCtx,
meta?: Record<string, unknown>
) => Promise<string>;
type LifecycleStage = 'before' | 'after' | 'error';
type HookFn<TCtx> = (
command: string,
payload: unknown,
ctx?: TCtx
) => Promise<void>;
interface CommandOptions {
aliases?: string[];
cacheTtlMs?: number;
debounceMs?: number;
}
interface RegisteredCommand<TDeps, TCtx> extends CommandOptions {
handler: CommandHandlerFn<TDeps, TCtx>;
aliases: string[];
cacheTtlMs?: number;
debounceMs?: number;
}
class CommandHandler<TDeps, TCtx = object> {
private commands = new Map<string, RegisteredCommand<TDeps, TCtx>>();
private preprocessors: PreprocessFn<TCtx>[] = [];
private hooks: Partial<Record<LifecycleStage, HookFn<TCtx>[]>> = {};
private cache = new Map<string, { value: string; expires: number }>();
private lastRun = new Map<string, number>();
constructor(
private deps: TDeps,
private formatWithAI?: FormatFn<TCtx>
) {}
register(command: string, handler: CommandHandlerFn<TDeps, TCtx>, options: CommandOptions = {}) {
this.commands.set(command.toLowerCase(), {
handler,
aliases: options.aliases || [],
cacheTtlMs: options.cacheTtlMs,
debounceMs: options.debounceMs,
});
}
usePreprocessor(fn: PreprocessFn<TCtx>) {
this.preprocessors.push(fn);
}
on(stage: LifecycleStage, fn: HookFn<TCtx>) {
this.hooks[stage] = this.hooks[stage] || [];
this.hooks[stage].push(fn);
}
private async emit(stage: LifecycleStage, cmd: string, payload: unknown, ctx?: TCtx) {
for (const fn of this.hooks[stage] || []) {
await fn(cmd, payload, ctx);
}
}
private async runPreprocessors(raw: string, ctx?: TCtx) {
let current = raw;
const meta: Record<string, unknown> = {};
for (const fn of this.preprocessors) {
const result = await fn(current, ctx);
current = result.message;
Object.assign(meta, result.meta);
}
return { message: current, meta };
}
async handle(raw: string, ctx?: TCtx): Promise<string> {
try {
const { message, meta } = await this.runPreprocessors(raw, ctx);
if (!message.startsWith('!')) return '❌ Must start with `!`';
const input = message.slice(1).trim();
const [rawCmd, ...rest] = input.split(/\s+/);
if (!rawCmd) return '❓ Unknown command: ""';
const all = [...this.commands.entries()].flatMap(([name, cmd]) =>
[name, ...cmd.aliases].map(alias => ({ alias, command: name }))
);
const match = fuzzyMatch(rawCmd.toLowerCase(), all.map(a => a.alias));
if (!match) return `❓ Unknown command: "${rawCmd}"`;
const found = all.find(c => c.alias === match);
if (!found) return `❓ Unknown command: "${rawCmd}"`;
const { command } = found;
const cmdObj = this.commands.get(command);
if (!cmdObj) return `❓ Unknown command: "${rawCmd}"`;
const { handler, cacheTtlMs, debounceMs } = cmdObj;
const now = Date.now();
const cacheKey = `${command}|${rest.join(' ')}`;
if (cacheTtlMs) {
const entry = this.cache.get(cacheKey);
if (entry && entry.expires > now) return entry.value;
}
if (debounceMs) {
const last = this.lastRun.get(cacheKey) || 0;
if (now - last < debounceMs) return '🕒 Please wait...';
this.lastRun.set(cacheKey, now);
}
const { positional, named } = parseArgs(rest.join(' '));
await this.emit('before', command, { args: positional, named, meta }, ctx);
const result = await handler(positional, named, this.deps, ctx, meta);
await this.emit('after', command, result, ctx);
const formatted = this.formatWithAI
? await this.formatWithAI(result, { command, args: positional, named, ctx, meta })
: result;
if (cacheTtlMs) {
this.cache.set(cacheKey, {
value: formatted,
expires: now + cacheTtlMs,
});
}
return formatted;
} catch (err) {
await this.emit('error', 'global', err, ctx);
return '⚠️ An error occurred.';
}
}
}
export { CommandHandler };