import { CommitEvent, CreateCommit } from "@atcute/jetstream"; import { is, ResourceUri } from "@atcute/lexicons"; import { getRecordByAtUri } from "./atcuteUtils.ts"; import { OnlineTimtinkersBotCommand, OnlineTimtinkersBotVariable, PlaceStreamChatMessage, } from "./lexicons/index.ts"; import { BotCommand, PermissionEntry, PermissionLevel } from "./botCommand.ts"; import { defaultCommands } from "./commands/defaultCommands.ts"; import { IndexedCollection } from "./streamplaceBot.ts"; import { customVariablesAtUris } from "../main.ts"; export interface MessageCreateEvent extends CommitEvent { commit: MessageCreateCommit; } interface MessageCreateCommit extends CreateCommit { record: PlaceStreamChatMessage.Main; } export class CommandHandler { private defaults: Map = new Map(); // Keyed by URI, indexed by trigger string. private customs = new IndexedCollection( (cmd) => cmd.trigger, ); // Maps any trigger or alias -> primary trigger, rebuilt on every change private defaultAliases: Map = new Map(); private customAliases: Map = new Map(); private loaded = false; init(): void { this.registerDefaultCommands(); // Custom commands are populated by the repo walk in StreamplaceBot // before init() is called, via ingestVariable / ingestRecord. this.loaded = true; console.log(`Loaded ${this.customs.size} custom commands`); } private registerDefaultCommands(): void { for (const [trigger, def] of defaultCommands) { const command = new BotCommand({ trigger, ...def }); this.defaults.set(command.trigger, command); } this.defaultAliases = buildAliasIndex(this.defaults); } private async commandFromRecord( uri: ResourceUri, record: OnlineTimtinkersBotCommand.Main, ): Promise { const variableMap: Map = new Map(); if (record.customVariables) { for (const variable of record.customVariables) { // Variables are pre-populated into customVariablesAtUris by the repo // walk (ingestVariable) before any command records are processed. // For live upserts arriving after startup, fetchVariables handles // any URI that slipped through. if (!customVariablesAtUris.has(variable.uri)) { const fetched = await this.fetchVariables([variable.uri]); if (fetched[0]) { customVariablesAtUris.set(variable.uri, fetched[0]); } } variableMap.set("$" + variable.name, variable.uri); } } return new BotCommand({ trigger: record.trigger, uri, aliases: record.aliases, response: record.response, description: record.description, isReply: record.isReply, hidden: record.hidden, permissions: record.permissions?.map(mapPermissionEntry), userCooldown: record.userCooldown, globalCooldown: record.globalCooldown, customVariables: variableMap, media: record.media, }); } private async fetchVariables( atUris: ResourceUri[], ): Promise { const variableRecords: OnlineTimtinkersBotVariable.Main[] = []; const results = await Promise.allSettled( atUris.map((uri) => getRecordByAtUri(uri)), ); for (const result of results) { const botVariable = result.status === "fulfilled" ? result.value?.value : null; if (is(OnlineTimtinkersBotVariable.mainSchema, botVariable)) { variableRecords.push(botVariable); } } return variableRecords; } /** * Called by the repo walk for each `online.timtinkers.bot.variable` record. * Must be called before ingestRecord so that commandFromRecord finds every * variable already in cache. */ ingestVariable( uri: ResourceUri, variable: OnlineTimtinkersBotVariable.Main, ): void { customVariablesAtUris.set(uri, variable); } /** * Called by the repo walk for each `online.timtinkers.bot.command` record. * Delegates to handleUpsert so live-update and startup paths stay in sync. */ async ingestRecord( uri: ResourceUri, record: OnlineTimtinkersBotCommand.Main, ): Promise { await this.handleUpsert(uri, record); } async handleUpsert( uri: ResourceUri, record: OnlineTimtinkersBotCommand.Main, ): Promise { if (!is(OnlineTimtinkersBotCommand.mainSchema, record)) return; const command = await this.commandFromRecord(uri, record); this.customs.set(uri, command); this.customAliases = buildAliasIndex(this.customs); } handleDelete(uri: ResourceUri): void { const command = this.customs.delete(uri); if (command) { this.customAliases = buildAliasIndex(this.customs); console.log( `Custom command "${command.trigger}" removed${ this.defaults.has(command.trigger) ? ", default restored" : "" }`, ); } } getCommand(trigger: string): BotCommand | undefined { const key = trigger.toLowerCase(); const customTrigger = this.customAliases.get(key); if (customTrigger) return this.customs.getByKey(customTrigger); const defaultTrigger = this.defaultAliases.get(key); if (defaultTrigger) return this.defaults.get(defaultTrigger); return undefined; } // All commands, defaults shadowed by customs — used internally getCommands(): Map { return new Map([...this.defaults, ...this.customs.entries()].map( ([, cmd]) => [cmd.trigger, cmd] as [string, BotCommand], )); } // Text commands visible to chatters: non-hidden, and not media-only. // Commands with both a response/handler AND media still appear here. getPublicCommands(): Map { return new Map( [...this.getCommands()].filter(([, cmd]) => !cmd.hidden && !cmd.isMediaOnly ), ); } // Media commands visible to chatters: non-hidden, and has a media attachment. getPublicMediaCommands(): Map { return new Map( [...this.getCommands()].filter(([, cmd]) => !cmd.hidden && cmd.media !== undefined ), ); } } // Builds a lookup of every trigger and alias -> primary trigger for a set of commands. // Accepts either a plain Map (defaults) or an IndexedCollection (customs) via // the shared IterableIterator<[ResourceUri, BotCommand]> / values() interface. function buildAliasIndex( commands: Map | IndexedCollection, ): Map { const index = new Map(); const values = commands instanceof Map ? commands.values() : commands.values(); for (const command of values) { index.set(command.trigger, command.trigger); for (const alias of command.aliases) { index.set(alias, command.trigger); } } return index; } // Converts a lexicon permissionLevel entry into the BotCommand representation: // "online.timtinkers.bot.command#streamer" -> "streamer", while a bare DID // (an individual user grant) passes through unchanged. function mapPermissionEntry(entry: string): PermissionEntry { if (entry.startsWith("online.timtinkers.bot.command#")) { return entry.replace( "online.timtinkers.bot.command#", "", ) as PermissionLevel; } return entry as Did; }