import { is, ResourceUri } from "@atcute/lexicons"; import { OnlineTimtinkersBotRecurring } from "./lexicons/index.ts"; import StreamplaceBot, { IndexedCollection } from "./streamplaceBot.ts"; import { MessageCreateEvent } from "./commandHandler.ts"; interface ActiveTimer { trigger: string; intervalSeconds: number; minMessages: number; intervalId: ReturnType; messagesSinceFire: number; } export class RecurringCommandHandler { private bot: StreamplaceBot; // Keyed by URI, indexed by trigger string. private recurring = new IndexedCollection( (t) => t.trigger, ); private loaded = false; constructor(bot: StreamplaceBot) { this.bot = bot; } init(): void { if (this.loaded) return; // Recurring records are populated by the repo walk in StreamplaceBot // before init() is called, via ingestRecord. this.loaded = true; console.log(`Loaded ${this.recurring.size} recurring commands`); } /** * Called by the repo walk for each `online.timtinkers.bot.recurring` record. * Delegates to handleUpsert so startup and live-update paths stay in sync. */ ingestRecord( uri: ResourceUri, record: OnlineTimtinkersBotRecurring.Main, ): void { this.handleUpsert(uri, record); } private scheduleTimer( uri: ResourceUri, record: OnlineTimtinkersBotRecurring.Main, ): void { // Clear any existing timer for this URI first (handles updates) this.clearTimer(uri); const trigger = record.trigger.toLowerCase(); const intervalMs = record.intervalSeconds ? record.intervalSeconds * 1000 : 60 * 1000; const intervalId = setInterval(async () => { const timer = this.recurring.getByUri(uri); if (!timer) return; if (!this.bot.streamerIsLive()) return; // Enforce minimum message threshold if (timer.messagesSinceFire < timer.minMessages) return; const command = this.bot.getCommandHandler().getCommand(trigger); if (!command) return; // Recurring commands bypass permission/cooldown gates const syntheticEvent = this.makeSyntheticEvent(); try { await command.execute(syntheticEvent, [], this.bot); timer.messagesSinceFire = 0; } catch (error) { console.error( `Error firing recurring command "${trigger}":`, error, ); } }, intervalMs); this.recurring.set(uri, { trigger, intervalSeconds: record.intervalSeconds || 60, minMessages: record.minMessages || 0, intervalId, messagesSinceFire: 0, }); } private clearTimer(uri: ResourceUri): void { const existing = this.recurring.delete(uri); if (existing) clearInterval(existing.intervalId); } // Called by StreamplaceBot.processMessage on every incoming message incrementMessageCount(): void { for (const timer of this.recurring.values()) { timer.messagesSinceFire++; } } // Called from EventHandler for live create/update handleUpsert( uri: ResourceUri, record: OnlineTimtinkersBotRecurring.Main, ): void { if (!is(OnlineTimtinkersBotRecurring.mainSchema, record)) return; this.scheduleTimer(uri, record); console.log( `Recurring command "${record.trigger}" scheduled (${record.intervalSeconds}s, min ${record.minMessages} msgs)`, ); } // Called from EventHandler for live delete handleDelete(uri: ResourceUri): void { const timer = this.recurring.delete(uri); if (timer) { clearInterval(timer.intervalId); console.log(`Recurring command "${timer.trigger}" removed`); } } destroy(): void { for (const timer of this.recurring.values()) { clearInterval(timer.intervalId); } this.recurring.clear(); } // Recurring commands fire from the bot itself — no real chatter event exists. // Commands that reference event.did will get the bot's own DID here. private makeSyntheticEvent(): MessageCreateEvent { return { did: this.bot.getBotDid()!, time_us: 0, kind: "commit", commit: { rev: "", operation: "create", collection: "place.stream.chat.message", rkey: "", record: { $type: "place.stream.chat.message", text: "", streamer: this.bot.getStreamerDid(), createdAt: "", }, cid: "", }, }; } }