@mariozechner/pi-mom 0.18.2 → 0.18.3

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,281 @@
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;
35
+ workingDir;
11
36
  botUserId = null;
12
- store;
13
- userCache = new Map();
14
- channelCache = new Map(); // id -> name
37
+ startupTs = null; // Messages older than this are just logged, not processed
38
+ users = new Map();
39
+ channels = new Map();
40
+ queues = new Map();
15
41
  constructor(handler, config) {
16
42
  this.handler = handler;
43
+ this.workingDir = config.workingDir;
17
44
  this.socketClient = new SocketModeClient({ appToken: config.appToken });
18
45
  this.webClient = new WebClient(config.botToken);
19
- this.store = new ChannelStore({
20
- workingDir: config.workingDir,
21
- botToken: config.botToken,
22
- });
46
+ }
47
+ // ==========================================================================
48
+ // Public API
49
+ // ==========================================================================
50
+ async start() {
51
+ const auth = await this.webClient.auth.test();
52
+ this.botUserId = auth.user_id;
53
+ await Promise.all([this.fetchUsers(), this.fetchChannels()]);
54
+ log.logInfo(`Loaded ${this.channels.size} channels, ${this.users.size} users`);
55
+ await this.backfillAllChannels();
23
56
  this.setupEventHandlers();
57
+ await this.socketClient.start();
58
+ // Record startup time - messages older than this are just logged, not processed
59
+ this.startupTs = (Date.now() / 1000).toFixed(6);
60
+ log.logConnected();
24
61
  }
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
- }
62
+ getUser(userId) {
63
+ return this.users.get(userId);
52
64
  }
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
- }
65
+ getChannel(channelId) {
66
+ return this.channels.get(channelId);
81
67
  }
82
- /**
83
- * Get all known channels (id -> name)
84
- */
85
- getChannels() {
86
- return Array.from(this.channelCache.entries()).map(([id, name]) => ({ id, name }));
68
+ getAllUsers() {
69
+ return Array.from(this.users.values());
70
+ }
71
+ getAllChannels() {
72
+ return Array.from(this.channels.values());
73
+ }
74
+ async postMessage(channel, text) {
75
+ const result = await this.webClient.chat.postMessage({ channel, text });
76
+ return result.ts;
77
+ }
78
+ async updateMessage(channel, ts, text) {
79
+ await this.webClient.chat.update({ channel, ts, text });
80
+ }
81
+ async postInThread(channel, threadTs, text) {
82
+ await this.webClient.chat.postMessage({ channel, thread_ts: threadTs, text });
83
+ }
84
+ async uploadFile(channel, filePath, title) {
85
+ const fileName = title || basename(filePath);
86
+ const fileContent = readFileSync(filePath);
87
+ await this.webClient.files.uploadV2({
88
+ channel_id: channel,
89
+ file: fileContent,
90
+ filename: fileName,
91
+ title: fileName,
92
+ });
87
93
  }
88
94
  /**
89
- * Get all known users
95
+ * Log a message to log.jsonl (SYNC)
96
+ * This is the ONLY place messages are written to log.jsonl
90
97
  */
91
- getUsers() {
92
- return Array.from(this.userCache.entries()).map(([id, { userName, displayName }]) => ({
93
- id,
94
- userName,
95
- displayName,
96
- }));
98
+ logToFile(channel, entry) {
99
+ const dir = join(this.workingDir, channel);
100
+ if (!existsSync(dir))
101
+ mkdirSync(dir, { recursive: true });
102
+ appendFileSync(join(dir, "log.jsonl"), JSON.stringify(entry) + "\n");
97
103
  }
98
104
  /**
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>"
105
+ * Log a bot response to log.jsonl
101
106
  */
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("_")}>`;
107
+ logBotResponse(channel, text, ts) {
108
+ this.logToFile(channel, {
109
+ date: new Date().toISOString(),
110
+ ts,
111
+ user: "bot",
112
+ text,
113
+ attachments: [],
114
+ isBot: true,
107
115
  });
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
116
  }
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 };
117
+ // ==========================================================================
118
+ // Private - Event Handlers
119
+ // ==========================================================================
120
+ getQueue(channelId) {
121
+ let queue = this.queues.get(channelId);
122
+ if (!queue) {
123
+ queue = new ChannelQueue();
124
+ this.queues.set(channelId, queue);
137
125
  }
126
+ return queue;
138
127
  }
139
128
  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);
129
+ // Channel @mentions
130
+ this.socketClient.on("app_mention", ({ event, ack }) => {
131
+ const e = event;
132
+ // Skip DMs (handled by message event)
133
+ if (e.channel.startsWith("D")) {
134
+ ack();
135
+ return;
136
+ }
137
+ const slackEvent = {
138
+ type: "mention",
139
+ channel: e.channel,
140
+ ts: e.ts,
141
+ user: e.user,
142
+ text: e.text.replace(/<@[A-Z0-9]+>/gi, "").trim(),
143
+ files: e.files,
144
+ };
145
+ // SYNC: Log to log.jsonl (ALWAYS, even for old messages)
146
+ this.logUserMessage(slackEvent);
147
+ // Only trigger processing for messages AFTER startup (not replayed old messages)
148
+ if (this.startupTs && e.ts < this.startupTs) {
149
+ log.logInfo(`[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`);
150
+ ack();
151
+ return;
152
+ }
153
+ // Check for stop command - execute immediately, don't queue!
154
+ if (slackEvent.text.toLowerCase().trim() === "stop") {
155
+ if (this.handler.isRunning(e.channel)) {
156
+ this.handler.handleStop(e.channel, this); // Don't await, don't queue
157
+ }
158
+ else {
159
+ this.postMessage(e.channel, "_Nothing running_");
160
+ }
161
+ ack();
162
+ return;
163
+ }
164
+ // SYNC: Check if busy
165
+ if (this.handler.isRunning(e.channel)) {
166
+ this.postMessage(e.channel, "_Already working. Say `@mom stop` to cancel._");
167
+ }
168
+ else {
169
+ this.getQueue(e.channel).enqueue(() => this.handler.handleEvent(slackEvent, this));
170
+ }
171
+ ack();
154
172
  });
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)
173
+ // All messages (for logging) + DMs (for triggering)
174
+ this.socketClient.on("message", ({ event, ack }) => {
175
+ const e = event;
176
+ // Skip bot messages, edits, etc.
177
+ if (e.bot_id || !e.user || e.user === this.botUserId) {
178
+ ack();
161
179
  return;
162
- // Ignore message edits, etc. (but allow file_share)
163
- if (slackEvent.subtype !== undefined && slackEvent.subtype !== "file_share")
180
+ }
181
+ if (e.subtype !== undefined && e.subtype !== "file_share") {
182
+ ack();
164
183
  return;
165
- // Ignore if no user
166
- if (!slackEvent.user)
184
+ }
185
+ if (!e.text && (!e.files || e.files.length === 0)) {
186
+ ack();
167
187
  return;
168
- // Ignore messages from the bot itself
169
- if (slackEvent.user === this.botUserId)
188
+ }
189
+ const isDM = e.channel_type === "im";
190
+ const isBotMention = e.text?.includes(`<@${this.botUserId}>`);
191
+ // Skip channel @mentions - already handled by app_mention event
192
+ if (!isDM && isBotMention) {
193
+ ack();
170
194
  return;
171
- // Ignore if no text AND no files
172
- if (!slackEvent.text && (!slackEvent.files || slackEvent.files.length === 0))
195
+ }
196
+ const slackEvent = {
197
+ type: isDM ? "dm" : "mention",
198
+ channel: e.channel,
199
+ ts: e.ts,
200
+ user: e.user,
201
+ text: (e.text || "").replace(/<@[A-Z0-9]+>/gi, "").trim(),
202
+ files: e.files,
203
+ };
204
+ // SYNC: Log to log.jsonl (ALL messages - channel chatter and DMs)
205
+ this.logUserMessage(slackEvent);
206
+ // Only trigger processing for messages AFTER startup (not replayed old messages)
207
+ if (this.startupTs && e.ts < this.startupTs) {
208
+ log.logInfo(`[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`);
209
+ ack();
173
210
  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
211
  }
212
+ // Only trigger handler for DMs
213
+ if (isDM) {
214
+ // Check for stop command - execute immediately, don't queue!
215
+ if (slackEvent.text.toLowerCase().trim() === "stop") {
216
+ if (this.handler.isRunning(e.channel)) {
217
+ this.handler.handleStop(e.channel, this); // Don't await, don't queue
218
+ }
219
+ else {
220
+ this.postMessage(e.channel, "_Nothing running_");
221
+ }
222
+ ack();
223
+ return;
224
+ }
225
+ if (this.handler.isRunning(e.channel)) {
226
+ this.postMessage(e.channel, "_Already working. Say `stop` to cancel._");
227
+ }
228
+ else {
229
+ this.getQueue(e.channel).enqueue(() => this.handler.handleEvent(slackEvent, this));
230
+ }
231
+ }
232
+ ack();
193
233
  });
194
234
  }
195
- async logMessage(event) {
196
- 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, {
235
+ /**
236
+ * Log a user message to log.jsonl (SYNC)
237
+ */
238
+ logUserMessage(event) {
239
+ const user = this.users.get(event.user);
240
+ this.logToFile(event.channel, {
199
241
  date: new Date(parseFloat(event.ts) * 1000).toISOString(),
200
242
  ts: event.ts,
201
243
  user: event.user,
202
- userName,
203
- displayName,
244
+ userName: user?.userName,
245
+ displayName: user?.displayName,
204
246
  text: event.text,
205
- attachments,
247
+ attachments: event.files?.map((f) => f.name) || [],
206
248
  isBot: false,
207
249
  });
208
250
  }
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;
251
+ // ==========================================================================
252
+ // Private - Backfill
253
+ // ==========================================================================
254
+ getExistingTimestamps(channelId) {
255
+ const logPath = join(this.workingDir, channelId, "log.jsonl");
256
+ const timestamps = new Set();
257
+ if (!existsSync(logPath))
258
+ return timestamps;
259
+ const content = readFileSync(logPath, "utf-8");
260
+ const lines = content.trim().split("\n").filter(Boolean);
261
+ for (const line of lines) {
262
+ try {
263
+ const entry = JSON.parse(line);
264
+ if (entry.ts)
265
+ timestamps.add(entry.ts);
220
266
  }
267
+ catch { }
221
268
  }
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
- };
269
+ return timestamps;
403
270
  }
404
- /**
405
- * Backfill missed messages for a single channel
406
- * Returns the number of messages backfilled
407
- */
408
271
  async backfillChannel(channelId) {
409
- const lastTs = this.store.getLastTimestamp(channelId);
272
+ const existingTs = this.getExistingTimestamps(channelId);
273
+ // Find the biggest ts in log.jsonl
274
+ let latestTs;
275
+ for (const ts of existingTs) {
276
+ if (!latestTs || parseFloat(ts) > parseFloat(latestTs))
277
+ latestTs = ts;
278
+ }
410
279
  const allMessages = [];
411
280
  let cursor;
412
281
  let pageCount = 0;
@@ -414,7 +283,7 @@ export class MomBot {
414
283
  do {
415
284
  const result = await this.webClient.conversations.history({
416
285
  channel: channelId,
417
- oldest: lastTs ?? undefined,
286
+ oldest: latestTs, // Only fetch messages newer than what we have
418
287
  inclusive: false,
419
288
  limit: 1000,
420
289
  cursor,
@@ -425,15 +294,14 @@ export class MomBot {
425
294
  cursor = result.response_metadata?.next_cursor;
426
295
  pageCount++;
427
296
  } while (cursor && pageCount < maxPages);
428
- // Filter messages: include mom's messages, exclude other bots
297
+ // Filter: include mom's messages, exclude other bots, skip already logged
429
298
  const relevantMessages = allMessages.filter((msg) => {
430
- // Always include mom's own messages
299
+ if (!msg.ts || existingTs.has(msg.ts))
300
+ return false; // Skip duplicates
431
301
  if (msg.user === this.botUserId)
432
302
  return true;
433
- // Exclude other bot messages
434
303
  if (msg.bot_id)
435
304
  return false;
436
- // Standard filters for user messages
437
305
  if (msg.subtype !== undefined && msg.subtype !== "file_share")
438
306
  return false;
439
307
  if (!msg.user)
@@ -442,76 +310,112 @@ export class MomBot {
442
310
  return false;
443
311
  return true;
444
312
  });
445
- // Reverse to chronological order (API returns newest first)
313
+ // Reverse to chronological order
446
314
  relevantMessages.reverse();
447
- // Log each message
315
+ // Log each message to log.jsonl
448
316
  for (const msg of relevantMessages) {
449
317
  const isMomMessage = msg.user === this.botUserId;
450
- 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
- }
318
+ const user = this.users.get(msg.user);
319
+ // Strip @mentions from text (same as live messages)
320
+ const text = (msg.text || "").replace(/<@[A-Z0-9]+>/gi, "").trim();
321
+ this.logToFile(channelId, {
322
+ date: new Date(parseFloat(msg.ts) * 1000).toISOString(),
323
+ ts: msg.ts,
324
+ user: isMomMessage ? "bot" : msg.user,
325
+ userName: isMomMessage ? undefined : user?.userName,
326
+ displayName: isMomMessage ? undefined : user?.displayName,
327
+ text,
328
+ attachments: msg.files?.map((f) => f.name) || [],
329
+ isBot: isMomMessage,
330
+ });
476
331
  }
477
332
  return relevantMessages.length;
478
333
  }
479
- /**
480
- * Backfill missed messages for all channels
481
- */
482
334
  async backfillAllChannels() {
483
335
  const startTime = Date.now();
484
- log.logBackfillStart(this.channelCache.size);
336
+ // Only backfill channels that already have a log.jsonl (mom has interacted with them before)
337
+ const channelsToBackfill = [];
338
+ for (const [channelId, channel] of this.channels) {
339
+ const logPath = join(this.workingDir, channelId, "log.jsonl");
340
+ if (existsSync(logPath)) {
341
+ channelsToBackfill.push([channelId, channel]);
342
+ }
343
+ }
344
+ log.logBackfillStart(channelsToBackfill.length);
485
345
  let totalMessages = 0;
486
- for (const [channelId, channelName] of this.channelCache) {
346
+ for (const [channelId, channel] of channelsToBackfill) {
487
347
  try {
488
348
  const count = await this.backfillChannel(channelId);
489
- if (count > 0) {
490
- log.logBackfillChannel(channelName, count);
491
- }
349
+ if (count > 0)
350
+ log.logBackfillChannel(channel.name, count);
492
351
  totalMessages += count;
493
352
  }
494
353
  catch (error) {
495
- log.logWarning(`Failed to backfill channel #${channelName}`, String(error));
354
+ log.logWarning(`Failed to backfill #${channel.name}`, String(error));
496
355
  }
497
356
  }
498
357
  const durationMs = Date.now() - startTime;
499
358
  log.logBackfillComplete(totalMessages, durationMs);
500
359
  }
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();
360
+ // ==========================================================================
361
+ // Private - Fetch Users/Channels
362
+ // ==========================================================================
363
+ async fetchUsers() {
364
+ let cursor;
365
+ do {
366
+ const result = await this.webClient.users.list({ limit: 200, cursor });
367
+ const members = result.members;
368
+ if (members) {
369
+ for (const u of members) {
370
+ if (u.id && u.name && !u.deleted) {
371
+ this.users.set(u.id, { id: u.id, userName: u.name, displayName: u.real_name || u.name });
372
+ }
373
+ }
374
+ }
375
+ cursor = result.response_metadata?.next_cursor;
376
+ } while (cursor);
511
377
  }
512
- async stop() {
513
- await this.socketClient.disconnect();
514
- log.logDisconnected();
378
+ async fetchChannels() {
379
+ // Fetch public/private channels
380
+ let cursor;
381
+ do {
382
+ const result = await this.webClient.conversations.list({
383
+ types: "public_channel,private_channel",
384
+ exclude_archived: true,
385
+ limit: 200,
386
+ cursor,
387
+ });
388
+ const channels = result.channels;
389
+ if (channels) {
390
+ for (const c of channels) {
391
+ if (c.id && c.name && c.is_member) {
392
+ this.channels.set(c.id, { id: c.id, name: c.name });
393
+ }
394
+ }
395
+ }
396
+ cursor = result.response_metadata?.next_cursor;
397
+ } while (cursor);
398
+ // Also fetch DM channels (IMs)
399
+ cursor = undefined;
400
+ do {
401
+ const result = await this.webClient.conversations.list({
402
+ types: "im",
403
+ limit: 200,
404
+ cursor,
405
+ });
406
+ const ims = result.channels;
407
+ if (ims) {
408
+ for (const im of ims) {
409
+ if (im.id) {
410
+ // Use user's name as channel name for DMs
411
+ const user = im.user ? this.users.get(im.user) : undefined;
412
+ const name = user ? `DM:${user.userName}` : `DM:${im.id}`;
413
+ this.channels.set(im.id, { id: im.id, name });
414
+ }
415
+ }
416
+ }
417
+ cursor = result.response_metadata?.next_cursor;
418
+ } while (cursor);
515
419
  }
516
420
  }
517
421
  //# sourceMappingURL=slack.js.map