@mariozechner/pi-mom 0.18.1 → 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/CHANGELOG.md +31 -11
- package/README.md +30 -18
- 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 +166 -90
- package/dist/main.js.map +1 -1
- package/dist/slack.d.ts +86 -55
- package/dist/slack.d.ts.map +1 -1
- package/dist/slack.js +322 -418
- package/dist/slack.js.map +1 -1
- package/package.json +4 -3
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
|
-
|
|
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;
|
|
35
|
+
workingDir;
|
|
11
36
|
botUserId = null;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return Array.from(this.
|
|
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
|
-
*
|
|
95
|
+
* Log a message to log.jsonl (SYNC)
|
|
96
|
+
* This is the ONLY place messages are written to log.jsonl
|
|
90
97
|
*/
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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 };
|
|
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
|
-
//
|
|
141
|
-
this.socketClient.on("app_mention",
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
//
|
|
156
|
-
this.socketClient.on("message",
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
if (
|
|
180
|
+
}
|
|
181
|
+
if (e.subtype !== undefined && e.subtype !== "file_share") {
|
|
182
|
+
ack();
|
|
164
183
|
return;
|
|
165
|
-
|
|
166
|
-
if (!
|
|
184
|
+
}
|
|
185
|
+
if (!e.text && (!e.files || e.files.length === 0)) {
|
|
186
|
+
ack();
|
|
167
187
|
return;
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
297
|
+
// Filter: include mom's messages, exclude other bots, skip already logged
|
|
429
298
|
const relevantMessages = allMessages.filter((msg) => {
|
|
430
|
-
|
|
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
|
|
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
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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.
|
|
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,
|
|
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(
|
|
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
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
|
513
|
-
|
|
514
|
-
|
|
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
|