@mariozechner/pi-mom 0.18.2 → 0.18.4

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/dist/slack.js CHANGED
@@ -1,412 +1,289 @@
1
1
  import { SocketModeClient } from "@slack/socket-mode";
2
2
  import { WebClient } from "@slack/web-api";
3
- import { readFileSync } from "fs";
4
- import { basename } from "path";
3
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "fs";
4
+ import { basename, join } from "path";
5
5
  import * as log from "./log.js";
6
- import { ChannelStore } from "./store.js";
7
- export class MomBot {
6
+ class ChannelQueue {
7
+ queue = [];
8
+ processing = false;
9
+ enqueue(work) {
10
+ this.queue.push(work);
11
+ this.processNext();
12
+ }
13
+ async processNext() {
14
+ if (this.processing || this.queue.length === 0)
15
+ return;
16
+ this.processing = true;
17
+ const work = this.queue.shift();
18
+ try {
19
+ await work();
20
+ }
21
+ catch (err) {
22
+ log.logWarning("Queue error", err instanceof Error ? err.message : String(err));
23
+ }
24
+ this.processing = false;
25
+ this.processNext();
26
+ }
27
+ }
28
+ // ============================================================================
29
+ // SlackBot
30
+ // ============================================================================
31
+ export class SlackBot {
8
32
  socketClient;
9
33
  webClient;
10
34
  handler;
11
- botUserId = null;
35
+ workingDir;
12
36
  store;
13
- userCache = new Map();
14
- channelCache = new Map(); // id -> name
37
+ botUserId = null;
38
+ startupTs = null; // Messages older than this are just logged, not processed
39
+ users = new Map();
40
+ channels = new Map();
41
+ queues = new Map();
15
42
  constructor(handler, config) {
16
43
  this.handler = handler;
44
+ this.workingDir = config.workingDir;
45
+ this.store = config.store;
17
46
  this.socketClient = new SocketModeClient({ appToken: config.appToken });
18
47
  this.webClient = new WebClient(config.botToken);
19
- this.store = new ChannelStore({
20
- workingDir: config.workingDir,
21
- botToken: config.botToken,
22
- });
48
+ }
49
+ // ==========================================================================
50
+ // Public API
51
+ // ==========================================================================
52
+ async start() {
53
+ const auth = await this.webClient.auth.test();
54
+ this.botUserId = auth.user_id;
55
+ await Promise.all([this.fetchUsers(), this.fetchChannels()]);
56
+ log.logInfo(`Loaded ${this.channels.size} channels, ${this.users.size} users`);
57
+ await this.backfillAllChannels();
23
58
  this.setupEventHandlers();
59
+ await this.socketClient.start();
60
+ // Record startup time - messages older than this are just logged, not processed
61
+ this.startupTs = (Date.now() / 1000).toFixed(6);
62
+ log.logConnected();
24
63
  }
25
- /**
26
- * Fetch all channels the bot is a member of
27
- */
28
- async fetchChannels() {
29
- try {
30
- let cursor;
31
- do {
32
- const result = await this.webClient.conversations.list({
33
- types: "public_channel,private_channel",
34
- exclude_archived: true,
35
- limit: 200,
36
- cursor,
37
- });
38
- const channels = result.channels;
39
- if (channels) {
40
- for (const channel of channels) {
41
- if (channel.id && channel.name && channel.is_member) {
42
- this.channelCache.set(channel.id, channel.name);
43
- }
44
- }
45
- }
46
- cursor = result.response_metadata?.next_cursor;
47
- } while (cursor);
48
- }
49
- catch (error) {
50
- log.logWarning("Failed to fetch channels", String(error));
51
- }
64
+ getUser(userId) {
65
+ return this.users.get(userId);
52
66
  }
53
- /**
54
- * Fetch all workspace users
55
- */
56
- async fetchUsers() {
57
- try {
58
- let cursor;
59
- do {
60
- const result = await this.webClient.users.list({
61
- limit: 200,
62
- cursor,
63
- });
64
- const members = result.members;
65
- if (members) {
66
- for (const user of members) {
67
- if (user.id && user.name && !user.deleted) {
68
- this.userCache.set(user.id, {
69
- userName: user.name,
70
- displayName: user.real_name || user.name,
71
- });
72
- }
73
- }
74
- }
75
- cursor = result.response_metadata?.next_cursor;
76
- } while (cursor);
77
- }
78
- catch (error) {
79
- log.logWarning("Failed to fetch users", String(error));
80
- }
67
+ getChannel(channelId) {
68
+ return this.channels.get(channelId);
81
69
  }
82
- /**
83
- * Get all known channels (id -> name)
84
- */
85
- getChannels() {
86
- return Array.from(this.channelCache.entries()).map(([id, name]) => ({ id, name }));
70
+ getAllUsers() {
71
+ return Array.from(this.users.values());
72
+ }
73
+ getAllChannels() {
74
+ return Array.from(this.channels.values());
75
+ }
76
+ async postMessage(channel, text) {
77
+ const result = await this.webClient.chat.postMessage({ channel, text });
78
+ return result.ts;
79
+ }
80
+ async updateMessage(channel, ts, text) {
81
+ await this.webClient.chat.update({ channel, ts, text });
82
+ }
83
+ async postInThread(channel, threadTs, text) {
84
+ await this.webClient.chat.postMessage({ channel, thread_ts: threadTs, text });
85
+ }
86
+ async uploadFile(channel, filePath, title) {
87
+ const fileName = title || basename(filePath);
88
+ const fileContent = readFileSync(filePath);
89
+ await this.webClient.files.uploadV2({
90
+ channel_id: channel,
91
+ file: fileContent,
92
+ filename: fileName,
93
+ title: fileName,
94
+ });
87
95
  }
88
96
  /**
89
- * Get all known users
97
+ * Log a message to log.jsonl (SYNC)
98
+ * This is the ONLY place messages are written to log.jsonl
90
99
  */
91
- getUsers() {
92
- return Array.from(this.userCache.entries()).map(([id, { userName, displayName }]) => ({
93
- id,
94
- userName,
95
- displayName,
96
- }));
100
+ logToFile(channel, entry) {
101
+ const dir = join(this.workingDir, channel);
102
+ if (!existsSync(dir))
103
+ mkdirSync(dir, { recursive: true });
104
+ appendFileSync(join(dir, "log.jsonl"), JSON.stringify(entry) + "\n");
97
105
  }
98
106
  /**
99
- * Obfuscate usernames and user IDs in text to prevent pinging people
100
- * e.g., "nate" -> "n_a_t_e", "@mario" -> "@m_a_r_i_o", "<@U123>" -> "<@U_1_2_3>"
107
+ * Log a bot response to log.jsonl
101
108
  */
102
- obfuscateUsernames(text) {
103
- let result = text;
104
- // Obfuscate user IDs like <@U16LAL8LS>
105
- result = result.replace(/<@([A-Z0-9]+)>/gi, (_match, id) => {
106
- return `<@${id.split("").join("_")}>`;
109
+ logBotResponse(channel, text, ts) {
110
+ this.logToFile(channel, {
111
+ date: new Date().toISOString(),
112
+ ts,
113
+ user: "bot",
114
+ text,
115
+ attachments: [],
116
+ isBot: true,
107
117
  });
108
- // Obfuscate usernames
109
- for (const { userName } of this.userCache.values()) {
110
- // Escape special regex characters in username
111
- const escaped = userName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
112
- // Match @username, <@username>, or bare username (case insensitive, word boundary)
113
- const pattern = new RegExp(`(<@|@)?(\\b${escaped}\\b)`, "gi");
114
- result = result.replace(pattern, (_match, prefix, name) => {
115
- const obfuscated = name.split("").join("_");
116
- return (prefix || "") + obfuscated;
117
- });
118
- }
119
- return result;
120
118
  }
121
- async getUserInfo(userId) {
122
- if (this.userCache.has(userId)) {
123
- return this.userCache.get(userId);
124
- }
125
- try {
126
- const result = await this.webClient.users.info({ user: userId });
127
- const user = result.user;
128
- const info = {
129
- userName: user?.name || userId,
130
- displayName: user?.real_name || user?.name || userId,
131
- };
132
- this.userCache.set(userId, info);
133
- return info;
134
- }
135
- catch {
136
- return { userName: userId, displayName: userId };
119
+ // ==========================================================================
120
+ // Private - Event Handlers
121
+ // ==========================================================================
122
+ getQueue(channelId) {
123
+ let queue = this.queues.get(channelId);
124
+ if (!queue) {
125
+ queue = new ChannelQueue();
126
+ this.queues.set(channelId, queue);
137
127
  }
128
+ return queue;
138
129
  }
139
130
  setupEventHandlers() {
140
- // Handle @mentions in channels
141
- this.socketClient.on("app_mention", async ({ event, ack }) => {
142
- await ack();
143
- const slackEvent = event;
144
- // Log the mention message (message event may not fire for all channel types)
145
- await this.logMessage({
146
- text: slackEvent.text,
147
- channel: slackEvent.channel,
148
- user: slackEvent.user,
149
- ts: slackEvent.ts,
150
- files: slackEvent.files,
151
- });
152
- const ctx = await this.createContext(slackEvent);
153
- await this.handler.onChannelMention(ctx);
131
+ // Channel @mentions
132
+ this.socketClient.on("app_mention", ({ event, ack }) => {
133
+ const e = event;
134
+ // Skip DMs (handled by message event)
135
+ if (e.channel.startsWith("D")) {
136
+ ack();
137
+ return;
138
+ }
139
+ const slackEvent = {
140
+ type: "mention",
141
+ channel: e.channel,
142
+ ts: e.ts,
143
+ user: e.user,
144
+ text: e.text.replace(/<@[A-Z0-9]+>/gi, "").trim(),
145
+ files: e.files,
146
+ };
147
+ // SYNC: Log to log.jsonl (ALWAYS, even for old messages)
148
+ // Also downloads attachments in background and stores local paths
149
+ slackEvent.attachments = this.logUserMessage(slackEvent);
150
+ // Only trigger processing for messages AFTER startup (not replayed old messages)
151
+ if (this.startupTs && e.ts < this.startupTs) {
152
+ log.logInfo(`[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`);
153
+ ack();
154
+ return;
155
+ }
156
+ // Check for stop command - execute immediately, don't queue!
157
+ if (slackEvent.text.toLowerCase().trim() === "stop") {
158
+ if (this.handler.isRunning(e.channel)) {
159
+ this.handler.handleStop(e.channel, this); // Don't await, don't queue
160
+ }
161
+ else {
162
+ this.postMessage(e.channel, "_Nothing running_");
163
+ }
164
+ ack();
165
+ return;
166
+ }
167
+ // SYNC: Check if busy
168
+ if (this.handler.isRunning(e.channel)) {
169
+ this.postMessage(e.channel, "_Already working. Say `@mom stop` to cancel._");
170
+ }
171
+ else {
172
+ this.getQueue(e.channel).enqueue(() => this.handler.handleEvent(slackEvent, this));
173
+ }
174
+ ack();
154
175
  });
155
- // Handle all messages (for logging) and DMs (for triggering handler)
156
- this.socketClient.on("message", async ({ event, ack }) => {
157
- await ack();
158
- const slackEvent = event;
159
- // Ignore bot messages
160
- if (slackEvent.bot_id)
176
+ // All messages (for logging) + DMs (for triggering)
177
+ this.socketClient.on("message", ({ event, ack }) => {
178
+ const e = event;
179
+ // Skip bot messages, edits, etc.
180
+ if (e.bot_id || !e.user || e.user === this.botUserId) {
181
+ ack();
161
182
  return;
162
- // Ignore message edits, etc. (but allow file_share)
163
- if (slackEvent.subtype !== undefined && slackEvent.subtype !== "file_share")
183
+ }
184
+ if (e.subtype !== undefined && e.subtype !== "file_share") {
185
+ ack();
164
186
  return;
165
- // Ignore if no user
166
- if (!slackEvent.user)
187
+ }
188
+ if (!e.text && (!e.files || e.files.length === 0)) {
189
+ ack();
167
190
  return;
168
- // Ignore messages from the bot itself
169
- if (slackEvent.user === this.botUserId)
191
+ }
192
+ const isDM = e.channel_type === "im";
193
+ const isBotMention = e.text?.includes(`<@${this.botUserId}>`);
194
+ // Skip channel @mentions - already handled by app_mention event
195
+ if (!isDM && isBotMention) {
196
+ ack();
170
197
  return;
171
- // Ignore if no text AND no files
172
- if (!slackEvent.text && (!slackEvent.files || slackEvent.files.length === 0))
198
+ }
199
+ const slackEvent = {
200
+ type: isDM ? "dm" : "mention",
201
+ channel: e.channel,
202
+ ts: e.ts,
203
+ user: e.user,
204
+ text: (e.text || "").replace(/<@[A-Z0-9]+>/gi, "").trim(),
205
+ files: e.files,
206
+ };
207
+ // SYNC: Log to log.jsonl (ALL messages - channel chatter and DMs)
208
+ // Also downloads attachments in background and stores local paths
209
+ slackEvent.attachments = this.logUserMessage(slackEvent);
210
+ // Only trigger processing for messages AFTER startup (not replayed old messages)
211
+ if (this.startupTs && e.ts < this.startupTs) {
212
+ log.logInfo(`[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`);
213
+ ack();
173
214
  return;
174
- // Log ALL messages (channel and DM)
175
- await this.logMessage({
176
- text: slackEvent.text || "",
177
- channel: slackEvent.channel,
178
- user: slackEvent.user,
179
- ts: slackEvent.ts,
180
- files: slackEvent.files,
181
- });
182
- // Only trigger handler for DMs (channel mentions are handled by app_mention event)
183
- if (slackEvent.channel_type === "im") {
184
- const ctx = await this.createContext({
185
- text: slackEvent.text || "",
186
- channel: slackEvent.channel,
187
- user: slackEvent.user,
188
- ts: slackEvent.ts,
189
- files: slackEvent.files,
190
- });
191
- await this.handler.onDirectMessage(ctx);
192
215
  }
216
+ // Only trigger handler for DMs
217
+ if (isDM) {
218
+ // Check for stop command - execute immediately, don't queue!
219
+ if (slackEvent.text.toLowerCase().trim() === "stop") {
220
+ if (this.handler.isRunning(e.channel)) {
221
+ this.handler.handleStop(e.channel, this); // Don't await, don't queue
222
+ }
223
+ else {
224
+ this.postMessage(e.channel, "_Nothing running_");
225
+ }
226
+ ack();
227
+ return;
228
+ }
229
+ if (this.handler.isRunning(e.channel)) {
230
+ this.postMessage(e.channel, "_Already working. Say `stop` to cancel._");
231
+ }
232
+ else {
233
+ this.getQueue(e.channel).enqueue(() => this.handler.handleEvent(slackEvent, this));
234
+ }
235
+ }
236
+ ack();
193
237
  });
194
238
  }
195
- async logMessage(event) {
239
+ /**
240
+ * Log a user message to log.jsonl (SYNC)
241
+ * Downloads attachments in background via store
242
+ */
243
+ logUserMessage(event) {
244
+ const user = this.users.get(event.user);
245
+ // Process attachments - queues downloads in background
196
246
  const attachments = event.files ? this.store.processAttachments(event.channel, event.files, event.ts) : [];
197
- const { userName, displayName } = await this.getUserInfo(event.user);
198
- await this.store.logMessage(event.channel, {
247
+ this.logToFile(event.channel, {
199
248
  date: new Date(parseFloat(event.ts) * 1000).toISOString(),
200
249
  ts: event.ts,
201
250
  user: event.user,
202
- userName,
203
- displayName,
251
+ userName: user?.userName,
252
+ displayName: user?.displayName,
204
253
  text: event.text,
205
254
  attachments,
206
255
  isBot: false,
207
256
  });
257
+ return attachments;
208
258
  }
209
- async createContext(event) {
210
- const rawText = event.text;
211
- const text = rawText.replace(/<@[A-Z0-9]+>/gi, "").trim();
212
- // Get user info for logging
213
- const { userName } = await this.getUserInfo(event.user);
214
- // Get channel name for logging (best effort)
215
- let channelName;
216
- try {
217
- if (event.channel.startsWith("C")) {
218
- const result = await this.webClient.conversations.info({ channel: event.channel });
219
- channelName = result.channel?.name ? `#${result.channel.name}` : undefined;
259
+ // ==========================================================================
260
+ // Private - Backfill
261
+ // ==========================================================================
262
+ getExistingTimestamps(channelId) {
263
+ const logPath = join(this.workingDir, channelId, "log.jsonl");
264
+ const timestamps = new Set();
265
+ if (!existsSync(logPath))
266
+ return timestamps;
267
+ const content = readFileSync(logPath, "utf-8");
268
+ const lines = content.trim().split("\n").filter(Boolean);
269
+ for (const line of lines) {
270
+ try {
271
+ const entry = JSON.parse(line);
272
+ if (entry.ts)
273
+ timestamps.add(entry.ts);
220
274
  }
275
+ catch { }
221
276
  }
222
- catch {
223
- // Ignore errors - we'll just use the channel ID
224
- }
225
- // Process attachments (for context, already logged by message handler)
226
- const attachments = event.files ? this.store.processAttachments(event.channel, event.files, event.ts) : [];
227
- // Track the single message for this run
228
- let messageTs = null;
229
- let accumulatedText = "";
230
- let isThinking = true; // Track if we're still in "thinking" state
231
- let isWorking = true; // Track if still processing
232
- const workingIndicator = " ...";
233
- let updatePromise = Promise.resolve();
234
- return {
235
- message: {
236
- text,
237
- rawText,
238
- user: event.user,
239
- userName,
240
- channel: event.channel,
241
- ts: event.ts,
242
- attachments,
243
- },
244
- channelName,
245
- store: this.store,
246
- channels: this.getChannels(),
247
- users: this.getUsers(),
248
- respond: async (responseText, shouldLog = true) => {
249
- // Queue updates to avoid race conditions
250
- updatePromise = updatePromise.then(async () => {
251
- try {
252
- if (isThinking) {
253
- // First real response replaces "Thinking..."
254
- accumulatedText = responseText;
255
- isThinking = false;
256
- }
257
- else {
258
- // Subsequent responses get appended
259
- accumulatedText += "\n" + responseText;
260
- }
261
- // Truncate accumulated text if too long (Slack limit is 40K, we use 35K for safety)
262
- const MAX_MAIN_LENGTH = 35000;
263
- const truncationNote = "\n\n_(message truncated, ask me to elaborate on specific parts)_";
264
- if (accumulatedText.length > MAX_MAIN_LENGTH) {
265
- accumulatedText =
266
- accumulatedText.substring(0, MAX_MAIN_LENGTH - truncationNote.length) + truncationNote;
267
- }
268
- // Add working indicator if still working
269
- const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;
270
- if (messageTs) {
271
- // Update existing message
272
- await this.webClient.chat.update({
273
- channel: event.channel,
274
- ts: messageTs,
275
- text: displayText,
276
- });
277
- }
278
- else {
279
- // Post initial message
280
- const result = await this.webClient.chat.postMessage({
281
- channel: event.channel,
282
- text: displayText,
283
- });
284
- messageTs = result.ts;
285
- }
286
- // Log the response if requested
287
- if (shouldLog) {
288
- await this.store.logBotResponse(event.channel, responseText, messageTs);
289
- }
290
- }
291
- catch (err) {
292
- log.logWarning("Slack respond error", err instanceof Error ? err.message : String(err));
293
- }
294
- });
295
- await updatePromise;
296
- },
297
- respondInThread: async (threadText) => {
298
- // Queue thread posts to maintain order
299
- updatePromise = updatePromise.then(async () => {
300
- try {
301
- if (!messageTs) {
302
- // No main message yet, just skip
303
- return;
304
- }
305
- // Obfuscate usernames to avoid pinging people in thread details
306
- let obfuscatedText = this.obfuscateUsernames(threadText);
307
- // Truncate thread messages if too long (20K limit for safety)
308
- const MAX_THREAD_LENGTH = 20000;
309
- if (obfuscatedText.length > MAX_THREAD_LENGTH) {
310
- obfuscatedText = obfuscatedText.substring(0, MAX_THREAD_LENGTH - 50) + "\n\n_(truncated)_";
311
- }
312
- // Post in thread under the main message
313
- await this.webClient.chat.postMessage({
314
- channel: event.channel,
315
- thread_ts: messageTs,
316
- text: obfuscatedText,
317
- });
318
- }
319
- catch (err) {
320
- log.logWarning("Slack respondInThread error", err instanceof Error ? err.message : String(err));
321
- }
322
- });
323
- await updatePromise;
324
- },
325
- setTyping: async (isTyping) => {
326
- if (isTyping && !messageTs) {
327
- // Post initial "thinking" message (... auto-appended by working indicator)
328
- accumulatedText = "_Thinking_";
329
- const result = await this.webClient.chat.postMessage({
330
- channel: event.channel,
331
- text: accumulatedText,
332
- });
333
- messageTs = result.ts;
334
- }
335
- // We don't delete/clear anymore - message persists and gets updated
336
- },
337
- uploadFile: async (filePath, title) => {
338
- const fileName = title || basename(filePath);
339
- const fileContent = readFileSync(filePath);
340
- await this.webClient.files.uploadV2({
341
- channel_id: event.channel,
342
- file: fileContent,
343
- filename: fileName,
344
- title: fileName,
345
- });
346
- },
347
- replaceMessage: async (text) => {
348
- updatePromise = updatePromise.then(async () => {
349
- try {
350
- // Replace the accumulated text entirely, with truncation
351
- const MAX_MAIN_LENGTH = 35000;
352
- const truncationNote = "\n\n_(message truncated, ask me to elaborate on specific parts)_";
353
- if (text.length > MAX_MAIN_LENGTH) {
354
- accumulatedText = text.substring(0, MAX_MAIN_LENGTH - truncationNote.length) + truncationNote;
355
- }
356
- else {
357
- accumulatedText = text;
358
- }
359
- const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;
360
- if (messageTs) {
361
- await this.webClient.chat.update({
362
- channel: event.channel,
363
- ts: messageTs,
364
- text: displayText,
365
- });
366
- }
367
- else {
368
- // Post initial message
369
- const result = await this.webClient.chat.postMessage({
370
- channel: event.channel,
371
- text: displayText,
372
- });
373
- messageTs = result.ts;
374
- }
375
- }
376
- catch (err) {
377
- log.logWarning("Slack replaceMessage error", err instanceof Error ? err.message : String(err));
378
- }
379
- });
380
- await updatePromise;
381
- },
382
- setWorking: async (working) => {
383
- updatePromise = updatePromise.then(async () => {
384
- try {
385
- isWorking = working;
386
- // If we have a message, update it to add/remove indicator
387
- if (messageTs) {
388
- const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;
389
- await this.webClient.chat.update({
390
- channel: event.channel,
391
- ts: messageTs,
392
- text: displayText,
393
- });
394
- }
395
- }
396
- catch (err) {
397
- log.logWarning("Slack setWorking error", err instanceof Error ? err.message : String(err));
398
- }
399
- });
400
- await updatePromise;
401
- },
402
- };
277
+ return timestamps;
403
278
  }
404
- /**
405
- * Backfill missed messages for a single channel
406
- * Returns the number of messages backfilled
407
- */
408
279
  async backfillChannel(channelId) {
409
- const lastTs = this.store.getLastTimestamp(channelId);
280
+ const existingTs = this.getExistingTimestamps(channelId);
281
+ // Find the biggest ts in log.jsonl
282
+ let latestTs;
283
+ for (const ts of existingTs) {
284
+ if (!latestTs || parseFloat(ts) > parseFloat(latestTs))
285
+ latestTs = ts;
286
+ }
410
287
  const allMessages = [];
411
288
  let cursor;
412
289
  let pageCount = 0;
@@ -414,7 +291,7 @@ export class MomBot {
414
291
  do {
415
292
  const result = await this.webClient.conversations.history({
416
293
  channel: channelId,
417
- oldest: lastTs ?? undefined,
294
+ oldest: latestTs, // Only fetch messages newer than what we have
418
295
  inclusive: false,
419
296
  limit: 1000,
420
297
  cursor,
@@ -425,15 +302,14 @@ export class MomBot {
425
302
  cursor = result.response_metadata?.next_cursor;
426
303
  pageCount++;
427
304
  } while (cursor && pageCount < maxPages);
428
- // Filter messages: include mom's messages, exclude other bots
305
+ // Filter: include mom's messages, exclude other bots, skip already logged
429
306
  const relevantMessages = allMessages.filter((msg) => {
430
- // Always include mom's own messages
307
+ if (!msg.ts || existingTs.has(msg.ts))
308
+ return false; // Skip duplicates
431
309
  if (msg.user === this.botUserId)
432
310
  return true;
433
- // Exclude other bot messages
434
311
  if (msg.bot_id)
435
312
  return false;
436
- // Standard filters for user messages
437
313
  if (msg.subtype !== undefined && msg.subtype !== "file_share")
438
314
  return false;
439
315
  if (!msg.user)
@@ -442,76 +318,114 @@ export class MomBot {
442
318
  return false;
443
319
  return true;
444
320
  });
445
- // Reverse to chronological order (API returns newest first)
321
+ // Reverse to chronological order
446
322
  relevantMessages.reverse();
447
- // Log each message
323
+ // Log each message to log.jsonl
448
324
  for (const msg of relevantMessages) {
449
325
  const isMomMessage = msg.user === this.botUserId;
326
+ const user = this.users.get(msg.user);
327
+ // Strip @mentions from text (same as live messages)
328
+ const text = (msg.text || "").replace(/<@[A-Z0-9]+>/gi, "").trim();
329
+ // Process attachments - queues downloads in background
450
330
  const attachments = msg.files ? this.store.processAttachments(channelId, msg.files, msg.ts) : [];
451
- if (isMomMessage) {
452
- // Log mom's message as bot response
453
- await this.store.logMessage(channelId, {
454
- date: new Date(parseFloat(msg.ts) * 1000).toISOString(),
455
- ts: msg.ts,
456
- user: "bot",
457
- text: msg.text || "",
458
- attachments,
459
- isBot: true,
460
- });
461
- }
462
- else {
463
- // Log user message
464
- const { userName, displayName } = await this.getUserInfo(msg.user);
465
- await this.store.logMessage(channelId, {
466
- date: new Date(parseFloat(msg.ts) * 1000).toISOString(),
467
- ts: msg.ts,
468
- user: msg.user,
469
- userName,
470
- displayName,
471
- text: msg.text || "",
472
- attachments,
473
- isBot: false,
474
- });
475
- }
331
+ this.logToFile(channelId, {
332
+ date: new Date(parseFloat(msg.ts) * 1000).toISOString(),
333
+ ts: msg.ts,
334
+ user: isMomMessage ? "bot" : msg.user,
335
+ userName: isMomMessage ? undefined : user?.userName,
336
+ displayName: isMomMessage ? undefined : user?.displayName,
337
+ text,
338
+ attachments,
339
+ isBot: isMomMessage,
340
+ });
476
341
  }
477
342
  return relevantMessages.length;
478
343
  }
479
- /**
480
- * Backfill missed messages for all channels
481
- */
482
344
  async backfillAllChannels() {
483
345
  const startTime = Date.now();
484
- log.logBackfillStart(this.channelCache.size);
346
+ // Only backfill channels that already have a log.jsonl (mom has interacted with them before)
347
+ const channelsToBackfill = [];
348
+ for (const [channelId, channel] of this.channels) {
349
+ const logPath = join(this.workingDir, channelId, "log.jsonl");
350
+ if (existsSync(logPath)) {
351
+ channelsToBackfill.push([channelId, channel]);
352
+ }
353
+ }
354
+ log.logBackfillStart(channelsToBackfill.length);
485
355
  let totalMessages = 0;
486
- for (const [channelId, channelName] of this.channelCache) {
356
+ for (const [channelId, channel] of channelsToBackfill) {
487
357
  try {
488
358
  const count = await this.backfillChannel(channelId);
489
- if (count > 0) {
490
- log.logBackfillChannel(channelName, count);
491
- }
359
+ if (count > 0)
360
+ log.logBackfillChannel(channel.name, count);
492
361
  totalMessages += count;
493
362
  }
494
363
  catch (error) {
495
- log.logWarning(`Failed to backfill channel #${channelName}`, String(error));
364
+ log.logWarning(`Failed to backfill #${channel.name}`, String(error));
496
365
  }
497
366
  }
498
367
  const durationMs = Date.now() - startTime;
499
368
  log.logBackfillComplete(totalMessages, durationMs);
500
369
  }
501
- async start() {
502
- const auth = await this.webClient.auth.test();
503
- this.botUserId = auth.user_id;
504
- // Fetch channels and users in parallel
505
- await Promise.all([this.fetchChannels(), this.fetchUsers()]);
506
- log.logInfo(`Loaded ${this.channelCache.size} channels, ${this.userCache.size} users`);
507
- // Backfill any messages missed while offline
508
- await this.backfillAllChannels();
509
- await this.socketClient.start();
510
- log.logConnected();
370
+ // ==========================================================================
371
+ // Private - Fetch Users/Channels
372
+ // ==========================================================================
373
+ async fetchUsers() {
374
+ let cursor;
375
+ do {
376
+ const result = await this.webClient.users.list({ limit: 200, cursor });
377
+ const members = result.members;
378
+ if (members) {
379
+ for (const u of members) {
380
+ if (u.id && u.name && !u.deleted) {
381
+ this.users.set(u.id, { id: u.id, userName: u.name, displayName: u.real_name || u.name });
382
+ }
383
+ }
384
+ }
385
+ cursor = result.response_metadata?.next_cursor;
386
+ } while (cursor);
511
387
  }
512
- async stop() {
513
- await this.socketClient.disconnect();
514
- log.logDisconnected();
388
+ async fetchChannels() {
389
+ // Fetch public/private channels
390
+ let cursor;
391
+ do {
392
+ const result = await this.webClient.conversations.list({
393
+ types: "public_channel,private_channel",
394
+ exclude_archived: true,
395
+ limit: 200,
396
+ cursor,
397
+ });
398
+ const channels = result.channels;
399
+ if (channels) {
400
+ for (const c of channels) {
401
+ if (c.id && c.name && c.is_member) {
402
+ this.channels.set(c.id, { id: c.id, name: c.name });
403
+ }
404
+ }
405
+ }
406
+ cursor = result.response_metadata?.next_cursor;
407
+ } while (cursor);
408
+ // Also fetch DM channels (IMs)
409
+ cursor = undefined;
410
+ do {
411
+ const result = await this.webClient.conversations.list({
412
+ types: "im",
413
+ limit: 200,
414
+ cursor,
415
+ });
416
+ const ims = result.channels;
417
+ if (ims) {
418
+ for (const im of ims) {
419
+ if (im.id) {
420
+ // Use user's name as channel name for DMs
421
+ const user = im.user ? this.users.get(im.user) : undefined;
422
+ const name = user ? `DM:${user.userName}` : `DM:${im.id}`;
423
+ this.channels.set(im.id, { id: im.id, name });
424
+ }
425
+ }
426
+ }
427
+ cursor = result.response_metadata?.next_cursor;
428
+ } while (cursor);
515
429
  }
516
430
  }
517
431
  //# sourceMappingURL=slack.js.map