Fully switch to bun / remove slack / update package list

This commit is contained in:
Max Wofford 2025-03-11 09:59:01 -04:00
parent 312d34d103
commit 3d86957e9f
11 changed files with 4 additions and 493 deletions

View file

@ -1,9 +1,3 @@
# Slack
SLACK_BOT_TOKEN=xoxb- # From OAuth & Permissions
SLACK_SIGNING_SECRET= # From Basic Information
SLACK_APP_TOKEN=xapp- # From Basic Information (for Socket Mode)
SLACK_CHANNEL_ID=channel-id # Channel where bot operates
# S3 Config CF in this example # S3 Config CF in this example
AWS_ACCESS_KEY_ID=1234567890abcdef AWS_ACCESS_KEY_ID=1234567890abcdef
AWS_SECRET_ACCESS_KEY=abcdef1234567890 AWS_SECRET_ACCESS_KEY=abcdef1234567890

1
.gitignore vendored
View file

@ -2,6 +2,5 @@
/splitfornpm/ /splitfornpm/
/.idea/ /.idea/
/.env /.env
/bun.lockb
/package-lock.json /package-lock.json
/.history /.history

BIN
bun.lockb Executable file

Binary file not shown.

View file

@ -1,25 +1,11 @@
const dotenv = require('dotenv');
dotenv.config();
const logger = require('./src/config/logger'); const logger = require('./src/config/logger');
logger.info('Starting CDN application 🚀'); logger.info('Starting CDN application 🚀');
// const {App} = require('@slack/bolt');
const fileUpload = require('./src/fileUpload');
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const apiRoutes = require('./src/api/index.js'); const apiRoutes = require('./src/api/index.js');
const BOT_START_TIME = Date.now() / 1000;
// const app = new App({
// token: process.env.SLACK_BOT_TOKEN,
// signingSecret: process.env.SLACK_SIGNING_SECRET,
// socketMode: false,
// appToken: process.env.SLACK_APP_TOKEN
// });
// API server // API server
const expressApp = express(); const expressApp = express();
expressApp.use(cors()); expressApp.use(cors());
@ -51,27 +37,12 @@ expressApp.use((req, res, next) => {
res.status(404).json({ error: 'Not found' }); res.status(404).json({ error: 'Not found' });
}); });
// Event listener for file_shared events
// app.event('file_shared', async ({event, client}) => {
// if (parseFloat(event.event_ts) < BOT_START_TIME) return;
// if (event.channel_id !== process.env.SLACK_CHANNEL_ID) return;
// try {
// await fileUpload.handleFileUpload(event, client);
// } catch (error) {
// logger.error(`Upload failed: ${error.message}`);
// }
// });
// Startup LOGs // Startup LOGs
(async () => { (async () => {
try { try {
await fileUpload.initialize();
// await app.start();
const port = parseInt(process.env.PORT || '4553', 10); const port = parseInt(process.env.PORT || '4553', 10);
expressApp.listen(port, () => { expressApp.listen(port, () => {
logger.info('CDN started successfully 🔥', { logger.info('CDN started successfully 🔥', {
slackMode: 'Socket Mode',
apiPort: port, apiPort: port,
startTime: new Date().toISOString() startTime: new Date().toISOString()
}); });

View file

@ -1,9 +0,0 @@
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(winston.format.colorize(), winston.format.simple()),
transports: [new winston.transports.Console()],
});
module.exports = logger;

View file

@ -1,17 +1,15 @@
{ {
"name": "cdn-v2-hackclub", "name": "cdn-v2-hackclub",
"version": "1.0.0", "version": "1.0.0",
"description": "Slack app and API to upload files to S3-compatible storage with unique URLs", "description": "API to upload files to S3-compatible storage with unique URLs",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "node index.js" "start": "bun index.js",
"dev": "bun --watch index.js"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.478.0", "@aws-sdk/client-s3": "^3.478.0",
"@slack/bolt": "^4.2.0",
"@slack/web-api": "^7.8.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^10.0.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-fetch": "^2.6.1", "node-fetch": "^2.6.1",
"p-limit": "^6.2.0", "p-limit": "^6.2.0",

View file

@ -1,7 +1,7 @@
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const crypto = require('crypto'); const crypto = require('crypto');
const {uploadToStorage} = require('../storage'); const {uploadToStorage} = require('../storage');
const {generateUrl, getCdnUrl} = require('./utils'); const {generateUrl} = require('./utils');
const logger = require('../config/logger'); const logger = require('../config/logger');
// Sanitize file name for storage // Sanitize file name for storage

View file

@ -1,5 +1,3 @@
const logger = require('../config/logger');
const getCdnUrl = () => process.env.AWS_CDN_URL; const getCdnUrl = () => process.env.AWS_CDN_URL;
const generateUrl = (version, fileName) => { const generateUrl = (version, fileName) => {

View file

@ -1,137 +0,0 @@
const messages = {
success: {
singleFile: "Hey <@{userId}>, here's your link:",
multipleFiles: "Hey <@{userId}>, here are your links:",
alternateSuccess: [
"thanks!",
"thanks, i'm gonna sell these to adfly!",
"tysm!",
"file away!"
]
},
fileTypes: {
gif: [
"_gif_ that file to me and i'll upload it",
"_gif_ me all all your files!"
],
heic: [
"What the heic???"
],
mov: [
"I'll _mov_ that to a permanent link for you"
],
html: [
"Oh, launching a new website?",
"uwu, what's this site?",
"WooOOAAah hey! Are you serving a site?",
"h-t-m-ello :wave:"
],
rar: [
".rawr xD",
"i also go \"rar\" sometimes!"
]
},
errors: {
tooBig: {
messages: [
"File too big!",
"That's a chonky file!",
"_orpheus struggles to lift the massive file_",
"Sorry, that file's too thicc for me to handle!"
],
images: [
"https://cloud-3tq9t10za-hack-club-bot.vercel.app/2too_big_4.png",
"https://cloud-3tq9t10za-hack-club-bot.vercel.app/3too_big_2.png",
"https://cloud-3tq9t10za-hack-club-bot.vercel.app/4too_big_1.png",
"https://cloud-3tq9t10za-hack-club-bot.vercel.app/6too_big_5.png",
"https://cloud-3tq9t10za-hack-club-bot.vercel.app/7too_big_3.png"
]
},
generic: {
messages: [
"_orpheus sneezes and drops the files on the ground before blowing her nose on a blank jpeg._",
"_orpheus trips and your files slip out of her hands and into an inconveniently placed sewer grate._",
"_orpheus accidentally slips the files into a folder in her briefcase labeled \"homework\". she starts sweating profusely._"
],
images: [
"https://cloud-3tq9t10za-hack-club-bot.vercel.app/0generic_3.png",
"https://cloud-3tq9t10za-hack-club-bot.vercel.app/1generic_2.png",
"https://cloud-3tq9t10za-hack-club-bot.vercel.app/5generic_1.png"
]
}
}
};
function getRandomItem(array) {
return array[Math.floor(Math.random() * array.length)];
}
function getFileTypeMessage(fileExtension) {
const ext = fileExtension.toLowerCase();
return messages.fileTypes[ext] ? getRandomItem(messages.fileTypes[ext]) : null;
}
function formatErrorMessage(failedFiles, isSizeError = false) {
const errorType = isSizeError ? messages.errors.tooBig : messages.errors.generic;
const errorMessage = getRandomItem(errorType.messages);
const errorImage = getRandomItem(errorType.images);
return [
errorMessage,
`Failed files: ${failedFiles.join(', ')}`,
'',
`<${errorImage}|image>`
].join('\n');
}
function formatSuccessMessage(userId, files, failedFiles = [], sizeFailedFiles = []) {
const messageLines = [];
const baseMessage = files.length === 1 ?
messages.success.singleFile :
messages.success.multipleFiles;
messageLines.push(baseMessage.replace('{userId}', userId), '');
const fileGroups = new Map();
files.forEach(file => {
const ext = file.originalName.split('.').pop();
const typeMessage = getFileTypeMessage(ext);
const key = typeMessage || 'noType';
if (!fileGroups.has(key)) {
fileGroups.set(key, []);
}
fileGroups.get(key).push(file);
});
fileGroups.forEach((groupFiles, typeMessage) => {
if (typeMessage !== 'noType') {
messageLines.push('', typeMessage);
}
groupFiles.forEach(file => {
messageLines.push(`${file.originalName}: ${file.url}`);
});
});
if (sizeFailedFiles.length > 0) {
messageLines.push(formatErrorMessage(sizeFailedFiles, true));
}
if (failedFiles.length > 0) {
messageLines.push(formatErrorMessage(failedFiles, false));
}
if (files.length > 0) {
messageLines.push('', `_${getRandomItem(messages.success.alternateSuccess)}_`);
}
return messageLines.join('\n');
}
module.exports = {
messages,
getFileTypeMessage,
formatSuccessMessage,
formatErrorMessage,
getRandomItem
};

View file

@ -1,302 +0,0 @@
const fetch = require('node-fetch');
const crypto = require('crypto');
const logger = require('./config/logger');
const storage = require('./storage');
const {generateFileUrl} = require('./utils');
const path = require('path');
const {
messages,
formatSuccessMessage,
formatErrorMessage,
getFileTypeMessage
} = require('./config/messages');
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB in bytes
const CONCURRENT_UPLOADS = 3; // Max concurrent uploads (messages)
const processedMessages = new Map();
let uploadLimit;
async function initialize() {
const pLimit = (await import('p-limit')).default;
uploadLimit = pLimit(CONCURRENT_UPLOADS);
}
// Basic stuff
function isMessageTooOld(eventTs) {
const eventTime = parseFloat(eventTs) * 1000;
return (Date.now() - eventTime) > 24 * 60 * 60 * 1000;
}
function isMessageProcessed(messageTs) {
return processedMessages.has(messageTs);
}
function markMessageAsProcessing(messageTs) {
processedMessages.set(messageTs, true);
}
// File processing
function sanitizeFileName(fileName) {
let sanitized = fileName.replace(/[^a-zA-Z0-9.-]/g, '_');
return sanitized || `upload_${Date.now()}`;
}
function generateUniqueFileName(fileName) {
return `${Date.now()}-${crypto.randomBytes(16).toString('hex')}-${sanitizeFileName(fileName)}`;
}
// upload functionality
async function processFiles(fileMessage, client) {
const uploadedFiles = [];
const failedFiles = [];
const sizeFailedFiles = [];
const fileTypeResponses = new Set();
logger.info(`Processing ${fileMessage.files?.length || 0} files`);
for (const file of fileMessage.files || []) {
try {
if (file.size > MAX_FILE_SIZE) {
sizeFailedFiles.push(file.name);
continue;
}
// Get file extension message if applicable
const ext = path.extname(file.name).slice(1);
const typeMessage = getFileTypeMessage(ext);
if (typeMessage) fileTypeResponses.add(typeMessage);
const response = await fetch(file.url_private, {
headers: {Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`}
});
if (!response.ok) throw new Error('Download failed');
const buffer = await response.buffer();
const uniqueFileName = generateUniqueFileName(file.name);
const userDir = `s/${fileMessage.user}`;
const success = await uploadLimit(() =>
storage.uploadToStorage(userDir, uniqueFileName, buffer, file.mimetype)
);
if (!success) throw new Error('Upload failed');
uploadedFiles.push({
name: uniqueFileName,
originalName: file.name,
url: generateFileUrl(userDir, uniqueFileName),
contentType: file.mimetype
});
} catch (error) {
logger.error(`Failed: ${file.name} - ${error.message}`);
failedFiles.push(file.name);
}
}
return {
uploadedFiles,
failedFiles,
sizeFailedFiles,
isSizeError: sizeFailedFiles.length > 0
};
}
// Slack interaction
async function addProcessingReaction(client, event, fileMessage) {
try {
await client.reactions.add({
name: 'beachball',
timestamp: fileMessage.ts,
channel: event.channel_id
});
} catch (error) {
logger.error('Failed to add reaction:', error.message);
}
}
async function updateReactions(client, event, fileMessage, totalFiles, failedCount) {
try {
await client.reactions.remove({
name: 'beachball',
timestamp: fileMessage.ts,
channel: event.channel_id
});
// Choose reaction based on how many files failed or well succeded
let reactionName;
if (failedCount === totalFiles) {
reactionName = 'x'; // All files failed
} else if (failedCount > 0) {
reactionName = 'warning'; // Some files failed
} else {
reactionName = 'white_check_mark'; // All files succeeded
}
await client.reactions.add({
name: reactionName,
timestamp: fileMessage.ts,
channel: event.channel_id
});
} catch (error) {
logger.error('Failed to update reactions:', error.message);
}
}
async function findFileMessage(event, client) {
try {
const fileInfo = await client.files.info({
file: event.file_id,
include_shares: true
});
if (!fileInfo.ok || !fileInfo.file) {
throw new Error('Could not get file info');
}
const channelShare = fileInfo.file.shares?.public?.[event.channel_id] ||
fileInfo.file.shares?.private?.[event.channel_id];
if (!channelShare || !channelShare.length) {
throw new Error('No share info found for this channel');
}
// Get the EXACT message using the ts from share info (channelShare)
const messageTs = channelShare[0].ts;
const messageInfo = await client.conversations.history({
channel: event.channel_id,
latest: messageTs,
limit: 1,
inclusive: true
});
if (!messageInfo.ok || !messageInfo.messages.length) {
throw new Error('Could not find original message');
}
return messageInfo.messages[0];
} catch (error) {
logger.error('Error finding file message:', error);
return null;
}
}
async function sendResultsMessage(client, channelId, fileMessage, uploadedFiles, failedFiles, sizeFailedFiles) {
try {
let message;
if (uploadedFiles.length === 0 && (failedFiles.length > 0 || sizeFailedFiles.length > 0)) {
// All files failed - use appropriate error type
message = formatErrorMessage(
[...failedFiles, ...sizeFailedFiles],
sizeFailedFiles.length > 0 && failedFiles.length === 0 // Only use size error if all failures are size-related (i hope this is how it makes most sense)
);
} else {
// Mixed success/failure or all success
message = formatSuccessMessage(
fileMessage.user,
uploadedFiles,
failedFiles,
sizeFailedFiles
);
}
const lines = message.split('\n');
const attachments = [];
let textBuffer = '';
for (const line of lines) {
if (line.match(/^<.*\|image>$/)) {
const imageUrl = line.replace(/^<|>$/g, '').replace('|image', '');
attachments.push({
image_url: imageUrl,
fallback: 'Error image'
});
} else {
textBuffer += line + '\n';
}
}
await client.chat.postMessage({
channel: channelId,
thread_ts: fileMessage.ts,
text: textBuffer.trim(),
attachments: attachments.length > 0 ? attachments : undefined
});
} catch (error) {
logger.error('Failed to send results message:', error);
throw error;
}
}
async function handleError(client, channelId, fileMessage, reactionAdded) {
if (fileMessage && reactionAdded) {
try {
await client.reactions.remove({
name: 'beachball',
timestamp: fileMessage.ts,
channel: channelId
});
} catch (cleanupError) {
if (cleanupError.data.error !== 'no_reaction') {
logger.error('Cleanup error:', cleanupError);
}
}
try {
await client.reactions.add({
name: 'x',
timestamp: fileMessage.ts,
channel: channelId
});
} catch (cleanupError) {
logger.error('Cleanup error:', cleanupError);
}
}
}
async function handleFileUpload(event, client) {
let fileMessage = null;
let reactionAdded = false;
try {
if (isMessageTooOld(event.event_ts)) return;
fileMessage = await findFileMessage(event, client);
if (!fileMessage || isMessageProcessed(fileMessage.ts)) return;
markMessageAsProcessing(fileMessage.ts);
await addProcessingReaction(client, event, fileMessage);
reactionAdded = true;
const {uploadedFiles, failedFiles, sizeFailedFiles} = await processFiles(fileMessage, client);
const totalFiles = uploadedFiles.length + failedFiles.length + sizeFailedFiles.length;
const failedCount = failedFiles.length + sizeFailedFiles.length;
await sendResultsMessage(
client,
event.channel_id,
fileMessage,
uploadedFiles,
failedFiles,
sizeFailedFiles
);
await updateReactions(
client,
event,
fileMessage,
totalFiles,
failedCount
);
} catch (error) {
logger.error(`Upload failed: ${error.message}`);
await handleError(client, event.channel_id, fileMessage, reactionAdded);
throw error;
}
}
module.exports = { handleFileUpload, initialize };

View file

@ -1,5 +1,4 @@
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3'); const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const logger = require('./config/logger'); const logger = require('./config/logger');
const {generateFileUrl} = require('./utils'); const {generateFileUrl} = require('./utils');