@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.
package/src/slack.ts ADDED
@@ -0,0 +1,635 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { logger } from "@oh-my-pi/pi-coding-agent";
4
+ import { SocketModeClient } from "@slack/socket-mode";
5
+ import { WebClient } from "@slack/web-api";
6
+ import * as log from "./log";
7
+ import type { Attachment, ChannelStore } from "./store";
8
+
9
+ // ============================================================================
10
+ // Types
11
+ // ============================================================================
12
+
13
+ export interface SlackEvent {
14
+ type: "mention" | "dm";
15
+ channel: string;
16
+ ts: string;
17
+ user: string;
18
+ text: string;
19
+ files?: Array<{ name?: string; url_private_download?: string; url_private?: string }>;
20
+ /** Processed attachments with local paths (populated after logUserMessage) */
21
+ attachments?: Attachment[];
22
+ }
23
+
24
+ export interface SlackUser {
25
+ id: string;
26
+ userName: string;
27
+ displayName: string;
28
+ }
29
+
30
+ export interface SlackChannel {
31
+ id: string;
32
+ name: string;
33
+ }
34
+
35
+ // Types used by agent.ts
36
+ export interface ChannelInfo {
37
+ id: string;
38
+ name: string;
39
+ }
40
+
41
+ export interface UserInfo {
42
+ id: string;
43
+ userName: string;
44
+ displayName: string;
45
+ }
46
+
47
+ export interface SlackContext {
48
+ message: {
49
+ text: string;
50
+ rawText: string;
51
+ user: string;
52
+ userName?: string;
53
+ channel: string;
54
+ ts: string;
55
+ attachments: Array<{ local: string }>;
56
+ };
57
+ channelName?: string;
58
+ channels: ChannelInfo[];
59
+ users: UserInfo[];
60
+ respond: (text: string, shouldLog?: boolean) => Promise<void>;
61
+ replaceMessage: (text: string) => Promise<void>;
62
+ respondInThread: (text: string) => Promise<void>;
63
+ setTyping: (isTyping: boolean) => Promise<void>;
64
+ uploadFile: (filePath: string, title?: string) => Promise<void>;
65
+ setWorking: (working: boolean) => Promise<void>;
66
+ deleteMessage: () => Promise<void>;
67
+ }
68
+
69
+ export interface MomHandler {
70
+ /**
71
+ * Check if channel is currently running (SYNC)
72
+ */
73
+ isRunning(channelId: string): boolean;
74
+
75
+ /**
76
+ * Handle an event that triggers mom (ASYNC)
77
+ * Called only when isRunning() returned false for user messages.
78
+ * Events always queue and pass isEvent=true.
79
+ */
80
+ handleEvent(event: SlackEvent, slack: SlackBot, isEvent?: boolean): Promise<void>;
81
+
82
+ /**
83
+ * Handle stop command (ASYNC)
84
+ * Called when user says "stop" while mom is running
85
+ */
86
+ handleStop(channelId: string, slack: SlackBot): Promise<void>;
87
+ }
88
+
89
+ // ============================================================================
90
+ // Per-channel queue for sequential processing
91
+ // ============================================================================
92
+
93
+ type QueuedWork = () => Promise<void>;
94
+
95
+ class ChannelQueue {
96
+ private queue: QueuedWork[] = [];
97
+ private processing = false;
98
+
99
+ enqueue(work: QueuedWork): void {
100
+ this.queue.push(work);
101
+ this.processNext();
102
+ }
103
+
104
+ tryEnqueue(work: QueuedWork, maxSize: number): boolean {
105
+ if (this.queue.length >= maxSize) {
106
+ return false;
107
+ }
108
+ this.queue.push(work);
109
+ this.processNext();
110
+ return true;
111
+ }
112
+
113
+ size(): number {
114
+ return this.queue.length;
115
+ }
116
+
117
+ private async processNext(): Promise<void> {
118
+ if (this.processing || this.queue.length === 0) return;
119
+ this.processing = true;
120
+ const work = this.queue.shift()!;
121
+ try {
122
+ await work();
123
+ } catch (err) {
124
+ log.logWarning("Queue error", err instanceof Error ? err.message : String(err));
125
+ }
126
+ this.processing = false;
127
+ this.processNext();
128
+ }
129
+ }
130
+
131
+ // ============================================================================
132
+ // SlackBot
133
+ // ============================================================================
134
+
135
+ export class SlackBot {
136
+ private socketClient: SocketModeClient;
137
+ private webClient: WebClient;
138
+ private handler: MomHandler;
139
+ private workingDir: string;
140
+ private store: ChannelStore;
141
+ private botUserId: string | null = null;
142
+ private startupTs: string | null = null; // Messages older than this are just logged, not processed
143
+
144
+ private users = new Map<string, SlackUser>();
145
+ private channels = new Map<string, SlackChannel>();
146
+ private queues = new Map<string, ChannelQueue>();
147
+
148
+ constructor(
149
+ handler: MomHandler,
150
+ config: { appToken: string; botToken: string; workingDir: string; store: ChannelStore },
151
+ ) {
152
+ this.handler = handler;
153
+ this.workingDir = config.workingDir;
154
+ this.store = config.store;
155
+ this.socketClient = new SocketModeClient({ appToken: config.appToken });
156
+ this.webClient = new WebClient(config.botToken);
157
+ }
158
+
159
+ // ==========================================================================
160
+ // Public API
161
+ // ==========================================================================
162
+
163
+ async start(): Promise<void> {
164
+ const auth = await this.webClient.auth.test();
165
+ this.botUserId = auth.user_id as string;
166
+
167
+ await Promise.all([this.fetchUsers(), this.fetchChannels()]);
168
+ log.logInfo(`Loaded ${this.channels.size} channels, ${this.users.size} users`);
169
+
170
+ await this.backfillAllChannels();
171
+
172
+ this.setupEventHandlers();
173
+ await this.socketClient.start();
174
+
175
+ // Record startup time - messages older than this are just logged, not processed
176
+ this.startupTs = (Date.now() / 1000).toFixed(6);
177
+
178
+ log.logConnected();
179
+ }
180
+
181
+ getUser(userId: string): SlackUser | undefined {
182
+ return this.users.get(userId);
183
+ }
184
+
185
+ getChannel(channelId: string): SlackChannel | undefined {
186
+ return this.channels.get(channelId);
187
+ }
188
+
189
+ getAllUsers(): SlackUser[] {
190
+ return Array.from(this.users.values());
191
+ }
192
+
193
+ getAllChannels(): SlackChannel[] {
194
+ return Array.from(this.channels.values());
195
+ }
196
+
197
+ async postMessage(channel: string, text: string): Promise<string> {
198
+ const result = await this.webClient.chat.postMessage({ channel, text });
199
+ return result.ts as string;
200
+ }
201
+
202
+ async updateMessage(channel: string, ts: string, text: string): Promise<void> {
203
+ await this.webClient.chat.update({ channel, ts, text });
204
+ }
205
+
206
+ async deleteMessage(channel: string, ts: string): Promise<void> {
207
+ await this.webClient.chat.delete({ channel, ts });
208
+ }
209
+
210
+ async postInThread(channel: string, threadTs: string, text: string): Promise<string> {
211
+ const result = await this.webClient.chat.postMessage({ channel, thread_ts: threadTs, text });
212
+ return result.ts as string;
213
+ }
214
+
215
+ async uploadFile(channel: string, filePath: string, title?: string): Promise<void> {
216
+ const fileName = title || basename(filePath);
217
+ const fileContent = readFileSync(filePath);
218
+ await this.webClient.files.uploadV2({
219
+ channel_id: channel,
220
+ file: fileContent,
221
+ filename: fileName,
222
+ title: fileName,
223
+ });
224
+ }
225
+
226
+ /**
227
+ * Log a message to log.jsonl (SYNC)
228
+ * This is the ONLY place messages are written to log.jsonl
229
+ */
230
+ logToFile(channel: string, entry: object): void {
231
+ const dir = join(this.workingDir, channel);
232
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
233
+ appendFileSync(join(dir, "log.jsonl"), `${JSON.stringify(entry)}\n`);
234
+ }
235
+
236
+ /**
237
+ * Log a bot response to log.jsonl
238
+ */
239
+ logBotResponse(channel: string, text: string, ts: string): void {
240
+ this.logToFile(channel, {
241
+ date: new Date().toISOString(),
242
+ ts,
243
+ user: "bot",
244
+ text,
245
+ attachments: [],
246
+ isBot: true,
247
+ });
248
+ }
249
+
250
+ // ==========================================================================
251
+ // Events Integration
252
+ // ==========================================================================
253
+
254
+ /**
255
+ * Enqueue an event for processing. Always queues (no "already working" rejection).
256
+ * Returns true if enqueued, false if queue is full (max 5).
257
+ */
258
+ enqueueEvent(event: SlackEvent): boolean {
259
+ const queue = this.getQueue(event.channel);
260
+ const enqueued = queue.tryEnqueue(() => this.handler.handleEvent(event, this, true), 5);
261
+ if (!enqueued) {
262
+ log.logWarning(`Event queue full for ${event.channel}, discarding: ${event.text.substring(0, 50)}`);
263
+ return false;
264
+ }
265
+ log.logInfo(`Enqueued event for ${event.channel}: ${event.text.substring(0, 50)}`);
266
+ return true;
267
+ }
268
+
269
+ // ==========================================================================
270
+ // Private - Event Handlers
271
+ // ==========================================================================
272
+
273
+ private getQueue(channelId: string): ChannelQueue {
274
+ let queue = this.queues.get(channelId);
275
+ if (!queue) {
276
+ queue = new ChannelQueue();
277
+ this.queues.set(channelId, queue);
278
+ }
279
+ return queue;
280
+ }
281
+
282
+ private setupEventHandlers(): void {
283
+ // Channel @mentions
284
+ this.socketClient.on("app_mention", ({ event, ack }) => {
285
+ const e = event as {
286
+ text: string;
287
+ channel: string;
288
+ user: string;
289
+ ts: string;
290
+ files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;
291
+ };
292
+
293
+ // Skip DMs (handled by message event)
294
+ if (e.channel.startsWith("D")) {
295
+ ack();
296
+ return;
297
+ }
298
+
299
+ const slackEvent: SlackEvent = {
300
+ type: "mention",
301
+ channel: e.channel,
302
+ ts: e.ts,
303
+ user: e.user,
304
+ text: e.text.replace(/<@[A-Z0-9]+>/gi, "").trim(),
305
+ files: e.files,
306
+ };
307
+
308
+ // SYNC: Log to log.jsonl (ALWAYS, even for old messages)
309
+ // Also downloads attachments in background and stores local paths
310
+ slackEvent.attachments = this.logUserMessage(slackEvent);
311
+
312
+ // Only trigger processing for messages AFTER startup (not replayed old messages)
313
+ if (this.startupTs && e.ts < this.startupTs) {
314
+ log.logInfo(
315
+ `[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`,
316
+ );
317
+ ack();
318
+ return;
319
+ }
320
+
321
+ // Check for stop command - execute immediately, don't queue!
322
+ if (slackEvent.text.toLowerCase().trim() === "stop") {
323
+ if (this.handler.isRunning(e.channel)) {
324
+ this.handler.handleStop(e.channel, this); // Don't await, don't queue
325
+ } else {
326
+ this.postMessage(e.channel, "_Nothing running_");
327
+ }
328
+ ack();
329
+ return;
330
+ }
331
+
332
+ // SYNC: Check if busy
333
+ if (this.handler.isRunning(e.channel)) {
334
+ this.postMessage(e.channel, "_Already working. Say `@mom stop` to cancel._");
335
+ } else {
336
+ this.getQueue(e.channel).enqueue(() => this.handler.handleEvent(slackEvent, this));
337
+ }
338
+
339
+ ack();
340
+ });
341
+
342
+ // All messages (for logging) + DMs (for triggering)
343
+ this.socketClient.on("message", ({ event, ack }) => {
344
+ const e = event as {
345
+ text?: string;
346
+ channel: string;
347
+ user?: string;
348
+ ts: string;
349
+ channel_type?: string;
350
+ subtype?: string;
351
+ bot_id?: string;
352
+ files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;
353
+ };
354
+
355
+ // Skip bot messages, edits, etc.
356
+ if (e.bot_id || !e.user || e.user === this.botUserId) {
357
+ ack();
358
+ return;
359
+ }
360
+ if (e.subtype !== undefined && e.subtype !== "file_share") {
361
+ ack();
362
+ return;
363
+ }
364
+ if (!e.text && (!e.files || e.files.length === 0)) {
365
+ ack();
366
+ return;
367
+ }
368
+
369
+ const isDM = e.channel_type === "im";
370
+ const isBotMention = e.text?.includes(`<@${this.botUserId}>`);
371
+
372
+ // Skip channel @mentions - already handled by app_mention event
373
+ if (!isDM && isBotMention) {
374
+ ack();
375
+ return;
376
+ }
377
+
378
+ const slackEvent: SlackEvent = {
379
+ type: isDM ? "dm" : "mention",
380
+ channel: e.channel,
381
+ ts: e.ts,
382
+ user: e.user,
383
+ text: (e.text || "").replace(/<@[A-Z0-9]+>/gi, "").trim(),
384
+ files: e.files,
385
+ };
386
+
387
+ // SYNC: Log to log.jsonl (ALL messages - channel chatter and DMs)
388
+ // Also downloads attachments in background and stores local paths
389
+ slackEvent.attachments = this.logUserMessage(slackEvent);
390
+
391
+ // Only trigger processing for messages AFTER startup (not replayed old messages)
392
+ if (this.startupTs && e.ts < this.startupTs) {
393
+ log.logInfo(`[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`);
394
+ ack();
395
+ return;
396
+ }
397
+
398
+ // Only trigger handler for DMs
399
+ if (isDM) {
400
+ // Check for stop command - execute immediately, don't queue!
401
+ if (slackEvent.text.toLowerCase().trim() === "stop") {
402
+ if (this.handler.isRunning(e.channel)) {
403
+ this.handler.handleStop(e.channel, this); // Don't await, don't queue
404
+ } else {
405
+ this.postMessage(e.channel, "_Nothing running_");
406
+ }
407
+ ack();
408
+ return;
409
+ }
410
+
411
+ if (this.handler.isRunning(e.channel)) {
412
+ this.postMessage(e.channel, "_Already working. Say `stop` to cancel._");
413
+ } else {
414
+ this.getQueue(e.channel).enqueue(() => this.handler.handleEvent(slackEvent, this));
415
+ }
416
+ }
417
+
418
+ ack();
419
+ });
420
+ }
421
+
422
+ /**
423
+ * Log a user message to log.jsonl (SYNC)
424
+ * Downloads attachments in background via store
425
+ */
426
+ private logUserMessage(event: SlackEvent): Attachment[] {
427
+ const user = this.users.get(event.user);
428
+ // Process attachments - queues downloads in background
429
+ const attachments = event.files ? this.store.processAttachments(event.channel, event.files, event.ts) : [];
430
+ this.logToFile(event.channel, {
431
+ date: new Date(parseFloat(event.ts) * 1000).toISOString(),
432
+ ts: event.ts,
433
+ user: event.user,
434
+ userName: user?.userName,
435
+ displayName: user?.displayName,
436
+ text: event.text,
437
+ attachments,
438
+ isBot: false,
439
+ });
440
+ return attachments;
441
+ }
442
+
443
+ // ==========================================================================
444
+ // Private - Backfill
445
+ // ==========================================================================
446
+
447
+ private getExistingTimestamps(channelId: string): Set<string> {
448
+ const logPath = join(this.workingDir, channelId, "log.jsonl");
449
+ const timestamps = new Set<string>();
450
+ if (!existsSync(logPath)) return timestamps;
451
+
452
+ const content = readFileSync(logPath, "utf-8");
453
+ const lines = content.trim().split("\n").filter(Boolean);
454
+ for (const line of lines) {
455
+ try {
456
+ const entry = JSON.parse(line);
457
+ if (entry.ts) timestamps.add(entry.ts);
458
+ } catch (err) {
459
+ logger.debug("Failed to parse log entry JSON", { error: String(err) });
460
+ }
461
+ }
462
+ return timestamps;
463
+ }
464
+
465
+ private async backfillChannel(channelId: string): Promise<number> {
466
+ const existingTs = this.getExistingTimestamps(channelId);
467
+
468
+ // Find the biggest ts in log.jsonl
469
+ let latestTs: string | undefined;
470
+ for (const ts of existingTs) {
471
+ if (!latestTs || parseFloat(ts) > parseFloat(latestTs)) latestTs = ts;
472
+ }
473
+
474
+ type Message = {
475
+ user?: string;
476
+ bot_id?: string;
477
+ text?: string;
478
+ ts?: string;
479
+ subtype?: string;
480
+ files?: Array<{ name: string }>;
481
+ };
482
+ const allMessages: Message[] = [];
483
+
484
+ let cursor: string | undefined;
485
+ let pageCount = 0;
486
+ const maxPages = 3;
487
+
488
+ do {
489
+ const result = await this.webClient.conversations.history({
490
+ channel: channelId,
491
+ oldest: latestTs, // Only fetch messages newer than what we have
492
+ inclusive: false,
493
+ limit: 1000,
494
+ cursor,
495
+ });
496
+ if (result.messages) {
497
+ allMessages.push(...(result.messages as Message[]));
498
+ }
499
+ cursor = result.response_metadata?.next_cursor;
500
+ pageCount++;
501
+ } while (cursor && pageCount < maxPages);
502
+
503
+ // Filter: include mom's messages, exclude other bots, skip already logged
504
+ const relevantMessages = allMessages.filter((msg) => {
505
+ if (!msg.ts || existingTs.has(msg.ts)) return false; // Skip duplicates
506
+ if (msg.user === this.botUserId) return true;
507
+ if (msg.bot_id) return false;
508
+ if (msg.subtype !== undefined && msg.subtype !== "file_share") return false;
509
+ if (!msg.user) return false;
510
+ if (!msg.text && (!msg.files || msg.files.length === 0)) return false;
511
+ return true;
512
+ });
513
+
514
+ // Reverse to chronological order
515
+ relevantMessages.reverse();
516
+
517
+ // Log each message to log.jsonl
518
+ for (const msg of relevantMessages) {
519
+ const isMomMessage = msg.user === this.botUserId;
520
+ const user = this.users.get(msg.user!);
521
+ // Strip @mentions from text (same as live messages)
522
+ const text = (msg.text || "").replace(/<@[A-Z0-9]+>/gi, "").trim();
523
+ // Process attachments - queues downloads in background
524
+ const attachments = msg.files ? this.store.processAttachments(channelId, msg.files, msg.ts!) : [];
525
+
526
+ this.logToFile(channelId, {
527
+ date: new Date(parseFloat(msg.ts!) * 1000).toISOString(),
528
+ ts: msg.ts!,
529
+ user: isMomMessage ? "bot" : msg.user!,
530
+ userName: isMomMessage ? undefined : user?.userName,
531
+ displayName: isMomMessage ? undefined : user?.displayName,
532
+ text,
533
+ attachments,
534
+ isBot: isMomMessage,
535
+ });
536
+ }
537
+
538
+ return relevantMessages.length;
539
+ }
540
+
541
+ private async backfillAllChannels(): Promise<void> {
542
+ const startTime = Date.now();
543
+
544
+ // Only backfill channels that already have a log.jsonl (mom has interacted with them before)
545
+ const channelsToBackfill: Array<[string, SlackChannel]> = [];
546
+ for (const [channelId, channel] of this.channels) {
547
+ const logPath = join(this.workingDir, channelId, "log.jsonl");
548
+ if (existsSync(logPath)) {
549
+ channelsToBackfill.push([channelId, channel]);
550
+ }
551
+ }
552
+
553
+ log.logBackfillStart(channelsToBackfill.length);
554
+
555
+ let totalMessages = 0;
556
+ for (const [channelId, channel] of channelsToBackfill) {
557
+ try {
558
+ const count = await this.backfillChannel(channelId);
559
+ if (count > 0) log.logBackfillChannel(channel.name, count);
560
+ totalMessages += count;
561
+ } catch (error) {
562
+ log.logWarning(`Failed to backfill #${channel.name}`, String(error));
563
+ }
564
+ }
565
+
566
+ const durationMs = Date.now() - startTime;
567
+ log.logBackfillComplete(totalMessages, durationMs);
568
+ }
569
+
570
+ // ==========================================================================
571
+ // Private - Fetch Users/Channels
572
+ // ==========================================================================
573
+
574
+ private async fetchUsers(): Promise<void> {
575
+ let cursor: string | undefined;
576
+ do {
577
+ const result = await this.webClient.users.list({ limit: 200, cursor });
578
+ const members = result.members as
579
+ | Array<{ id?: string; name?: string; real_name?: string; deleted?: boolean }>
580
+ | undefined;
581
+ if (members) {
582
+ for (const u of members) {
583
+ if (u.id && u.name && !u.deleted) {
584
+ this.users.set(u.id, { id: u.id, userName: u.name, displayName: u.real_name || u.name });
585
+ }
586
+ }
587
+ }
588
+ cursor = result.response_metadata?.next_cursor;
589
+ } while (cursor);
590
+ }
591
+
592
+ private async fetchChannels(): Promise<void> {
593
+ // Fetch public/private channels
594
+ let cursor: string | undefined;
595
+ do {
596
+ const result = await this.webClient.conversations.list({
597
+ types: "public_channel,private_channel",
598
+ exclude_archived: true,
599
+ limit: 200,
600
+ cursor,
601
+ });
602
+ const channels = result.channels as Array<{ id?: string; name?: string; is_member?: boolean }> | undefined;
603
+ if (channels) {
604
+ for (const c of channels) {
605
+ if (c.id && c.name && c.is_member) {
606
+ this.channels.set(c.id, { id: c.id, name: c.name });
607
+ }
608
+ }
609
+ }
610
+ cursor = result.response_metadata?.next_cursor;
611
+ } while (cursor);
612
+
613
+ // Also fetch DM channels (IMs)
614
+ cursor = undefined;
615
+ do {
616
+ const result = await this.webClient.conversations.list({
617
+ types: "im",
618
+ limit: 200,
619
+ cursor,
620
+ });
621
+ const ims = result.channels as Array<{ id?: string; user?: string }> | undefined;
622
+ if (ims) {
623
+ for (const im of ims) {
624
+ if (im.id) {
625
+ // Use user's name as channel name for DMs
626
+ const user = im.user ? this.users.get(im.user) : undefined;
627
+ const name = user ? `DM:${user.userName}` : `DM:${im.id}`;
628
+ this.channels.set(im.id, { id: im.id, name });
629
+ }
630
+ }
631
+ }
632
+ cursor = result.response_metadata?.next_cursor;
633
+ } while (cursor);
634
+ }
635
+ }