{"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,QAAQ,EAAc,MAAM,YAAY,CAAC;AAMvD,MAAM,WAAW,cAAc;IAC9B,IAAI,EAAE,WAAW,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,YAAY;IAC5B,IAAI,EAAE,UAAU,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACX;AAED,MAAM,WAAW,aAAa;IAC7B,IAAI,EAAE,UAAU,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,QAAQ,GAAG,cAAc,GAAG,YAAY,GAAG,aAAa,CAAC;AAUrE,qBAAa,aAAa;IASxB,OAAO,CAAC,SAAS;IACjB,OAAO,CAAC,KAAK;IATd,OAAO,CAAC,MAAM,CAA0C;IACxD,OAAO,CAAC,KAAK,CAAgC;IAC7C,OAAO,CAAC,cAAc,CAA0C;IAChE,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,OAAO,CAA0B;IACzC,OAAO,CAAC,UAAU,CAA0B;IAE5C,YACS,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,QAAQ,EAGvB;IAED;;OAEG;IACH,KAAK,IAAI,IAAI,CAkBZ;IAED;;OAEG;IACH,IAAI,IAAI,IAAI,CA2BX;IAED,OAAO,CAAC,QAAQ;IAchB,OAAO,CAAC,YAAY;IAcpB,OAAO,CAAC,gBAAgB;IAgBxB,OAAO,CAAC,YAAY;IAQpB,OAAO,CAAC,eAAe;YAcT,UAAU;IA0CxB,OAAO,CAAC,UAAU;IAqClB,OAAO,CAAC,eAAe;IAoBvB,OAAO,CAAC,aAAa;IAuBrB,OAAO,CAAC,cAAc;IAiBtB,OAAO,CAAC,OAAO;IAyCf,OAAO,CAAC,UAAU;IAalB,OAAO,CAAC,KAAK;CAGb;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,GAAG,aAAa,CAGxF","sourcesContent":["import { Cron } from \"croner\";\nimport { existsSync, type FSWatcher, mkdirSync, readdirSync, statSync, unlinkSync, watch } from \"fs\";\nimport { readFile } from \"fs/promises\";\nimport { join } from \"path\";\nimport * as log from \"./log.js\";\nimport type { SlackBot, SlackEvent } from \"./slack.js\";\n\n// ============================================================================\n// Event Types\n// ============================================================================\n\nexport interface ImmediateEvent {\n\ttype: \"immediate\";\n\tchannelId: string;\n\ttext: string;\n}\n\nexport interface OneShotEvent {\n\ttype: \"one-shot\";\n\tchannelId: string;\n\ttext: string;\n\tat: string; // ISO 8601 with timezone offset\n}\n\nexport interface PeriodicEvent {\n\ttype: \"periodic\";\n\tchannelId: string;\n\ttext: string;\n\tschedule: string; // cron syntax\n\ttimezone: string; // IANA timezone\n}\n\nexport type MomEvent = ImmediateEvent | OneShotEvent | PeriodicEvent;\n\n// ============================================================================\n// EventsWatcher\n// ============================================================================\n\nconst DEBOUNCE_MS = 100;\nconst MAX_RETRIES = 3;\nconst RETRY_BASE_MS = 100;\n\nexport class EventsWatcher {\n\tprivate timers: Map<string, NodeJS.Timeout> = new Map();\n\tprivate crons: Map<string, Cron> = new Map();\n\tprivate debounceTimers: Map<string, NodeJS.Timeout> = new Map();\n\tprivate startTime: number;\n\tprivate watcher: FSWatcher | null = null;\n\tprivate knownFiles: Set<string> = new Set();\n\n\tconstructor(\n\t\tprivate eventsDir: string,\n\t\tprivate slack: SlackBot,\n\t) {\n\t\tthis.startTime = Date.now();\n\t}\n\n\t/**\n\t * Start watching for events. Call this after SlackBot is ready.\n\t */\n\tstart(): void {\n\t\t// Ensure events directory exists\n\t\tif (!existsSync(this.eventsDir)) {\n\t\t\tmkdirSync(this.eventsDir, { recursive: true });\n\t\t}\n\n\t\tlog.logInfo(`Events watcher starting, dir: ${this.eventsDir}`);\n\n\t\t// Scan existing files\n\t\tthis.scanExisting();\n\n\t\t// Watch for changes\n\t\tthis.watcher = watch(this.eventsDir, (_eventType, filename) => {\n\t\t\tif (!filename || !filename.endsWith(\".json\")) return;\n\t\t\tthis.debounce(filename, () => this.handleFileChange(filename));\n\t\t});\n\n\t\tlog.logInfo(`Events watcher started, tracking ${this.knownFiles.size} files`);\n\t}\n\n\t/**\n\t * Stop watching and cancel all scheduled events.\n\t */\n\tstop(): void {\n\t\t// Stop fs watcher\n\t\tif (this.watcher) {\n\t\t\tthis.watcher.close();\n\t\t\tthis.watcher = null;\n\t\t}\n\n\t\t// Cancel all debounce timers\n\t\tfor (const timer of this.debounceTimers.values()) {\n\t\t\tclearTimeout(timer);\n\t\t}\n\t\tthis.debounceTimers.clear();\n\n\t\t// Cancel all scheduled timers\n\t\tfor (const timer of this.timers.values()) {\n\t\t\tclearTimeout(timer);\n\t\t}\n\t\tthis.timers.clear();\n\n\t\t// Cancel all cron jobs\n\t\tfor (const cron of this.crons.values()) {\n\t\t\tcron.stop();\n\t\t}\n\t\tthis.crons.clear();\n\n\t\tthis.knownFiles.clear();\n\t\tlog.logInfo(\"Events watcher stopped\");\n\t}\n\n\tprivate debounce(filename: string, fn: () => void): void {\n\t\tconst existing = this.debounceTimers.get(filename);\n\t\tif (existing) {\n\t\t\tclearTimeout(existing);\n\t\t}\n\t\tthis.debounceTimers.set(\n\t\t\tfilename,\n\t\t\tsetTimeout(() => {\n\t\t\t\tthis.debounceTimers.delete(filename);\n\t\t\t\tfn();\n\t\t\t}, DEBOUNCE_MS),\n\t\t);\n\t}\n\n\tprivate scanExisting(): void {\n\t\tlet files: string[];\n\t\ttry {\n\t\t\tfiles = readdirSync(this.eventsDir).filter((f) => f.endsWith(\".json\"));\n\t\t} catch (err) {\n\t\t\tlog.logWarning(\"Failed to read events directory\", String(err));\n\t\t\treturn;\n\t\t}\n\n\t\tfor (const filename of files) {\n\t\t\tthis.handleFile(filename);\n\t\t}\n\t}\n\n\tprivate handleFileChange(filename: string): void {\n\t\tconst filePath = join(this.eventsDir, filename);\n\n\t\tif (!existsSync(filePath)) {\n\t\t\t// File was deleted\n\t\t\tthis.handleDelete(filename);\n\t\t} else if (this.knownFiles.has(filename)) {\n\t\t\t// File was modified - cancel existing and re-schedule\n\t\t\tthis.cancelScheduled(filename);\n\t\t\tthis.handleFile(filename);\n\t\t} else {\n\t\t\t// New file\n\t\t\tthis.handleFile(filename);\n\t\t}\n\t}\n\n\tprivate handleDelete(filename: string): void {\n\t\tif (!this.knownFiles.has(filename)) return;\n\n\t\tlog.logInfo(`Event file deleted: ${filename}`);\n\t\tthis.cancelScheduled(filename);\n\t\tthis.knownFiles.delete(filename);\n\t}\n\n\tprivate cancelScheduled(filename: string): void {\n\t\tconst timer = this.timers.get(filename);\n\t\tif (timer) {\n\t\t\tclearTimeout(timer);\n\t\t\tthis.timers.delete(filename);\n\t\t}\n\n\t\tconst cron = this.crons.get(filename);\n\t\tif (cron) {\n\t\t\tcron.stop();\n\t\t\tthis.crons.delete(filename);\n\t\t}\n\t}\n\n\tprivate async handleFile(filename: string): Promise<void> {\n\t\tconst filePath = join(this.eventsDir, filename);\n\n\t\t// Parse with retries\n\t\tlet event: MomEvent | null = null;\n\t\tlet lastError: Error | null = null;\n\n\t\tfor (let i = 0; i < MAX_RETRIES; i++) {\n\t\t\ttry {\n\t\t\t\tconst content = await readFile(filePath, \"utf-8\");\n\t\t\t\tevent = this.parseEvent(content, filename);\n\t\t\t\tbreak;\n\t\t\t} catch (err) {\n\t\t\t\tlastError = err instanceof Error ? err : new Error(String(err));\n\t\t\t\tif (i < MAX_RETRIES - 1) {\n\t\t\t\t\tawait this.sleep(RETRY_BASE_MS * 2 ** i);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (!event) {\n\t\t\tlog.logWarning(`Failed to parse event file after ${MAX_RETRIES} retries: ${filename}`, lastError?.message);\n\t\t\tthis.deleteFile(filename);\n\t\t\treturn;\n\t\t}\n\n\t\tthis.knownFiles.add(filename);\n\n\t\t// Schedule based on type\n\t\tswitch (event.type) {\n\t\t\tcase \"immediate\":\n\t\t\t\tthis.handleImmediate(filename, event);\n\t\t\t\tbreak;\n\t\t\tcase \"one-shot\":\n\t\t\t\tthis.handleOneShot(filename, event);\n\t\t\t\tbreak;\n\t\t\tcase \"periodic\":\n\t\t\t\tthis.handlePeriodic(filename, event);\n\t\t\t\tbreak;\n\t\t}\n\t}\n\n\tprivate parseEvent(content: string, filename: string): MomEvent | null {\n\t\tconst data = JSON.parse(content);\n\n\t\tif (!data.type || !data.channelId || !data.text) {\n\t\t\tthrow new Error(`Missing required fields (type, channelId, text) in ${filename}`);\n\t\t}\n\n\t\tswitch (data.type) {\n\t\t\tcase \"immediate\":\n\t\t\t\treturn { type: \"immediate\", channelId: data.channelId, text: data.text };\n\n\t\t\tcase \"one-shot\":\n\t\t\t\tif (!data.at) {\n\t\t\t\t\tthrow new Error(`Missing 'at' field for one-shot event in ${filename}`);\n\t\t\t\t}\n\t\t\t\treturn { type: \"one-shot\", channelId: data.channelId, text: data.text, at: data.at };\n\n\t\t\tcase \"periodic\":\n\t\t\t\tif (!data.schedule) {\n\t\t\t\t\tthrow new Error(`Missing 'schedule' field for periodic event in ${filename}`);\n\t\t\t\t}\n\t\t\t\tif (!data.timezone) {\n\t\t\t\t\tthrow new Error(`Missing 'timezone' field for periodic event in ${filename}`);\n\t\t\t\t}\n\t\t\t\treturn {\n\t\t\t\t\ttype: \"periodic\",\n\t\t\t\t\tchannelId: data.channelId,\n\t\t\t\t\ttext: data.text,\n\t\t\t\t\tschedule: data.schedule,\n\t\t\t\t\ttimezone: data.timezone,\n\t\t\t\t};\n\n\t\t\tdefault:\n\t\t\t\tthrow new Error(`Unknown event type '${data.type}' in ${filename}`);\n\t\t}\n\t}\n\n\tprivate handleImmediate(filename: string, event: ImmediateEvent): void {\n\t\tconst filePath = join(this.eventsDir, filename);\n\n\t\t// Check if stale (created before harness started)\n\t\ttry {\n\t\t\tconst stat = statSync(filePath);\n\t\t\tif (stat.mtimeMs < this.startTime) {\n\t\t\t\tlog.logInfo(`Stale immediate event, deleting: ${filename}`);\n\t\t\t\tthis.deleteFile(filename);\n\t\t\t\treturn;\n\t\t\t}\n\t\t} catch {\n\t\t\t// File may have been deleted\n\t\t\treturn;\n\t\t}\n\n\t\tlog.logInfo(`Executing immediate event: ${filename}`);\n\t\tthis.execute(filename, event);\n\t}\n\n\tprivate handleOneShot(filename: string, event: OneShotEvent): void {\n\t\tconst atTime = new Date(event.at).getTime();\n\t\tconst now = Date.now();\n\n\t\tif (atTime <= now) {\n\t\t\t// Past - delete without executing\n\t\t\tlog.logInfo(`One-shot event in the past, deleting: ${filename}`);\n\t\t\tthis.deleteFile(filename);\n\t\t\treturn;\n\t\t}\n\n\t\tconst delay = atTime - now;\n\t\tlog.logInfo(`Scheduling one-shot event: ${filename} in ${Math.round(delay / 1000)}s`);\n\n\t\tconst timer = setTimeout(() => {\n\t\t\tthis.timers.delete(filename);\n\t\t\tlog.logInfo(`Executing one-shot event: ${filename}`);\n\t\t\tthis.execute(filename, event);\n\t\t}, delay);\n\n\t\tthis.timers.set(filename, timer);\n\t}\n\n\tprivate handlePeriodic(filename: string, event: PeriodicEvent): void {\n\t\ttry {\n\t\t\tconst cron = new Cron(event.schedule, { timezone: event.timezone }, () => {\n\t\t\t\tlog.logInfo(`Executing periodic event: ${filename}`);\n\t\t\t\tthis.execute(filename, event, false); // Don't delete periodic events\n\t\t\t});\n\n\t\t\tthis.crons.set(filename, cron);\n\n\t\t\tconst next = cron.nextRun();\n\t\t\tlog.logInfo(`Scheduled periodic event: ${filename}, next run: ${next?.toISOString() ?? \"unknown\"}`);\n\t\t} catch (err) {\n\t\t\tlog.logWarning(`Invalid cron schedule for ${filename}: ${event.schedule}`, String(err));\n\t\t\tthis.deleteFile(filename);\n\t\t}\n\t}\n\n\tprivate execute(filename: string, event: MomEvent, deleteAfter: boolean = true): void {\n\t\t// Format the message\n\t\tlet scheduleInfo: string;\n\t\tswitch (event.type) {\n\t\t\tcase \"immediate\":\n\t\t\t\tscheduleInfo = \"immediate\";\n\t\t\t\tbreak;\n\t\t\tcase \"one-shot\":\n\t\t\t\tscheduleInfo = event.at;\n\t\t\t\tbreak;\n\t\t\tcase \"periodic\":\n\t\t\t\tscheduleInfo = event.schedule;\n\t\t\t\tbreak;\n\t\t}\n\n\t\tconst message = `[EVENT:${filename}:${event.type}:${scheduleInfo}] ${event.text}`;\n\n\t\t// Create synthetic SlackEvent\n\t\tconst syntheticEvent: SlackEvent = {\n\t\t\ttype: \"mention\",\n\t\t\tchannel: event.channelId,\n\t\t\tuser: \"EVENT\",\n\t\t\ttext: message,\n\t\t\tts: Date.now().toString(),\n\t\t};\n\n\t\t// Enqueue for processing\n\t\tconst enqueued = this.slack.enqueueEvent(syntheticEvent);\n\n\t\tif (enqueued && deleteAfter) {\n\t\t\t// Delete file after successful enqueue (immediate and one-shot)\n\t\t\tthis.deleteFile(filename);\n\t\t} else if (!enqueued) {\n\t\t\tlog.logWarning(`Event queue full, discarded: ${filename}`);\n\t\t\t// Still delete immediate/one-shot even if discarded\n\t\t\tif (deleteAfter) {\n\t\t\t\tthis.deleteFile(filename);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate deleteFile(filename: string): void {\n\t\tconst filePath = join(this.eventsDir, filename);\n\t\ttry {\n\t\t\tunlinkSync(filePath);\n\t\t} catch (err) {\n\t\t\t// ENOENT is fine (file already deleted), other errors are warnings\n\t\t\tif (err instanceof Error && \"code\" in err && err.code !== \"ENOENT\") {\n\t\t\t\tlog.logWarning(`Failed to delete event file: ${filename}`, String(err));\n\t\t\t}\n\t\t}\n\t\tthis.knownFiles.delete(filename);\n\t}\n\n\tprivate sleep(ms: number): Promise<void> {\n\t\treturn new Promise((resolve) => setTimeout(resolve, ms));\n\t}\n}\n\n/**\n * Create and start an events watcher.\n */\nexport function createEventsWatcher(workspaceDir: string, slack: SlackBot): EventsWatcher {\n\tconst eventsDir = join(workspaceDir, \"events\");\n\treturn new EventsWatcher(eventsDir, slack);\n}\n"]}