Something went wrong. Try again.
A work-in-progress chat bot for Streamplace with chat overlay functionality
Something went wrong. Try again.
7.1 kB · 223 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224import { 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<string, BotCommand> = new Map(); // Keyed by URI, indexed by trigger string. private customs = new IndexedCollection<string, BotCommand>( (cmd) => cmd.trigger, ); // Maps any trigger or alias -> primary trigger, rebuilt on every change private defaultAliases: Map<string, string> = new Map(); private customAliases: Map<string, string> = 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<BotCommand> { const variableMap: Map<string, ResourceUri> = 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<OnlineTimtinkersBotVariable.Main[]> { 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<void> { await this.handleUpsert(uri, record); }
async handleUpsert( uri: ResourceUri, record: OnlineTimtinkersBotCommand.Main, ): Promise<void> { 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<string, BotCommand> { 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<string, BotCommand> { 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<string, BotCommand> { 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<string, BotCommand> | IndexedCollection<string, BotCommand>,): Map<string, string> { const index = new Map<string, string>(); 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;}