@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/CHANGELOG.md +338 -0
- package/README.md +517 -0
- package/dev.sh +30 -0
- package/docker.sh +95 -0
- package/docs/artifacts-server.md +475 -0
- package/docs/events.md +307 -0
- package/docs/new.md +977 -0
- package/docs/sandbox.md +153 -0
- package/docs/slack-bot-minimal-guide.md +399 -0
- package/docs/v86.md +319 -0
- package/package.json +45 -0
- package/scripts/migrate-timestamps.ts +121 -0
- package/src/agent.ts +887 -0
- package/src/context.ts +666 -0
- package/src/download.ts +117 -0
- package/src/events.ts +385 -0
- package/src/log.ts +271 -0
- package/src/main.ts +334 -0
- package/src/sandbox.ts +238 -0
- package/src/slack.ts +635 -0
- package/src/store.ts +253 -0
- package/src/tools/attach.ts +47 -0
- package/src/tools/bash.ts +99 -0
- package/src/tools/edit.ts +165 -0
- package/src/tools/index.ts +19 -0
- package/src/tools/read.ts +165 -0
- package/src/tools/truncate.ts +236 -0
- package/src/tools/write.ts +45 -0
- package/tsconfig.build.json +9 -0
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
|
+
}
|