@mariozechner/pi-mom 0.19.1 → 0.19.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +50 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +72 -1
- package/dist/agent.js.map +1 -1
- package/dist/events.d.ts +57 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +310 -0
- package/dist/events.js.map +1 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +50 -6
- package/dist/main.js.map +1 -1
- package/dist/slack.d.ts +11 -3
- package/dist/slack.d.ts.map +1 -1
- package/dist/slack.js +25 -1
- package/dist/slack.js.map +1 -1
- package/package.json +5 -4
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"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"]}
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { Cron } from "croner";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, watch } from "fs";
|
|
3
|
+
import { readFile } from "fs/promises";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import * as log from "./log.js";
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// EventsWatcher
|
|
8
|
+
// ============================================================================
|
|
9
|
+
const DEBOUNCE_MS = 100;
|
|
10
|
+
const MAX_RETRIES = 3;
|
|
11
|
+
const RETRY_BASE_MS = 100;
|
|
12
|
+
export class EventsWatcher {
|
|
13
|
+
eventsDir;
|
|
14
|
+
slack;
|
|
15
|
+
timers = new Map();
|
|
16
|
+
crons = new Map();
|
|
17
|
+
debounceTimers = new Map();
|
|
18
|
+
startTime;
|
|
19
|
+
watcher = null;
|
|
20
|
+
knownFiles = new Set();
|
|
21
|
+
constructor(eventsDir, slack) {
|
|
22
|
+
this.eventsDir = eventsDir;
|
|
23
|
+
this.slack = slack;
|
|
24
|
+
this.startTime = Date.now();
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Start watching for events. Call this after SlackBot is ready.
|
|
28
|
+
*/
|
|
29
|
+
start() {
|
|
30
|
+
// Ensure events directory exists
|
|
31
|
+
if (!existsSync(this.eventsDir)) {
|
|
32
|
+
mkdirSync(this.eventsDir, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
log.logInfo(`Events watcher starting, dir: ${this.eventsDir}`);
|
|
35
|
+
// Scan existing files
|
|
36
|
+
this.scanExisting();
|
|
37
|
+
// Watch for changes
|
|
38
|
+
this.watcher = watch(this.eventsDir, (_eventType, filename) => {
|
|
39
|
+
if (!filename || !filename.endsWith(".json"))
|
|
40
|
+
return;
|
|
41
|
+
this.debounce(filename, () => this.handleFileChange(filename));
|
|
42
|
+
});
|
|
43
|
+
log.logInfo(`Events watcher started, tracking ${this.knownFiles.size} files`);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Stop watching and cancel all scheduled events.
|
|
47
|
+
*/
|
|
48
|
+
stop() {
|
|
49
|
+
// Stop fs watcher
|
|
50
|
+
if (this.watcher) {
|
|
51
|
+
this.watcher.close();
|
|
52
|
+
this.watcher = null;
|
|
53
|
+
}
|
|
54
|
+
// Cancel all debounce timers
|
|
55
|
+
for (const timer of this.debounceTimers.values()) {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
}
|
|
58
|
+
this.debounceTimers.clear();
|
|
59
|
+
// Cancel all scheduled timers
|
|
60
|
+
for (const timer of this.timers.values()) {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
}
|
|
63
|
+
this.timers.clear();
|
|
64
|
+
// Cancel all cron jobs
|
|
65
|
+
for (const cron of this.crons.values()) {
|
|
66
|
+
cron.stop();
|
|
67
|
+
}
|
|
68
|
+
this.crons.clear();
|
|
69
|
+
this.knownFiles.clear();
|
|
70
|
+
log.logInfo("Events watcher stopped");
|
|
71
|
+
}
|
|
72
|
+
debounce(filename, fn) {
|
|
73
|
+
const existing = this.debounceTimers.get(filename);
|
|
74
|
+
if (existing) {
|
|
75
|
+
clearTimeout(existing);
|
|
76
|
+
}
|
|
77
|
+
this.debounceTimers.set(filename, setTimeout(() => {
|
|
78
|
+
this.debounceTimers.delete(filename);
|
|
79
|
+
fn();
|
|
80
|
+
}, DEBOUNCE_MS));
|
|
81
|
+
}
|
|
82
|
+
scanExisting() {
|
|
83
|
+
let files;
|
|
84
|
+
try {
|
|
85
|
+
files = readdirSync(this.eventsDir).filter((f) => f.endsWith(".json"));
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
log.logWarning("Failed to read events directory", String(err));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
for (const filename of files) {
|
|
92
|
+
this.handleFile(filename);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
handleFileChange(filename) {
|
|
96
|
+
const filePath = join(this.eventsDir, filename);
|
|
97
|
+
if (!existsSync(filePath)) {
|
|
98
|
+
// File was deleted
|
|
99
|
+
this.handleDelete(filename);
|
|
100
|
+
}
|
|
101
|
+
else if (this.knownFiles.has(filename)) {
|
|
102
|
+
// File was modified - cancel existing and re-schedule
|
|
103
|
+
this.cancelScheduled(filename);
|
|
104
|
+
this.handleFile(filename);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
// New file
|
|
108
|
+
this.handleFile(filename);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
handleDelete(filename) {
|
|
112
|
+
if (!this.knownFiles.has(filename))
|
|
113
|
+
return;
|
|
114
|
+
log.logInfo(`Event file deleted: ${filename}`);
|
|
115
|
+
this.cancelScheduled(filename);
|
|
116
|
+
this.knownFiles.delete(filename);
|
|
117
|
+
}
|
|
118
|
+
cancelScheduled(filename) {
|
|
119
|
+
const timer = this.timers.get(filename);
|
|
120
|
+
if (timer) {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
this.timers.delete(filename);
|
|
123
|
+
}
|
|
124
|
+
const cron = this.crons.get(filename);
|
|
125
|
+
if (cron) {
|
|
126
|
+
cron.stop();
|
|
127
|
+
this.crons.delete(filename);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async handleFile(filename) {
|
|
131
|
+
const filePath = join(this.eventsDir, filename);
|
|
132
|
+
// Parse with retries
|
|
133
|
+
let event = null;
|
|
134
|
+
let lastError = null;
|
|
135
|
+
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
136
|
+
try {
|
|
137
|
+
const content = await readFile(filePath, "utf-8");
|
|
138
|
+
event = this.parseEvent(content, filename);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
143
|
+
if (i < MAX_RETRIES - 1) {
|
|
144
|
+
await this.sleep(RETRY_BASE_MS * 2 ** i);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!event) {
|
|
149
|
+
log.logWarning(`Failed to parse event file after ${MAX_RETRIES} retries: ${filename}`, lastError?.message);
|
|
150
|
+
this.deleteFile(filename);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
this.knownFiles.add(filename);
|
|
154
|
+
// Schedule based on type
|
|
155
|
+
switch (event.type) {
|
|
156
|
+
case "immediate":
|
|
157
|
+
this.handleImmediate(filename, event);
|
|
158
|
+
break;
|
|
159
|
+
case "one-shot":
|
|
160
|
+
this.handleOneShot(filename, event);
|
|
161
|
+
break;
|
|
162
|
+
case "periodic":
|
|
163
|
+
this.handlePeriodic(filename, event);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
parseEvent(content, filename) {
|
|
168
|
+
const data = JSON.parse(content);
|
|
169
|
+
if (!data.type || !data.channelId || !data.text) {
|
|
170
|
+
throw new Error(`Missing required fields (type, channelId, text) in ${filename}`);
|
|
171
|
+
}
|
|
172
|
+
switch (data.type) {
|
|
173
|
+
case "immediate":
|
|
174
|
+
return { type: "immediate", channelId: data.channelId, text: data.text };
|
|
175
|
+
case "one-shot":
|
|
176
|
+
if (!data.at) {
|
|
177
|
+
throw new Error(`Missing 'at' field for one-shot event in ${filename}`);
|
|
178
|
+
}
|
|
179
|
+
return { type: "one-shot", channelId: data.channelId, text: data.text, at: data.at };
|
|
180
|
+
case "periodic":
|
|
181
|
+
if (!data.schedule) {
|
|
182
|
+
throw new Error(`Missing 'schedule' field for periodic event in ${filename}`);
|
|
183
|
+
}
|
|
184
|
+
if (!data.timezone) {
|
|
185
|
+
throw new Error(`Missing 'timezone' field for periodic event in ${filename}`);
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
type: "periodic",
|
|
189
|
+
channelId: data.channelId,
|
|
190
|
+
text: data.text,
|
|
191
|
+
schedule: data.schedule,
|
|
192
|
+
timezone: data.timezone,
|
|
193
|
+
};
|
|
194
|
+
default:
|
|
195
|
+
throw new Error(`Unknown event type '${data.type}' in ${filename}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
handleImmediate(filename, event) {
|
|
199
|
+
const filePath = join(this.eventsDir, filename);
|
|
200
|
+
// Check if stale (created before harness started)
|
|
201
|
+
try {
|
|
202
|
+
const stat = statSync(filePath);
|
|
203
|
+
if (stat.mtimeMs < this.startTime) {
|
|
204
|
+
log.logInfo(`Stale immediate event, deleting: ${filename}`);
|
|
205
|
+
this.deleteFile(filename);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// File may have been deleted
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
log.logInfo(`Executing immediate event: ${filename}`);
|
|
214
|
+
this.execute(filename, event);
|
|
215
|
+
}
|
|
216
|
+
handleOneShot(filename, event) {
|
|
217
|
+
const atTime = new Date(event.at).getTime();
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
if (atTime <= now) {
|
|
220
|
+
// Past - delete without executing
|
|
221
|
+
log.logInfo(`One-shot event in the past, deleting: ${filename}`);
|
|
222
|
+
this.deleteFile(filename);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const delay = atTime - now;
|
|
226
|
+
log.logInfo(`Scheduling one-shot event: ${filename} in ${Math.round(delay / 1000)}s`);
|
|
227
|
+
const timer = setTimeout(() => {
|
|
228
|
+
this.timers.delete(filename);
|
|
229
|
+
log.logInfo(`Executing one-shot event: ${filename}`);
|
|
230
|
+
this.execute(filename, event);
|
|
231
|
+
}, delay);
|
|
232
|
+
this.timers.set(filename, timer);
|
|
233
|
+
}
|
|
234
|
+
handlePeriodic(filename, event) {
|
|
235
|
+
try {
|
|
236
|
+
const cron = new Cron(event.schedule, { timezone: event.timezone }, () => {
|
|
237
|
+
log.logInfo(`Executing periodic event: ${filename}`);
|
|
238
|
+
this.execute(filename, event, false); // Don't delete periodic events
|
|
239
|
+
});
|
|
240
|
+
this.crons.set(filename, cron);
|
|
241
|
+
const next = cron.nextRun();
|
|
242
|
+
log.logInfo(`Scheduled periodic event: ${filename}, next run: ${next?.toISOString() ?? "unknown"}`);
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
log.logWarning(`Invalid cron schedule for ${filename}: ${event.schedule}`, String(err));
|
|
246
|
+
this.deleteFile(filename);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
execute(filename, event, deleteAfter = true) {
|
|
250
|
+
// Format the message
|
|
251
|
+
let scheduleInfo;
|
|
252
|
+
switch (event.type) {
|
|
253
|
+
case "immediate":
|
|
254
|
+
scheduleInfo = "immediate";
|
|
255
|
+
break;
|
|
256
|
+
case "one-shot":
|
|
257
|
+
scheduleInfo = event.at;
|
|
258
|
+
break;
|
|
259
|
+
case "periodic":
|
|
260
|
+
scheduleInfo = event.schedule;
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
const message = `[EVENT:${filename}:${event.type}:${scheduleInfo}] ${event.text}`;
|
|
264
|
+
// Create synthetic SlackEvent
|
|
265
|
+
const syntheticEvent = {
|
|
266
|
+
type: "mention",
|
|
267
|
+
channel: event.channelId,
|
|
268
|
+
user: "EVENT",
|
|
269
|
+
text: message,
|
|
270
|
+
ts: Date.now().toString(),
|
|
271
|
+
};
|
|
272
|
+
// Enqueue for processing
|
|
273
|
+
const enqueued = this.slack.enqueueEvent(syntheticEvent);
|
|
274
|
+
if (enqueued && deleteAfter) {
|
|
275
|
+
// Delete file after successful enqueue (immediate and one-shot)
|
|
276
|
+
this.deleteFile(filename);
|
|
277
|
+
}
|
|
278
|
+
else if (!enqueued) {
|
|
279
|
+
log.logWarning(`Event queue full, discarded: ${filename}`);
|
|
280
|
+
// Still delete immediate/one-shot even if discarded
|
|
281
|
+
if (deleteAfter) {
|
|
282
|
+
this.deleteFile(filename);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
deleteFile(filename) {
|
|
287
|
+
const filePath = join(this.eventsDir, filename);
|
|
288
|
+
try {
|
|
289
|
+
unlinkSync(filePath);
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
// ENOENT is fine (file already deleted), other errors are warnings
|
|
293
|
+
if (err instanceof Error && "code" in err && err.code !== "ENOENT") {
|
|
294
|
+
log.logWarning(`Failed to delete event file: ${filename}`, String(err));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
this.knownFiles.delete(filename);
|
|
298
|
+
}
|
|
299
|
+
sleep(ms) {
|
|
300
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Create and start an events watcher.
|
|
305
|
+
*/
|
|
306
|
+
export function createEventsWatcher(workspaceDir, slack) {
|
|
307
|
+
const eventsDir = join(workspaceDir, "events");
|
|
308
|
+
return new EventsWatcher(eventsDir, slack);
|
|
309
|
+
}
|
|
310
|
+
//# sourceMappingURL=events.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"events.js","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"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","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"]}
|
package/dist/main.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"","sourcesContent":["#!/usr/bin/env node\n\nimport { join, resolve } from \"path\";\nimport { type AgentRunner, getOrCreateRunner } from \"./agent.js\";\nimport { syncLogToContext } from \"./context.js\";\nimport { downloadChannel } from \"./download.js\";\nimport * as log from \"./log.js\";\nimport { parseSandboxArg, type SandboxConfig, validateSandbox } from \"./sandbox.js\";\nimport { type MomHandler, type SlackBot, SlackBot as SlackBotClass, type SlackEvent } from \"./slack.js\";\nimport { ChannelStore } from \"./store.js\";\n\n// ============================================================================\n// Config\n// ============================================================================\n\nconst MOM_SLACK_APP_TOKEN = process.env.MOM_SLACK_APP_TOKEN;\nconst MOM_SLACK_BOT_TOKEN = process.env.MOM_SLACK_BOT_TOKEN;\nconst ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;\nconst ANTHROPIC_OAUTH_TOKEN = process.env.ANTHROPIC_OAUTH_TOKEN;\n\ninterface ParsedArgs {\n\tworkingDir?: string;\n\tsandbox: SandboxConfig;\n\tdownloadChannel?: string;\n}\n\nfunction parseArgs(): ParsedArgs {\n\tconst args = process.argv.slice(2);\n\tlet sandbox: SandboxConfig = { type: \"host\" };\n\tlet workingDir: string | undefined;\n\tlet downloadChannelId: string | undefined;\n\n\tfor (let i = 0; i < args.length; i++) {\n\t\tconst arg = args[i];\n\t\tif (arg.startsWith(\"--sandbox=\")) {\n\t\t\tsandbox = parseSandboxArg(arg.slice(\"--sandbox=\".length));\n\t\t} else if (arg === \"--sandbox\") {\n\t\t\tsandbox = parseSandboxArg(args[++i] || \"\");\n\t\t} else if (arg.startsWith(\"--download=\")) {\n\t\t\tdownloadChannelId = arg.slice(\"--download=\".length);\n\t\t} else if (arg === \"--download\") {\n\t\t\tdownloadChannelId = args[++i];\n\t\t} else if (!arg.startsWith(\"-\")) {\n\t\t\tworkingDir = arg;\n\t\t}\n\t}\n\n\treturn {\n\t\tworkingDir: workingDir ? resolve(workingDir) : undefined,\n\t\tsandbox,\n\t\tdownloadChannel: downloadChannelId,\n\t};\n}\n\nconst parsedArgs = parseArgs();\n\n// Handle --download mode\nif (parsedArgs.downloadChannel) {\n\tif (!MOM_SLACK_BOT_TOKEN) {\n\t\tconsole.error(\"Missing env: MOM_SLACK_BOT_TOKEN\");\n\t\tprocess.exit(1);\n\t}\n\tawait downloadChannel(parsedArgs.downloadChannel, MOM_SLACK_BOT_TOKEN);\n\tprocess.exit(0);\n}\n\n// Normal bot mode - require working dir\nif (!parsedArgs.workingDir) {\n\tconsole.error(\"Usage: mom [--sandbox=host|docker:<name>] <working-directory>\");\n\tconsole.error(\" mom --download <channel-id>\");\n\tprocess.exit(1);\n}\n\nconst { workingDir, sandbox } = { workingDir: parsedArgs.workingDir, sandbox: parsedArgs.sandbox };\n\nif (!MOM_SLACK_APP_TOKEN || !MOM_SLACK_BOT_TOKEN || (!ANTHROPIC_API_KEY && !ANTHROPIC_OAUTH_TOKEN)) {\n\tconsole.error(\"Missing env: MOM_SLACK_APP_TOKEN, MOM_SLACK_BOT_TOKEN, ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN\");\n\tprocess.exit(1);\n}\n\nawait validateSandbox(sandbox);\n\n// ============================================================================\n// State (per channel)\n// ============================================================================\n\ninterface ChannelState {\n\trunning: boolean;\n\trunner: AgentRunner;\n\tstore: ChannelStore;\n\tstopRequested: boolean;\n\tstopMessageTs?: string;\n}\n\nconst channelStates = new Map<string, ChannelState>();\n\nfunction getState(channelId: string): ChannelState {\n\tlet state = channelStates.get(channelId);\n\tif (!state) {\n\t\tconst channelDir = join(workingDir, channelId);\n\t\tstate = {\n\t\t\trunning: false,\n\t\t\trunner: getOrCreateRunner(sandbox, channelId, channelDir),\n\t\t\tstore: new ChannelStore({ workingDir, botToken: MOM_SLACK_BOT_TOKEN! }),\n\t\t\tstopRequested: false,\n\t\t};\n\t\tchannelStates.set(channelId, state);\n\t}\n\treturn state;\n}\n\n// ============================================================================\n// Create SlackContext adapter\n// ============================================================================\n\nfunction createSlackContext(event: SlackEvent, slack: SlackBot, state: ChannelState) {\n\tlet messageTs: string | null = null;\n\tlet accumulatedText = \"\";\n\tlet isWorking = true;\n\tconst workingIndicator = \" ...\";\n\tlet updatePromise = Promise.resolve();\n\n\tconst user = slack.getUser(event.user);\n\n\treturn {\n\t\tmessage: {\n\t\t\ttext: event.text,\n\t\t\trawText: event.text,\n\t\t\tuser: event.user,\n\t\t\tuserName: user?.userName,\n\t\t\tchannel: event.channel,\n\t\t\tts: event.ts,\n\t\t\tattachments: (event.attachments || []).map((a) => ({ local: a.local })),\n\t\t},\n\t\tchannelName: slack.getChannel(event.channel)?.name,\n\t\tstore: state.store,\n\t\tchannels: slack.getAllChannels().map((c) => ({ id: c.id, name: c.name })),\n\t\tusers: slack.getAllUsers().map((u) => ({ id: u.id, userName: u.userName, displayName: u.displayName })),\n\n\t\trespond: async (text: string, shouldLog = true) => {\n\t\t\tupdatePromise = updatePromise.then(async () => {\n\t\t\t\taccumulatedText = accumulatedText ? accumulatedText + \"\\n\" + text : text;\n\t\t\t\tconst displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\n\t\t\t\tif (messageTs) {\n\t\t\t\t\tawait slack.updateMessage(event.channel, messageTs, displayText);\n\t\t\t\t} else {\n\t\t\t\t\tmessageTs = await slack.postMessage(event.channel, displayText);\n\t\t\t\t}\n\n\t\t\t\tif (shouldLog && messageTs) {\n\t\t\t\t\tslack.logBotResponse(event.channel, text, messageTs);\n\t\t\t\t}\n\t\t\t});\n\t\t\tawait updatePromise;\n\t\t},\n\n\t\treplaceMessage: async (text: string) => {\n\t\t\tupdatePromise = updatePromise.then(async () => {\n\t\t\t\taccumulatedText = text;\n\t\t\t\tconst displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\t\t\t\tif (messageTs) {\n\t\t\t\t\tawait slack.updateMessage(event.channel, messageTs, displayText);\n\t\t\t\t} else {\n\t\t\t\t\tmessageTs = await slack.postMessage(event.channel, displayText);\n\t\t\t\t}\n\t\t\t});\n\t\t\tawait updatePromise;\n\t\t},\n\n\t\trespondInThread: async (text: string) => {\n\t\t\tupdatePromise = updatePromise.then(async () => {\n\t\t\t\tif (messageTs) {\n\t\t\t\t\tawait slack.postInThread(event.channel, messageTs, text);\n\t\t\t\t}\n\t\t\t});\n\t\t\tawait updatePromise;\n\t\t},\n\n\t\tsetTyping: async (isTyping: boolean) => {\n\t\t\tif (isTyping && !messageTs) {\n\t\t\t\taccumulatedText = \"_Thinking_\";\n\t\t\t\tmessageTs = await slack.postMessage(event.channel, accumulatedText + workingIndicator);\n\t\t\t}\n\t\t},\n\n\t\tuploadFile: async (filePath: string, title?: string) => {\n\t\t\tawait slack.uploadFile(event.channel, filePath, title);\n\t\t},\n\n\t\tsetWorking: async (working: boolean) => {\n\t\t\tupdatePromise = updatePromise.then(async () => {\n\t\t\t\tisWorking = working;\n\t\t\t\tif (messageTs) {\n\t\t\t\t\tconst displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\t\t\t\t\tawait slack.updateMessage(event.channel, messageTs, displayText);\n\t\t\t\t}\n\t\t\t});\n\t\t\tawait updatePromise;\n\t\t},\n\t};\n}\n\n// ============================================================================\n// Handler\n// ============================================================================\n\nconst handler: MomHandler = {\n\tisRunning(channelId: string): boolean {\n\t\tconst state = channelStates.get(channelId);\n\t\treturn state?.running ?? false;\n\t},\n\n\tasync handleStop(channelId: string, slack: SlackBot): Promise<void> {\n\t\tconst state = channelStates.get(channelId);\n\t\tif (state?.running) {\n\t\t\tstate.stopRequested = true;\n\t\t\tstate.runner.abort();\n\t\t\tconst ts = await slack.postMessage(channelId, \"_Stopping..._\");\n\t\t\tstate.stopMessageTs = ts; // Save for updating later\n\t\t} else {\n\t\t\tawait slack.postMessage(channelId, \"_Nothing running_\");\n\t\t}\n\t},\n\n\tasync handleEvent(event: SlackEvent, slack: SlackBot): Promise<void> {\n\t\tconst state = getState(event.channel);\n\t\tconst channelDir = join(workingDir, event.channel);\n\n\t\t// Start run\n\t\tstate.running = true;\n\t\tstate.stopRequested = false;\n\n\t\tlog.logInfo(`[${event.channel}] Starting run: ${event.text.substring(0, 50)}`);\n\n\t\ttry {\n\t\t\t// SYNC context from log.jsonl BEFORE processing\n\t\t\t// This adds any messages that were logged while mom wasn't running\n\t\t\t// Exclude messages >= current ts (will be handled by agent)\n\t\t\tconst syncedCount = syncLogToContext(channelDir, event.ts);\n\t\t\tif (syncedCount > 0) {\n\t\t\t\tlog.logInfo(`[${event.channel}] Synced ${syncedCount} messages from log to context`);\n\t\t\t}\n\n\t\t\t// Create context adapter\n\t\t\tconst ctx = createSlackContext(event, slack, state);\n\n\t\t\t// Run the agent\n\t\t\tawait ctx.setTyping(true);\n\t\t\tawait ctx.setWorking(true);\n\t\t\tconst result = await state.runner.run(ctx as any, state.store);\n\t\t\tawait ctx.setWorking(false);\n\n\t\t\tif (result.stopReason === \"aborted\" && state.stopRequested) {\n\t\t\t\tif (state.stopMessageTs) {\n\t\t\t\t\tawait slack.updateMessage(event.channel, state.stopMessageTs, \"_Stopped_\");\n\t\t\t\t\tstate.stopMessageTs = undefined;\n\t\t\t\t} else {\n\t\t\t\t\tawait slack.postMessage(event.channel, \"_Stopped_\");\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tlog.logWarning(`[${event.channel}] Run error`, err instanceof Error ? err.message : String(err));\n\t\t} finally {\n\t\t\tstate.running = false;\n\t\t}\n\t},\n};\n\n// ============================================================================\n// Start\n// ============================================================================\n\nlog.logStartup(workingDir, sandbox.type === \"host\" ? \"host\" : `docker:${sandbox.container}`);\n\n// Shared store for attachment downloads (also used per-channel in getState)\nconst sharedStore = new ChannelStore({ workingDir, botToken: MOM_SLACK_BOT_TOKEN! });\n\nconst bot = new SlackBotClass(handler, {\n\tappToken: MOM_SLACK_APP_TOKEN,\n\tbotToken: MOM_SLACK_BOT_TOKEN,\n\tworkingDir,\n\tstore: sharedStore,\n});\n\nbot.start();\n"]}
|
|
1
|
+
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"","sourcesContent":["#!/usr/bin/env node\n\nimport { join, resolve } from \"path\";\nimport { type AgentRunner, getOrCreateRunner } from \"./agent.js\";\nimport { syncLogToContext } from \"./context.js\";\nimport { downloadChannel } from \"./download.js\";\nimport { createEventsWatcher } from \"./events.js\";\nimport * as log from \"./log.js\";\nimport { parseSandboxArg, type SandboxConfig, validateSandbox } from \"./sandbox.js\";\nimport { type MomHandler, type SlackBot, SlackBot as SlackBotClass, type SlackEvent } from \"./slack.js\";\nimport { ChannelStore } from \"./store.js\";\n\n// ============================================================================\n// Config\n// ============================================================================\n\nconst MOM_SLACK_APP_TOKEN = process.env.MOM_SLACK_APP_TOKEN;\nconst MOM_SLACK_BOT_TOKEN = process.env.MOM_SLACK_BOT_TOKEN;\nconst ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;\nconst ANTHROPIC_OAUTH_TOKEN = process.env.ANTHROPIC_OAUTH_TOKEN;\n\ninterface ParsedArgs {\n\tworkingDir?: string;\n\tsandbox: SandboxConfig;\n\tdownloadChannel?: string;\n}\n\nfunction parseArgs(): ParsedArgs {\n\tconst args = process.argv.slice(2);\n\tlet sandbox: SandboxConfig = { type: \"host\" };\n\tlet workingDir: string | undefined;\n\tlet downloadChannelId: string | undefined;\n\n\tfor (let i = 0; i < args.length; i++) {\n\t\tconst arg = args[i];\n\t\tif (arg.startsWith(\"--sandbox=\")) {\n\t\t\tsandbox = parseSandboxArg(arg.slice(\"--sandbox=\".length));\n\t\t} else if (arg === \"--sandbox\") {\n\t\t\tsandbox = parseSandboxArg(args[++i] || \"\");\n\t\t} else if (arg.startsWith(\"--download=\")) {\n\t\t\tdownloadChannelId = arg.slice(\"--download=\".length);\n\t\t} else if (arg === \"--download\") {\n\t\t\tdownloadChannelId = args[++i];\n\t\t} else if (!arg.startsWith(\"-\")) {\n\t\t\tworkingDir = arg;\n\t\t}\n\t}\n\n\treturn {\n\t\tworkingDir: workingDir ? resolve(workingDir) : undefined,\n\t\tsandbox,\n\t\tdownloadChannel: downloadChannelId,\n\t};\n}\n\nconst parsedArgs = parseArgs();\n\n// Handle --download mode\nif (parsedArgs.downloadChannel) {\n\tif (!MOM_SLACK_BOT_TOKEN) {\n\t\tconsole.error(\"Missing env: MOM_SLACK_BOT_TOKEN\");\n\t\tprocess.exit(1);\n\t}\n\tawait downloadChannel(parsedArgs.downloadChannel, MOM_SLACK_BOT_TOKEN);\n\tprocess.exit(0);\n}\n\n// Normal bot mode - require working dir\nif (!parsedArgs.workingDir) {\n\tconsole.error(\"Usage: mom [--sandbox=host|docker:<name>] <working-directory>\");\n\tconsole.error(\" mom --download <channel-id>\");\n\tprocess.exit(1);\n}\n\nconst { workingDir, sandbox } = { workingDir: parsedArgs.workingDir, sandbox: parsedArgs.sandbox };\n\nif (!MOM_SLACK_APP_TOKEN || !MOM_SLACK_BOT_TOKEN || (!ANTHROPIC_API_KEY && !ANTHROPIC_OAUTH_TOKEN)) {\n\tconsole.error(\"Missing env: MOM_SLACK_APP_TOKEN, MOM_SLACK_BOT_TOKEN, ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN\");\n\tprocess.exit(1);\n}\n\nawait validateSandbox(sandbox);\n\n// ============================================================================\n// State (per channel)\n// ============================================================================\n\ninterface ChannelState {\n\trunning: boolean;\n\trunner: AgentRunner;\n\tstore: ChannelStore;\n\tstopRequested: boolean;\n\tstopMessageTs?: string;\n}\n\nconst channelStates = new Map<string, ChannelState>();\n\nfunction getState(channelId: string): ChannelState {\n\tlet state = channelStates.get(channelId);\n\tif (!state) {\n\t\tconst channelDir = join(workingDir, channelId);\n\t\tstate = {\n\t\t\trunning: false,\n\t\t\trunner: getOrCreateRunner(sandbox, channelId, channelDir),\n\t\t\tstore: new ChannelStore({ workingDir, botToken: MOM_SLACK_BOT_TOKEN! }),\n\t\t\tstopRequested: false,\n\t\t};\n\t\tchannelStates.set(channelId, state);\n\t}\n\treturn state;\n}\n\n// ============================================================================\n// Create SlackContext adapter\n// ============================================================================\n\nfunction createSlackContext(event: SlackEvent, slack: SlackBot, state: ChannelState, isEvent?: boolean) {\n\tlet messageTs: string | null = null;\n\tconst threadMessageTs: string[] = [];\n\tlet accumulatedText = \"\";\n\tlet isWorking = true;\n\tconst workingIndicator = \" ...\";\n\tlet updatePromise = Promise.resolve();\n\n\tconst user = slack.getUser(event.user);\n\n\t// Extract event filename for status message\n\tconst eventFilename = isEvent ? event.text.match(/^\\[EVENT:([^:]+):/)?.[1] : undefined;\n\n\treturn {\n\t\tmessage: {\n\t\t\ttext: event.text,\n\t\t\trawText: event.text,\n\t\t\tuser: event.user,\n\t\t\tuserName: user?.userName,\n\t\t\tchannel: event.channel,\n\t\t\tts: event.ts,\n\t\t\tattachments: (event.attachments || []).map((a) => ({ local: a.local })),\n\t\t},\n\t\tchannelName: slack.getChannel(event.channel)?.name,\n\t\tstore: state.store,\n\t\tchannels: slack.getAllChannels().map((c) => ({ id: c.id, name: c.name })),\n\t\tusers: slack.getAllUsers().map((u) => ({ id: u.id, userName: u.userName, displayName: u.displayName })),\n\n\t\trespond: async (text: string, shouldLog = true) => {\n\t\t\tupdatePromise = updatePromise.then(async () => {\n\t\t\t\taccumulatedText = accumulatedText ? accumulatedText + \"\\n\" + text : text;\n\t\t\t\tconst displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\n\t\t\t\tif (messageTs) {\n\t\t\t\t\tawait slack.updateMessage(event.channel, messageTs, displayText);\n\t\t\t\t} else {\n\t\t\t\t\tmessageTs = await slack.postMessage(event.channel, displayText);\n\t\t\t\t}\n\n\t\t\t\tif (shouldLog && messageTs) {\n\t\t\t\t\tslack.logBotResponse(event.channel, text, messageTs);\n\t\t\t\t}\n\t\t\t});\n\t\t\tawait updatePromise;\n\t\t},\n\n\t\treplaceMessage: async (text: string) => {\n\t\t\tupdatePromise = updatePromise.then(async () => {\n\t\t\t\taccumulatedText = text;\n\t\t\t\tconst displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\t\t\t\tif (messageTs) {\n\t\t\t\t\tawait slack.updateMessage(event.channel, messageTs, displayText);\n\t\t\t\t} else {\n\t\t\t\t\tmessageTs = await slack.postMessage(event.channel, displayText);\n\t\t\t\t}\n\t\t\t});\n\t\t\tawait updatePromise;\n\t\t},\n\n\t\trespondInThread: async (text: string) => {\n\t\t\tupdatePromise = updatePromise.then(async () => {\n\t\t\t\tif (messageTs) {\n\t\t\t\t\tconst ts = await slack.postInThread(event.channel, messageTs, text);\n\t\t\t\t\tthreadMessageTs.push(ts);\n\t\t\t\t}\n\t\t\t});\n\t\t\tawait updatePromise;\n\t\t},\n\n\t\tsetTyping: async (isTyping: boolean) => {\n\t\t\tif (isTyping && !messageTs) {\n\t\t\t\tupdatePromise = updatePromise.then(async () => {\n\t\t\t\t\tif (!messageTs) {\n\t\t\t\t\t\taccumulatedText = eventFilename ? `_Starting event: ${eventFilename}_` : \"_Thinking_\";\n\t\t\t\t\t\tmessageTs = await slack.postMessage(event.channel, accumulatedText + workingIndicator);\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t\tawait updatePromise;\n\t\t\t}\n\t\t},\n\n\t\tuploadFile: async (filePath: string, title?: string) => {\n\t\t\tawait slack.uploadFile(event.channel, filePath, title);\n\t\t},\n\n\t\tsetWorking: async (working: boolean) => {\n\t\t\tupdatePromise = updatePromise.then(async () => {\n\t\t\t\tisWorking = working;\n\t\t\t\tif (messageTs) {\n\t\t\t\t\tconst displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\t\t\t\t\tawait slack.updateMessage(event.channel, messageTs, displayText);\n\t\t\t\t}\n\t\t\t});\n\t\t\tawait updatePromise;\n\t\t},\n\n\t\tdeleteMessage: async () => {\n\t\t\tupdatePromise = updatePromise.then(async () => {\n\t\t\t\t// Delete thread messages first (in reverse order)\n\t\t\t\tfor (let i = threadMessageTs.length - 1; i >= 0; i--) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait slack.deleteMessage(event.channel, threadMessageTs[i]);\n\t\t\t\t\t} catch {\n\t\t\t\t\t\t// Ignore errors deleting thread messages\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tthreadMessageTs.length = 0;\n\t\t\t\t// Then delete main message\n\t\t\t\tif (messageTs) {\n\t\t\t\t\tawait slack.deleteMessage(event.channel, messageTs);\n\t\t\t\t\tmessageTs = null;\n\t\t\t\t}\n\t\t\t});\n\t\t\tawait updatePromise;\n\t\t},\n\t};\n}\n\n// ============================================================================\n// Handler\n// ============================================================================\n\nconst handler: MomHandler = {\n\tisRunning(channelId: string): boolean {\n\t\tconst state = channelStates.get(channelId);\n\t\treturn state?.running ?? false;\n\t},\n\n\tasync handleStop(channelId: string, slack: SlackBot): Promise<void> {\n\t\tconst state = channelStates.get(channelId);\n\t\tif (state?.running) {\n\t\t\tstate.stopRequested = true;\n\t\t\tstate.runner.abort();\n\t\t\tconst ts = await slack.postMessage(channelId, \"_Stopping..._\");\n\t\t\tstate.stopMessageTs = ts; // Save for updating later\n\t\t} else {\n\t\t\tawait slack.postMessage(channelId, \"_Nothing running_\");\n\t\t}\n\t},\n\n\tasync handleEvent(event: SlackEvent, slack: SlackBot, isEvent?: boolean): Promise<void> {\n\t\tconst state = getState(event.channel);\n\t\tconst channelDir = join(workingDir, event.channel);\n\n\t\t// Start run\n\t\tstate.running = true;\n\t\tstate.stopRequested = false;\n\n\t\tlog.logInfo(`[${event.channel}] Starting run: ${event.text.substring(0, 50)}`);\n\n\t\ttry {\n\t\t\t// SYNC context from log.jsonl BEFORE processing\n\t\t\t// This adds any messages that were logged while mom wasn't running\n\t\t\t// Exclude messages >= current ts (will be handled by agent)\n\t\t\tconst syncedCount = syncLogToContext(channelDir, event.ts);\n\t\t\tif (syncedCount > 0) {\n\t\t\t\tlog.logInfo(`[${event.channel}] Synced ${syncedCount} messages from log to context`);\n\t\t\t}\n\n\t\t\t// Create context adapter\n\t\t\tconst ctx = createSlackContext(event, slack, state, isEvent);\n\n\t\t\t// Run the agent\n\t\t\tawait ctx.setTyping(true);\n\t\t\tawait ctx.setWorking(true);\n\t\t\tconst result = await state.runner.run(ctx as any, state.store);\n\t\t\tawait ctx.setWorking(false);\n\n\t\t\tif (result.stopReason === \"aborted\" && state.stopRequested) {\n\t\t\t\tif (state.stopMessageTs) {\n\t\t\t\t\tawait slack.updateMessage(event.channel, state.stopMessageTs, \"_Stopped_\");\n\t\t\t\t\tstate.stopMessageTs = undefined;\n\t\t\t\t} else {\n\t\t\t\t\tawait slack.postMessage(event.channel, \"_Stopped_\");\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tlog.logWarning(`[${event.channel}] Run error`, err instanceof Error ? err.message : String(err));\n\t\t} finally {\n\t\t\tstate.running = false;\n\t\t}\n\t},\n};\n\n// ============================================================================\n// Start\n// ============================================================================\n\nlog.logStartup(workingDir, sandbox.type === \"host\" ? \"host\" : `docker:${sandbox.container}`);\n\n// Shared store for attachment downloads (also used per-channel in getState)\nconst sharedStore = new ChannelStore({ workingDir, botToken: MOM_SLACK_BOT_TOKEN! });\n\nconst bot = new SlackBotClass(handler, {\n\tappToken: MOM_SLACK_APP_TOKEN,\n\tbotToken: MOM_SLACK_BOT_TOKEN,\n\tworkingDir,\n\tstore: sharedStore,\n});\n\n// Start events watcher\nconst eventsWatcher = createEventsWatcher(workingDir, bot);\neventsWatcher.start();\n\n// Handle shutdown\nprocess.on(\"SIGINT\", () => {\n\tlog.logInfo(\"Shutting down...\");\n\teventsWatcher.stop();\n\tprocess.exit(0);\n});\n\nprocess.on(\"SIGTERM\", () => {\n\tlog.logInfo(\"Shutting down...\");\n\teventsWatcher.stop();\n\tprocess.exit(0);\n});\n\nbot.start();\n"]}
|
package/dist/main.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join, resolve } from "path";
|
|
|
3
3
|
import { getOrCreateRunner } from "./agent.js";
|
|
4
4
|
import { syncLogToContext } from "./context.js";
|
|
5
5
|
import { downloadChannel } from "./download.js";
|
|
6
|
+
import { createEventsWatcher } from "./events.js";
|
|
6
7
|
import * as log from "./log.js";
|
|
7
8
|
import { parseSandboxArg, validateSandbox } from "./sandbox.js";
|
|
8
9
|
import { SlackBot as SlackBotClass } from "./slack.js";
|
|
@@ -83,13 +84,16 @@ function getState(channelId) {
|
|
|
83
84
|
// ============================================================================
|
|
84
85
|
// Create SlackContext adapter
|
|
85
86
|
// ============================================================================
|
|
86
|
-
function createSlackContext(event, slack, state) {
|
|
87
|
+
function createSlackContext(event, slack, state, isEvent) {
|
|
87
88
|
let messageTs = null;
|
|
89
|
+
const threadMessageTs = [];
|
|
88
90
|
let accumulatedText = "";
|
|
89
91
|
let isWorking = true;
|
|
90
92
|
const workingIndicator = " ...";
|
|
91
93
|
let updatePromise = Promise.resolve();
|
|
92
94
|
const user = slack.getUser(event.user);
|
|
95
|
+
// Extract event filename for status message
|
|
96
|
+
const eventFilename = isEvent ? event.text.match(/^\[EVENT:([^:]+):/)?.[1] : undefined;
|
|
93
97
|
return {
|
|
94
98
|
message: {
|
|
95
99
|
text: event.text,
|
|
@@ -136,15 +140,21 @@ function createSlackContext(event, slack, state) {
|
|
|
136
140
|
respondInThread: async (text) => {
|
|
137
141
|
updatePromise = updatePromise.then(async () => {
|
|
138
142
|
if (messageTs) {
|
|
139
|
-
await slack.postInThread(event.channel, messageTs, text);
|
|
143
|
+
const ts = await slack.postInThread(event.channel, messageTs, text);
|
|
144
|
+
threadMessageTs.push(ts);
|
|
140
145
|
}
|
|
141
146
|
});
|
|
142
147
|
await updatePromise;
|
|
143
148
|
},
|
|
144
149
|
setTyping: async (isTyping) => {
|
|
145
150
|
if (isTyping && !messageTs) {
|
|
146
|
-
|
|
147
|
-
|
|
151
|
+
updatePromise = updatePromise.then(async () => {
|
|
152
|
+
if (!messageTs) {
|
|
153
|
+
accumulatedText = eventFilename ? `_Starting event: ${eventFilename}_` : "_Thinking_";
|
|
154
|
+
messageTs = await slack.postMessage(event.channel, accumulatedText + workingIndicator);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
await updatePromise;
|
|
148
158
|
}
|
|
149
159
|
},
|
|
150
160
|
uploadFile: async (filePath, title) => {
|
|
@@ -160,6 +170,26 @@ function createSlackContext(event, slack, state) {
|
|
|
160
170
|
});
|
|
161
171
|
await updatePromise;
|
|
162
172
|
},
|
|
173
|
+
deleteMessage: async () => {
|
|
174
|
+
updatePromise = updatePromise.then(async () => {
|
|
175
|
+
// Delete thread messages first (in reverse order)
|
|
176
|
+
for (let i = threadMessageTs.length - 1; i >= 0; i--) {
|
|
177
|
+
try {
|
|
178
|
+
await slack.deleteMessage(event.channel, threadMessageTs[i]);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// Ignore errors deleting thread messages
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
threadMessageTs.length = 0;
|
|
185
|
+
// Then delete main message
|
|
186
|
+
if (messageTs) {
|
|
187
|
+
await slack.deleteMessage(event.channel, messageTs);
|
|
188
|
+
messageTs = null;
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
await updatePromise;
|
|
192
|
+
},
|
|
163
193
|
};
|
|
164
194
|
}
|
|
165
195
|
// ============================================================================
|
|
@@ -182,7 +212,7 @@ const handler = {
|
|
|
182
212
|
await slack.postMessage(channelId, "_Nothing running_");
|
|
183
213
|
}
|
|
184
214
|
},
|
|
185
|
-
async handleEvent(event, slack) {
|
|
215
|
+
async handleEvent(event, slack, isEvent) {
|
|
186
216
|
const state = getState(event.channel);
|
|
187
217
|
const channelDir = join(workingDir, event.channel);
|
|
188
218
|
// Start run
|
|
@@ -198,7 +228,7 @@ const handler = {
|
|
|
198
228
|
log.logInfo(`[${event.channel}] Synced ${syncedCount} messages from log to context`);
|
|
199
229
|
}
|
|
200
230
|
// Create context adapter
|
|
201
|
-
const ctx = createSlackContext(event, slack, state);
|
|
231
|
+
const ctx = createSlackContext(event, slack, state, isEvent);
|
|
202
232
|
// Run the agent
|
|
203
233
|
await ctx.setTyping(true);
|
|
204
234
|
await ctx.setWorking(true);
|
|
@@ -234,5 +264,19 @@ const bot = new SlackBotClass(handler, {
|
|
|
234
264
|
workingDir,
|
|
235
265
|
store: sharedStore,
|
|
236
266
|
});
|
|
267
|
+
// Start events watcher
|
|
268
|
+
const eventsWatcher = createEventsWatcher(workingDir, bot);
|
|
269
|
+
eventsWatcher.start();
|
|
270
|
+
// Handle shutdown
|
|
271
|
+
process.on("SIGINT", () => {
|
|
272
|
+
log.logInfo("Shutting down...");
|
|
273
|
+
eventsWatcher.stop();
|
|
274
|
+
process.exit(0);
|
|
275
|
+
});
|
|
276
|
+
process.on("SIGTERM", () => {
|
|
277
|
+
log.logInfo("Shutting down...");
|
|
278
|
+
eventsWatcher.stop();
|
|
279
|
+
process.exit(0);
|
|
280
|
+
});
|
|
237
281
|
bot.start();
|
|
238
282
|
//# sourceMappingURL=main.js.map
|