@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/CHANGELOG.md +41 -11
- package/README.md +36 -20
- package/dist/agent.d.ts +14 -2
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +294 -331
- package/dist/agent.js.map +1 -1
- package/dist/context.d.ts +132 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +538 -0
- package/dist/context.js.map +1 -0
- package/dist/log.d.ts +1 -1
- package/dist/log.d.ts.map +1 -1
- package/dist/log.js +14 -1
- package/dist/log.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +169 -90
- package/dist/main.js.map +1 -1
- package/dist/slack.d.ts +92 -55
- package/dist/slack.d.ts.map +1 -1
- package/dist/slack.js +329 -415
- package/dist/slack.js.map +1 -1
- package/package.json +4 -3
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
35
|
+
workingDir;
|
|
12
36
|
store;
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return Array.from(this.
|
|
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
|
-
*
|
|
97
|
+
* Log a message to log.jsonl (SYNC)
|
|
98
|
+
* This is the ONLY place messages are written to log.jsonl
|
|
90
99
|
*/
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
//
|
|
141
|
-
this.socketClient.on("app_mention",
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
//
|
|
156
|
-
this.socketClient.on("message",
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
if (
|
|
183
|
+
}
|
|
184
|
+
if (e.subtype !== undefined && e.subtype !== "file_share") {
|
|
185
|
+
ack();
|
|
164
186
|
return;
|
|
165
|
-
|
|
166
|
-
if (!
|
|
187
|
+
}
|
|
188
|
+
if (!e.text && (!e.files || e.files.length === 0)) {
|
|
189
|
+
ack();
|
|
167
190
|
return;
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
305
|
+
// Filter: include mom's messages, exclude other bots, skip already logged
|
|
429
306
|
const relevantMessages = allMessages.filter((msg) => {
|
|
430
|
-
|
|
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
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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.
|
|
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,
|
|
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(
|
|
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
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
|
513
|
-
|
|
514
|
-
|
|
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
|