-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathapp.js
More file actions
382 lines (347 loc) · 13.1 KB
/
Copy pathapp.js
File metadata and controls
382 lines (347 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
///////////////////////////////////////////////////////////////
// A bolt.js Slack chatbot. Wires Bolt event handlers onto pure
// helpers in lib/. Conversation is routed through native Ollama
// or Gemini SDKs by lib/chat.js; canned trigger-word replies are
// matched in lib/responses.js.
///////////////////////////////////////////////////////////////
import 'dotenv/config';
import { directMention } from '@slack/bolt';
import fetch from 'node-fetch';
import { buildDeps, validateRequiredEnv } from './lib/deps.js';
import { handleMessage, clearHistory } from './lib/chat.js';
import { generateImage } from './lib/image.js';
import {
ASIMOV_RULES,
IMAGE_REQUEST_GUIDANCE,
RICKROLL_BLOCKS,
TIKTOK_BLOCKS,
buildDancePartyMessage,
buildHelpText,
fetchDadJoke,
formatDadJoke,
formatPodBayResponse,
GENERIC_ERROR_TEXT,
isDanceParty,
isHelpRequest,
isImageRequest,
isLoveYou,
isPodBayDoor,
isRickroll,
isTheRules,
isTikTok,
} from './lib/responses.js';
export { generateImage, handleMessage };
const THINKING_REACTION = 'brain';
// Add a :brain: reaction to the user's message to signal Data is processing.
// Returns true if the reaction landed (so caller can remove it on reply).
async function addThinkingReaction(app, channel, ts) {
if (!channel || !ts) return false;
try {
await app.client.reactions.add({ channel, timestamp: ts, name: THINKING_REACTION });
return true;
} catch (err) {
console.warn('Failed to add thinking reaction:', err && err.message ? err.message : err);
return false;
}
}
async function removeThinkingReaction(app, channel, ts) {
if (!channel || !ts) return;
try {
await app.client.reactions.remove({ channel, timestamp: ts, name: THINKING_REACTION });
} catch (err) {
console.warn('Failed to remove thinking reaction:', err && err.message ? err.message : err);
}
}
const VISION_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
// Pull any image attachments off a Slack message, fetch them with the bot
// token, return `[{ mimeType, data: base64 }]` for the chat layer. Anything
// non-image or that fails to fetch is logged and skipped.
async function extractMessageImages(message, botToken) {
if (!message.files?.length) return [];
const allFiles = message.files;
const imageFiles = allFiles.filter((f) => VISION_MIME_TYPES.includes(f.mimetype));
if (allFiles.length && !imageFiles.length) {
console.log(
`Message has ${allFiles.length} attachment(s) but none are supported image types:`,
allFiles.map((f) => f.mimetype).join(', ')
);
}
const out = [];
for (const file of imageFiles) {
try {
const res = await fetch(file.url_private, {
headers: { Authorization: `Bearer ${botToken}` },
});
if (!res.ok) {
console.warn(`Slack file fetch ${file.id}: HTTP ${res.status}`);
continue;
}
const buf = Buffer.from(await res.arrayBuffer());
out.push({ mimeType: file.mimetype, data: buf.toString('base64') });
console.log(
`Vision: extracted ${file.mimetype} (${(buf.length / 1024).toFixed(1)}KB) from Slack file ${
file.id
}`
);
} catch (err) {
console.warn(`Slack file fetch ${file.id} failed:`, err.message);
}
}
return out;
}
// Shared chat-turn pipeline for both the DM handler and the @-mention handler.
// Runs the common pre-flight guards (empty message, edits, image-request nudge)
// then the react → extract-images → handleMessage → reply sequence. The only
// things that differ between the two call sites are `say` (flat for DMs,
// in-thread for mentions) and the error-log label, so both are injected.
export async function runChatTurn({ message, say, deps, errorLabel }) {
const { app, chat, convoStore, botToken } = deps;
const hasText = message.text && message.text.trim() !== '';
const hasFiles = !!message.files?.length;
if (!hasText && !hasFiles) return;
if (message.edited) return;
if (isImageRequest(message.text)) {
await say(IMAGE_REQUEST_GUIDANCE);
return;
}
const reacted = await addThinkingReaction(app, message.channel, message.ts);
try {
const images = await extractMessageImages(message, botToken);
const result = await handleMessage({ ...message, images }, { chat, convoStore });
if (reacted) await removeThinkingReaction(app, message.channel, message.ts);
await say(result.text);
} catch (error) {
console.error(errorLabel, error);
if (reacted) await removeThinkingReaction(app, message.channel, message.ts);
await say(GENERIC_ERROR_TEXT);
}
}
// Wire all the Bolt event listeners onto `deps.app`. Pure: takes deps, registers handlers.
export function registerHandlers(deps) {
// `chat` is consumed inside runChatTurn (via `deps`); everything else is used
// directly by the handlers below.
const { app, convoStore, geminiClient, geminiImageModel, botName, botToken } = deps;
app.message(async ({ message, say, context }) => {
if (!message) {
console.log('Received undefined message');
return;
}
if (context.botUserId && message.text && message.text.includes(`<@${context.botUserId}>`)) {
return;
}
// Slack tags messages with attached files as subtype 'file_share' — let
// those through so vision uploads reach the LLM. All other subtypes
// (edits, deletes, channel joins, etc.) are skipped.
if (message.subtype && message.subtype !== 'file_share') return;
// Ignore bot-originated messages (prevents loops). Match the mention
// handler: some bot messages carry bot_profile but no bot_id.
if (message.bot_profile || message.bot_id) return;
if (isLoveYou(message.text)) {
await say('I know.');
return;
}
if (isPodBayDoor(message.text)) {
// This branch makes a live users.info call — the only canned response
// that does I/O. Guard it so a transient Slack API failure can't take
// down the handler with an unhandled rejection; fall back to HAL's
// canonical "Dave" so the gag still lands.
let displayName = 'Dave';
try {
const userInfo = await app.client.users.info({ token: botToken, user: message.user });
displayName = userInfo.user.profile.display_name || userInfo.user.real_name || displayName;
} catch (err) {
console.warn('pod bay: users.info failed, using fallback name:', err?.message || err);
}
await say(formatPodBayResponse(displayName));
return;
}
if (isDanceParty(message.text)) {
await say(buildDancePartyMessage());
return;
}
if (isTikTok(message.text)) {
await say(TIKTOK_BLOCKS);
return;
}
if (isRickroll(message.text)) {
await say(RICKROLL_BLOCKS);
return;
}
const channelType = message.channel_type;
if (channelType !== 'im' && channelType !== 'mpim') return;
await runChatTurn({
message,
say,
deps,
errorLabel: `Error in ${channelType} message processing:`,
});
});
app.message(directMention(), async ({ message, say }) => {
if (!message) return;
// Slack tags messages with attached files as subtype 'file_share' — let
// those through so vision uploads reach the LLM. All other subtypes
// (edits, deletes, channel joins, etc.) are skipped.
if (message.subtype && message.subtype !== 'file_share') return;
// Bail on bot-originated messages to prevent loops; thread replies from
// humans are allowed through so Data can hold a back-and-forth in-thread.
if (message.bot_profile || message.bot_id) return;
// Channel @-mentions reply in-thread: continue the existing thread if the
// mention came from one, otherwise start a new thread rooted at the
// mention itself. Keeps Data from flooding the channel.
const threadTs = message.thread_ts || message.ts;
const sayInThread = (payload) => {
const obj = typeof payload === 'string' ? { text: payload } : payload;
return say({ ...obj, thread_ts: threadTs });
};
if (isHelpRequest(message.text)) {
await sayInThread(buildHelpText(botName));
return;
}
if (isTheRules(message.text)) {
await sayInThread(ASIMOV_RULES);
return;
}
if (message.text && /\bdad\s*joke\b/i.test(message.text)) {
try {
const joke = await fetchDadJoke(fetch);
const { joke: jokeText, zinger } = formatDadJoke(joke);
await sayInThread(jokeText);
if (zinger) {
// Fire-and-forget the zinger after a beat for comedic timing — don't
// hold the handler open for 10s waiting on it. Errors are logged only.
setTimeout(() => {
sayInThread(zinger).catch((err) =>
console.error('Failed to post dad joke zinger:', err)
);
}, 10000);
}
} catch (error) {
console.error(error);
await sayInThread(`Encountered an error :( ${error}`);
}
return;
}
await runChatTurn({
message,
say: sayInThread,
deps,
errorLabel: 'Error in direct mention processing:',
});
});
app.command('/image', async ({ command, ack, respond, client }) => {
try {
await ack();
if (!command.text || command.text.trim() === '') {
await respond({
text: 'I need a description to generate an image. Please provide a prompt after the /image command.',
response_type: 'ephemeral',
});
return;
}
const prompt = command.text;
await respond({
text: `:art: Generating image for prompt: "${prompt}"...`,
blocks: [
{
type: 'section',
text: { type: 'mrkdwn', text: `:art: *Generating image with Gemini*` },
},
{ type: 'section', text: { type: 'mrkdwn', text: `> ${prompt}` } },
{
type: 'context',
elements: [
{ type: 'mrkdwn', text: ':hourglass_flowing_sand: _This may take a few moments..._' },
],
},
],
response_type: 'ephemeral',
});
queueMicrotask(async () => {
try {
const imageBuffer = await generateImage(prompt, {
client: geminiClient,
model: geminiImageModel,
});
await client.files.uploadV2({
token: botToken,
channel_id: command.channel_id,
file: imageBuffer,
filename: 'gemini-image.png',
title: prompt,
initial_comment: `Here's the Gemini image for: "${prompt}"`,
alt_text: `Gemini generated image for: ${prompt}`,
});
} catch (error) {
console.error('Error in async image generation:', error);
await respond({
text: `❌ Image generation failed: ${error.message}`,
response_type: 'ephemeral',
replace_original: false,
});
}
});
} catch (error) {
console.error('Error in initial /image command handling:', error);
try {
await respond({
text: `❌ Error processing command: ${error.message}`,
response_type: 'ephemeral',
});
} catch (respondError) {
console.error('Failed to send error response:', respondError);
}
}
});
// Let a user wipe their own conversation history. History is keyed by user id
// alone, so this resets Data's memory of the user everywhere, not just the
// channel the command was invoked from. Reply is ephemeral so it stays quiet.
app.command('/forget', async ({ command, ack, respond }) => {
try {
await ack();
await clearHistory(command.user_id, { convoStore });
console.log(`Cleared conversation history for user ${command.user_id}`);
await respond({
text: 'My memory of our previous conversation has been erased. I am now a blank slate, ready to begin anew. How may I assist you?',
response_type: 'ephemeral',
});
} catch (error) {
console.error('Error in /forget command handling:', error);
try {
await respond({
text: `❌ I was unable to clear our conversation history: ${error.message}`,
response_type: 'ephemeral',
});
} catch (respondError) {
console.error('Failed to send error response:', respondError);
}
}
});
}
export async function start(deps = buildDeps()) {
// Graceful shutdown
const shutdown = async (signal) => {
console.log(`Received ${signal}, stopping app...`);
try {
await deps.app.stop();
} catch (err) {
console.error('Error while stopping app:', err && err.message ? err.message : err);
}
process.exit(0);
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err && err.stack ? err.stack : err);
shutdown('uncaughtException');
});
registerHandlers(deps);
await deps.app.start(process.env.PORT || 3000);
console.log('Bot is alive!');
}
// Only boot the bot when this module is run directly. This is what lets the
// test suite import app.js without triggering env validation or Slack connect.
const isMain = import.meta.url === `file://${process.argv[1]}`;
if (isMain) {
validateRequiredEnv();
start();
}