@oh-my-pi/pi-mom 0.1.0

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.
@@ -0,0 +1,117 @@
1
+ import { LogLevel, WebClient } from "@slack/web-api";
2
+
3
+ interface Message {
4
+ ts: string;
5
+ user?: string;
6
+ text?: string;
7
+ thread_ts?: string;
8
+ reply_count?: number;
9
+ files?: Array<{ name: string; url_private?: string }>;
10
+ }
11
+
12
+ function formatTs(ts: string): string {
13
+ const date = new Date(parseFloat(ts) * 1000);
14
+ return date
15
+ .toISOString()
16
+ .replace("T", " ")
17
+ .replace(/\.\d+Z$/, "");
18
+ }
19
+
20
+ function formatMessage(ts: string, user: string, text: string, indent = ""): string {
21
+ const prefix = `[${formatTs(ts)}] ${user}: `;
22
+ const lines = text.split("\n");
23
+ const firstLine = `${indent}${prefix}${lines[0]}`;
24
+ if (lines.length === 1) return firstLine;
25
+ // All continuation lines get same indent as content start
26
+ const contentIndent = indent + " ".repeat(prefix.length);
27
+ return [firstLine, ...lines.slice(1).map((l) => contentIndent + l)].join("\n");
28
+ }
29
+
30
+ export async function downloadChannel(channelId: string, botToken: string): Promise<void> {
31
+ const client = new WebClient(botToken, { logLevel: LogLevel.ERROR });
32
+
33
+ console.error(`Fetching channel info for ${channelId}...`);
34
+
35
+ // Get channel info
36
+ let channelName = channelId;
37
+ try {
38
+ const info = await client.conversations.info({ channel: channelId });
39
+ channelName = (info.channel as any)?.name || channelId;
40
+ } catch {
41
+ // DM channels don't have names, that's fine
42
+ }
43
+
44
+ console.error(`Downloading history for #${channelName} (${channelId})...`);
45
+
46
+ // Fetch all messages
47
+ const messages: Message[] = [];
48
+ let cursor: string | undefined;
49
+
50
+ do {
51
+ const response = await client.conversations.history({
52
+ channel: channelId,
53
+ limit: 200,
54
+ cursor,
55
+ });
56
+
57
+ if (response.messages) {
58
+ messages.push(...(response.messages as Message[]));
59
+ }
60
+
61
+ cursor = response.response_metadata?.next_cursor;
62
+ console.error(` Fetched ${messages.length} messages...`);
63
+ } while (cursor);
64
+
65
+ // Reverse to chronological order
66
+ messages.reverse();
67
+
68
+ // Build map of thread replies
69
+ const threadReplies = new Map<string, Message[]>();
70
+ const threadsToFetch = messages.filter((m) => m.reply_count && m.reply_count > 0);
71
+
72
+ console.error(`Fetching ${threadsToFetch.length} threads...`);
73
+
74
+ for (let i = 0; i < threadsToFetch.length; i++) {
75
+ const parent = threadsToFetch[i];
76
+ console.error(` Thread ${i + 1}/${threadsToFetch.length} (${parent.reply_count} replies)...`);
77
+
78
+ const replies: Message[] = [];
79
+ let threadCursor: string | undefined;
80
+
81
+ do {
82
+ const response = await client.conversations.replies({
83
+ channel: channelId,
84
+ ts: parent.ts,
85
+ limit: 200,
86
+ cursor: threadCursor,
87
+ });
88
+
89
+ if (response.messages) {
90
+ // Skip the first message (it's the parent)
91
+ replies.push(...(response.messages as Message[]).slice(1));
92
+ }
93
+
94
+ threadCursor = response.response_metadata?.next_cursor;
95
+ } while (threadCursor);
96
+
97
+ threadReplies.set(parent.ts, replies);
98
+ }
99
+
100
+ // Output messages with thread replies interleaved
101
+ let totalReplies = 0;
102
+ for (const msg of messages) {
103
+ // Output the message
104
+ console.log(formatMessage(msg.ts, msg.user || "unknown", msg.text || ""));
105
+
106
+ // Output thread replies right after parent (indented)
107
+ const replies = threadReplies.get(msg.ts);
108
+ if (replies) {
109
+ for (const reply of replies) {
110
+ console.log(formatMessage(reply.ts, reply.user || "unknown", reply.text || "", " "));
111
+ totalReplies++;
112
+ }
113
+ }
114
+ }
115
+
116
+ console.error(`Done! ${messages.length} messages, ${totalReplies} thread replies`);
117
+ }
package/src/events.ts ADDED
@@ -0,0 +1,385 @@
1
+ import { existsSync, type FSWatcher, mkdirSync, readdirSync, statSync, unlinkSync, watch } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { logger } from "@oh-my-pi/pi-coding-agent";
5
+ import { Cron } from "croner";
6
+ import * as log from "./log";
7
+ import type { SlackBot, SlackEvent } from "./slack";
8
+
9
+ // ============================================================================
10
+ // Event Types
11
+ // ============================================================================
12
+
13
+ export interface ImmediateEvent {
14
+ type: "immediate";
15
+ channelId: string;
16
+ text: string;
17
+ }
18
+
19
+ export interface OneShotEvent {
20
+ type: "one-shot";
21
+ channelId: string;
22
+ text: string;
23
+ at: string; // ISO 8601 with timezone offset
24
+ }
25
+
26
+ export interface PeriodicEvent {
27
+ type: "periodic";
28
+ channelId: string;
29
+ text: string;
30
+ schedule: string; // cron syntax
31
+ timezone: string; // IANA timezone
32
+ }
33
+
34
+ export type MomEvent = ImmediateEvent | OneShotEvent | PeriodicEvent;
35
+
36
+ // ============================================================================
37
+ // EventsWatcher
38
+ // ============================================================================
39
+
40
+ const DEBOUNCE_MS = 100;
41
+ const MAX_RETRIES = 3;
42
+ const RETRY_BASE_MS = 100;
43
+
44
+ export class EventsWatcher {
45
+ private timers: Map<string, NodeJS.Timeout> = new Map();
46
+ private crons: Map<string, Cron> = new Map();
47
+ private debounceTimers: Map<string, NodeJS.Timeout> = new Map();
48
+ private startTime: number;
49
+ private watcher: FSWatcher | null = null;
50
+ private knownFiles: Set<string> = new Set();
51
+
52
+ constructor(
53
+ private eventsDir: string,
54
+ private slack: SlackBot,
55
+ ) {
56
+ this.startTime = Date.now();
57
+ }
58
+
59
+ /**
60
+ * Start watching for events. Call this after SlackBot is ready.
61
+ */
62
+ start(): void {
63
+ // Ensure events directory exists
64
+ if (!existsSync(this.eventsDir)) {
65
+ mkdirSync(this.eventsDir, { recursive: true });
66
+ }
67
+
68
+ log.logInfo(`Events watcher starting, dir: ${this.eventsDir}`);
69
+
70
+ // Scan existing files
71
+ this.scanExisting();
72
+
73
+ // Watch for changes
74
+ this.watcher = watch(this.eventsDir, (_eventType, filename) => {
75
+ if (!filename || !filename.endsWith(".json")) return;
76
+ this.debounce(filename, () => this.handleFileChange(filename));
77
+ });
78
+
79
+ log.logInfo(`Events watcher started, tracking ${this.knownFiles.size} files`);
80
+ }
81
+
82
+ /**
83
+ * Stop watching and cancel all scheduled events.
84
+ */
85
+ stop(): void {
86
+ // Stop fs watcher
87
+ if (this.watcher) {
88
+ this.watcher.close();
89
+ this.watcher = null;
90
+ }
91
+
92
+ // Cancel all debounce timers
93
+ for (const timer of this.debounceTimers.values()) {
94
+ clearTimeout(timer);
95
+ }
96
+ this.debounceTimers.clear();
97
+
98
+ // Cancel all scheduled timers
99
+ for (const timer of this.timers.values()) {
100
+ clearTimeout(timer);
101
+ }
102
+ this.timers.clear();
103
+
104
+ // Cancel all cron jobs
105
+ for (const cron of this.crons.values()) {
106
+ cron.stop();
107
+ }
108
+ this.crons.clear();
109
+
110
+ this.knownFiles.clear();
111
+ log.logInfo("Events watcher stopped");
112
+ }
113
+
114
+ private debounce(filename: string, fn: () => void): void {
115
+ const existing = this.debounceTimers.get(filename);
116
+ if (existing) {
117
+ clearTimeout(existing);
118
+ }
119
+ this.debounceTimers.set(
120
+ filename,
121
+ setTimeout(() => {
122
+ this.debounceTimers.delete(filename);
123
+ fn();
124
+ }, DEBOUNCE_MS),
125
+ );
126
+ }
127
+
128
+ private scanExisting(): void {
129
+ let files: string[];
130
+ try {
131
+ files = readdirSync(this.eventsDir).filter((f) => f.endsWith(".json"));
132
+ } catch (err) {
133
+ log.logWarning("Failed to read events directory", String(err));
134
+ return;
135
+ }
136
+
137
+ for (const filename of files) {
138
+ this.handleFile(filename);
139
+ }
140
+ }
141
+
142
+ private handleFileChange(filename: string): void {
143
+ const filePath = join(this.eventsDir, filename);
144
+
145
+ if (!existsSync(filePath)) {
146
+ // File was deleted
147
+ this.handleDelete(filename);
148
+ } else if (this.knownFiles.has(filename)) {
149
+ // File was modified - cancel existing and re-schedule
150
+ this.cancelScheduled(filename);
151
+ this.handleFile(filename);
152
+ } else {
153
+ // New file
154
+ this.handleFile(filename);
155
+ }
156
+ }
157
+
158
+ private handleDelete(filename: string): void {
159
+ if (!this.knownFiles.has(filename)) return;
160
+
161
+ log.logInfo(`Event file deleted: ${filename}`);
162
+ this.cancelScheduled(filename);
163
+ this.knownFiles.delete(filename);
164
+ }
165
+
166
+ private cancelScheduled(filename: string): void {
167
+ const timer = this.timers.get(filename);
168
+ if (timer) {
169
+ clearTimeout(timer);
170
+ this.timers.delete(filename);
171
+ }
172
+
173
+ const cron = this.crons.get(filename);
174
+ if (cron) {
175
+ cron.stop();
176
+ this.crons.delete(filename);
177
+ }
178
+ }
179
+
180
+ private async handleFile(filename: string): Promise<void> {
181
+ const filePath = join(this.eventsDir, filename);
182
+
183
+ // Mark as known immediately to prevent duplicate processing
184
+ this.knownFiles.add(filename);
185
+
186
+ // Parse with retries
187
+ let event: MomEvent | null = null;
188
+ let lastError: Error | null = null;
189
+
190
+ for (let i = 0; i < MAX_RETRIES; i++) {
191
+ try {
192
+ const content = await readFile(filePath, "utf-8");
193
+ event = this.parseEvent(content, filename);
194
+ break;
195
+ } catch (err) {
196
+ lastError = err instanceof Error ? err : new Error(String(err));
197
+ if (i < MAX_RETRIES - 1) {
198
+ await this.sleep(RETRY_BASE_MS * 2 ** i);
199
+ }
200
+ }
201
+ }
202
+
203
+ if (!event) {
204
+ log.logWarning(`Failed to parse event file after ${MAX_RETRIES} retries: ${filename}`, lastError?.message);
205
+ this.deleteFile(filename);
206
+ return;
207
+ }
208
+
209
+ // Schedule based on type
210
+ switch (event.type) {
211
+ case "immediate":
212
+ this.handleImmediate(filename, event);
213
+ break;
214
+ case "one-shot":
215
+ this.handleOneShot(filename, event);
216
+ break;
217
+ case "periodic":
218
+ this.handlePeriodic(filename, event);
219
+ break;
220
+ }
221
+ }
222
+
223
+ private parseEvent(content: string, filename: string): MomEvent | null {
224
+ const data = JSON.parse(content);
225
+
226
+ if (!data.type || !data.channelId || !data.text) {
227
+ throw new Error(`Missing required fields (type, channelId, text) in ${filename}`);
228
+ }
229
+
230
+ switch (data.type) {
231
+ case "immediate":
232
+ return { type: "immediate", channelId: data.channelId, text: data.text };
233
+
234
+ case "one-shot":
235
+ if (!data.at) {
236
+ throw new Error(`Missing 'at' field for one-shot event in ${filename}`);
237
+ }
238
+ return { type: "one-shot", channelId: data.channelId, text: data.text, at: data.at };
239
+
240
+ case "periodic":
241
+ if (!data.schedule) {
242
+ throw new Error(`Missing 'schedule' field for periodic event in ${filename}`);
243
+ }
244
+ if (!data.timezone) {
245
+ throw new Error(`Missing 'timezone' field for periodic event in ${filename}`);
246
+ }
247
+ return {
248
+ type: "periodic",
249
+ channelId: data.channelId,
250
+ text: data.text,
251
+ schedule: data.schedule,
252
+ timezone: data.timezone,
253
+ };
254
+
255
+ default:
256
+ throw new Error(`Unknown event type '${data.type}' in ${filename}`);
257
+ }
258
+ }
259
+
260
+ private handleImmediate(filename: string, event: ImmediateEvent): void {
261
+ const filePath = join(this.eventsDir, filename);
262
+
263
+ // Check if stale (created before harness started)
264
+ try {
265
+ const stat = statSync(filePath);
266
+ if (stat.mtimeMs < this.startTime) {
267
+ log.logInfo(`Stale immediate event, deleting: ${filename}`);
268
+ this.deleteFile(filename);
269
+ return;
270
+ }
271
+ } catch (err) {
272
+ logger.debug("File stat failed", { filename, error: String(err) });
273
+ return;
274
+ }
275
+
276
+ log.logInfo(`Executing immediate event: ${filename}`);
277
+ this.execute(filename, event);
278
+ }
279
+
280
+ private handleOneShot(filename: string, event: OneShotEvent): void {
281
+ const atTime = new Date(event.at).getTime();
282
+ const now = Date.now();
283
+
284
+ if (atTime <= now) {
285
+ // Past - delete without executing
286
+ log.logInfo(`One-shot event in the past, deleting: ${filename}`);
287
+ this.deleteFile(filename);
288
+ return;
289
+ }
290
+
291
+ const delay = atTime - now;
292
+ log.logInfo(`Scheduling one-shot event: ${filename} in ${Math.round(delay / 1000)}s`);
293
+
294
+ const timer = setTimeout(() => {
295
+ this.timers.delete(filename);
296
+ log.logInfo(`Executing one-shot event: ${filename}`);
297
+ this.execute(filename, event);
298
+ }, delay);
299
+
300
+ this.timers.set(filename, timer);
301
+ }
302
+
303
+ private handlePeriodic(filename: string, event: PeriodicEvent): void {
304
+ try {
305
+ const cron = new Cron(event.schedule, { timezone: event.timezone }, () => {
306
+ log.logInfo(`Executing periodic event: ${filename}`);
307
+ this.execute(filename, event, false); // Don't delete periodic events
308
+ });
309
+
310
+ this.crons.set(filename, cron);
311
+
312
+ const next = cron.nextRun();
313
+ log.logInfo(`Scheduled periodic event: ${filename}, next run: ${next?.toISOString() ?? "unknown"}`);
314
+ } catch (err) {
315
+ log.logWarning(`Invalid cron schedule for ${filename}: ${event.schedule}`, String(err));
316
+ this.deleteFile(filename);
317
+ }
318
+ }
319
+
320
+ private execute(filename: string, event: MomEvent, deleteAfter: boolean = true): void {
321
+ // Format the message
322
+ let scheduleInfo: string;
323
+ switch (event.type) {
324
+ case "immediate":
325
+ scheduleInfo = "immediate";
326
+ break;
327
+ case "one-shot":
328
+ scheduleInfo = event.at;
329
+ break;
330
+ case "periodic":
331
+ scheduleInfo = event.schedule;
332
+ break;
333
+ }
334
+
335
+ const message = `[EVENT:${filename}:${event.type}:${scheduleInfo}] ${event.text}`;
336
+
337
+ // Create synthetic SlackEvent
338
+ const syntheticEvent: SlackEvent = {
339
+ type: "mention",
340
+ channel: event.channelId,
341
+ user: "EVENT",
342
+ text: message,
343
+ ts: Date.now().toString(),
344
+ };
345
+
346
+ // Enqueue for processing
347
+ const enqueued = this.slack.enqueueEvent(syntheticEvent);
348
+
349
+ if (enqueued && deleteAfter) {
350
+ // Delete file after successful enqueue (immediate and one-shot)
351
+ this.deleteFile(filename);
352
+ } else if (!enqueued) {
353
+ log.logWarning(`Event queue full, discarded: ${filename}`);
354
+ // Still delete immediate/one-shot even if discarded
355
+ if (deleteAfter) {
356
+ this.deleteFile(filename);
357
+ }
358
+ }
359
+ }
360
+
361
+ private deleteFile(filename: string): void {
362
+ const filePath = join(this.eventsDir, filename);
363
+ try {
364
+ unlinkSync(filePath);
365
+ } catch (err) {
366
+ // ENOENT is fine (file already deleted), other errors are warnings
367
+ if (err instanceof Error && "code" in err && err.code !== "ENOENT") {
368
+ log.logWarning(`Failed to delete event file: ${filename}`, String(err));
369
+ }
370
+ }
371
+ this.knownFiles.delete(filename);
372
+ }
373
+
374
+ private sleep(ms: number): Promise<void> {
375
+ return new Promise((resolve) => setTimeout(resolve, ms));
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Create and start an events watcher.
381
+ */
382
+ export function createEventsWatcher(workspaceDir: string, slack: SlackBot): EventsWatcher {
383
+ const eventsDir = join(workspaceDir, "events");
384
+ return new EventsWatcher(eventsDir, slack);
385
+ }