diff options
Diffstat (limited to 'src/main/java/com')
| -rw-r--r-- | src/main/java/com/pinapelz/Database.java | 140 | ||||
| -rw-r--r-- | src/main/java/com/pinapelz/FileSystem.java | 23 | ||||
| -rw-r--r-- | src/main/java/com/pinapelz/Main.java | 3 | ||||
| -rw-r--r-- | src/main/java/com/pinapelz/Retriever.java | 8 | ||||
| -rw-r--r-- | src/main/java/com/pinapelz/frontend/App.kt | 189 | ||||
| -rw-r--r-- | src/main/java/com/pinapelz/frontend/MultipartFileManager.kt | 263 | ||||
| -rw-r--r-- | src/main/java/com/pinapelz/frontend/WebhookManager.kt | 165 |
7 files changed, 773 insertions, 18 deletions
diff --git a/src/main/java/com/pinapelz/Database.java b/src/main/java/com/pinapelz/Database.java index e43212d..0e8e984 100644 --- a/src/main/java/com/pinapelz/Database.java +++ b/src/main/java/com/pinapelz/Database.java @@ -7,6 +7,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.sql.*; +import java.sql.Types; import java.util.Properties; public class Database { @@ -214,6 +215,43 @@ public class Database { } } + public ResultSet getUniqueOriginalFilesFromPartials(int directoryId, String search) { + StringBuilder sql = new StringBuilder(""" + SELECT DISTINCT + original_filename, + mime_type, + directory_id, + MAX(created_at) as created_at, + SUM(part_size) as size, + MAX(file_description) as file_description + FROM file_partials + WHERE directory_id = ? + """); + + if (search != null && !search.trim().isEmpty()) { + sql.append(" AND LOWER(original_filename) LIKE ?"); + } + + sql.append(""" + GROUP BY original_filename, mime_type, directory_id + ORDER BY original_filename ASC + """); + + try { + PreparedStatement ps = conn.prepareStatement(sql.toString()); + int paramIndex = 1; + ps.setInt(paramIndex++, directoryId); + + if (search != null && !search.trim().isEmpty()) { + ps.setString(paramIndex++, "%" + search.toLowerCase() + "%"); + } + + return ps.executeQuery(); + } catch (SQLException e) { + throw new RuntimeException("Failed to fetch unique original files from partials", e); + } + } + public boolean deleteDirectory(int directoryId) throws SQLException { String checkSql = """ SELECT COUNT(*) as file_count @@ -246,4 +284,106 @@ public class Database { } } + public long recordFilePartial(String channelId, String messageId, int directoryId, + String partName, int partNumber, long partSize, + String originalFilename, String description, String mimeType) throws SQLException { + String sql = """ + INSERT INTO file_partials ( + disc_channel_id, + disc_message_id, + directory_id, + part_name, + part_number, + part_size, + original_filename, + file_description, + mime_type, + uploaded_via_webhook + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING partial_id + """; + + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, channelId); + ps.setString(2, messageId); + ps.setInt(3, directoryId); + ps.setString(4, partName); + ps.setInt(5, partNumber); + ps.setLong(6, partSize); + ps.setString(7, originalFilename); + ps.setString(8, description); + ps.setString(9, mimeType); + ps.setBoolean(10, true); + + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + return rs.getLong("partial_id"); + } + throw new SQLException("Failed to get partial ID"); + } + } + } + + public boolean checkPartialExists(String partName, int directoryId) throws SQLException { + String sql = """ + SELECT COUNT(*) as count + FROM file_partials + WHERE part_name = ? AND directory_id = ? + """; + + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, partName); + ps.setInt(2, directoryId); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + return rs.getInt("count") > 0; + } + return false; + } + } + } + + public boolean deleteFilePartials(String originalFilename, int directoryId) throws SQLException { + String sql = """ + DELETE FROM file_partials + WHERE original_filename = ? AND directory_id = ? + """; + + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, originalFilename); + ps.setInt(2, directoryId); + int rowsAffected = ps.executeUpdate(); + return rowsAffected > 0; + } + } + + public ResultSet getFilePartialsByOriginalFilename(String originalFilename, int directoryId) { + String sql = """ + SELECT + partial_id, + disc_channel_id, + disc_message_id, + part_name, + part_number, + part_size, + original_filename, + mime_type, + uploaded_via_webhook, + created_at + FROM file_partials + WHERE original_filename = ? AND directory_id = ? + ORDER BY part_number ASC + """; + + try { + PreparedStatement ps = conn.prepareStatement(sql); + ps.setString(1, originalFilename); + ps.setInt(2, directoryId); + return ps.executeQuery(); + } catch (SQLException e) { + throw new RuntimeException("Failed to fetch file partials", e); + } + } + } diff --git a/src/main/java/com/pinapelz/FileSystem.java b/src/main/java/com/pinapelz/FileSystem.java index 109b9ed..e86cf6e 100644 --- a/src/main/java/com/pinapelz/FileSystem.java +++ b/src/main/java/com/pinapelz/FileSystem.java @@ -69,4 +69,27 @@ public class FileSystem { public boolean deleteDirectory(int directoryId) throws SQLException { return database.deleteDirectory(directoryId); } + + public long createFilePartial(String channelId, String messageId, int directoryId, + String partName, int partNumber, long partSize, + String originalFilename, String description, String mimeType) throws SQLException { + return database.recordFilePartial(channelId, messageId, directoryId, partName, + partNumber, partSize, originalFilename, description, mimeType); + } + + public ResultSet getFilePartialsByOriginalFilename(String originalFilename, int directoryId) { + return database.getFilePartialsByOriginalFilename(originalFilename, directoryId); + } + + public ResultSet getGroupedPartials(int directoryId, String search) { + return database.getUniqueOriginalFilesFromPartials(directoryId, search); + } + + public boolean deleteFilePartials(String originalFilename, int directoryId) throws SQLException { + return database.deleteFilePartials(originalFilename, directoryId); + } + + public boolean checkPartialNameConstraint(String partName, int directoryId) throws SQLException { + return database.checkPartialExists(partName, directoryId); + } } diff --git a/src/main/java/com/pinapelz/Main.java b/src/main/java/com/pinapelz/Main.java index 40b0feb..f3c2ef1 100644 --- a/src/main/java/com/pinapelz/Main.java +++ b/src/main/java/com/pinapelz/Main.java @@ -31,8 +31,9 @@ public class Main } public static void main(String[] args) throws Exception{ + String pathToWebhooks = readSetting("WEBHOOKS_TXT"); JDA jda = startBot(); - startFrontend(new Retriever(jda), fileSystem); + startFrontend(new Retriever(jda), fileSystem, pathToWebhooks); } diff --git a/src/main/java/com/pinapelz/Retriever.java b/src/main/java/com/pinapelz/Retriever.java index 856dab1..db314fe 100644 --- a/src/main/java/com/pinapelz/Retriever.java +++ b/src/main/java/com/pinapelz/Retriever.java @@ -13,6 +13,10 @@ public class Retriever { } public String getFileUrl(String channelId, String messageId, String fileName) { + return getFileUrl(channelId, messageId, fileName, false); + } + + public String getFileUrl(String channelId, String messageId, String fileName, boolean isWebhookUpload) { TextChannel channel = jda.getTextChannelById(channelId); if (channel == null) { throw new RuntimeException("Channel not found or deleted"); @@ -23,10 +27,10 @@ public class Retriever { for (Message.Attachment file : message.getAttachments()) { if (file.getFileName().equals(fileName)) { - return file.getProxyUrl(); + return isWebhookUpload ? file.getUrl() : file.getProxyUrl(); } } throw new RuntimeException("Matching attachment not found"); } -}
\ No newline at end of file +} diff --git a/src/main/java/com/pinapelz/frontend/App.kt b/src/main/java/com/pinapelz/frontend/App.kt index 22806bd..60a4bea 100644 --- a/src/main/java/com/pinapelz/frontend/App.kt +++ b/src/main/java/com/pinapelz/frontend/App.kt @@ -5,8 +5,22 @@ import com.pinapelz.Retriever import com.pinapelz.FileSystem import java.sql.ResultSet import java.text.SimpleDateFormat +import java.io.File +import java.net.URLEncoder -fun startFrontend(retriever: Retriever, fileSystem: FileSystem) { +fun startFrontend(retriever: Retriever, fileSystem: FileSystem, webhooksFile: String) { + // Initialize WebhookManager if webhooks file exists + val webhookManager = if (File(webhooksFile).exists()) { + try { + WebhookManager(webhooksFile) + } catch (e: Exception) { + println("Warning: Failed to initialize webhook manager: ${e.message}") + null + } + } else { + println("Warning: Webhooks file not found: $webhooksFile") + null + } val app = Javalin.create{}; app.get("/") { ctx -> @@ -18,6 +32,35 @@ fun startFrontend(retriever: Retriever, fileSystem: FileSystem) { ctx.html(generateFileSplitterHtml()) } + /* + { + "success": true, + "parts": [ + { + "id": "unique-part-id-1", + "name": "filename.part001.nitro", + "size": 26214400 + }, + { + "id": "unique-part-id-2", + "name": "filename.part002.nitro", + "size": 26214400 + }, + { + "id": "unique-part-id-3", + "name": "filename.part003.nitro", + "size": 15728640 + } + ] +} + + */ + app.post("/api/split") { ctx -> + val manager = MultipartFileManager(fileSystem, webhookManager) + val result = manager.handleSplitRequest(ctx) + ctx.json(result) + } + app.get("/api/directories") { ctx -> val directories = mutableListOf<Map<String, Any>>() val rs = fileSystem.getAllDirectories() @@ -76,6 +119,21 @@ fun startFrontend(retriever: Retriever, fileSystem: FileSystem) { } rs.close() + val partialsRs = fileSystem.getGroupedPartials(directoryId, search) + while (partialsRs.next()) { + val originalName = partialsRs.getString("original_filename") + val partialDescription = partialsRs.getString("file_description") + files.add(mapOf( + "id" to "partial:$originalName|$directoryId", + "name" to originalName, + "description" to (partialDescription ?: ""), + "size" to formatFileSize(partialsRs.getLong("size")), + "mimeType" to (partialsRs.getString("mime_type") ?: "application/octet-stream"), + "created" to dateFormat.format(partialsRs.getTimestamp("created_at")) + )) + } + partialsRs.close() + val html = generateFileTableHtml(files, search, mimeTypeFilter) ctx.html(html) ctx.header("HX-Trigger", "updateFileCount") @@ -138,17 +196,25 @@ fun startFrontend(retriever: Retriever, fileSystem: FileSystem) { } app.delete("/api/files/{id}") { ctx -> - val fileId = ctx.pathParam("id").toIntOrNull() - if (fileId == null) { - ctx.status(400).json(mapOf( - "success" to false, - "message" to "Invalid file ID" - )) - return@delete - } + val idStr = ctx.pathParam("id") try { - val deleted = fileSystem.deleteFile(fileId) + val deleted = if (idStr.startsWith("partial:")) { + val data = idStr.substring("partial:".length).split("|") + val filename = data[0] + val dirId = data[1].toInt() + fileSystem.deleteFilePartials(filename, dirId) + } else { + val fileId = idStr.toIntOrNull() + if (fileId == null) { + ctx.status(400).json(mapOf( + "success" to false, + "message" to "Invalid file ID" + )) + return@delete + } + fileSystem.deleteFile(fileId) + } if (deleted) { ctx.json(mapOf( "success" to true, @@ -199,12 +265,105 @@ fun startFrontend(retriever: Retriever, fileSystem: FileSystem) { } } + app.get("/api/reassemble") { ctx -> + val filename = ctx.queryParam("filename") ?: throw io.javalin.http.BadRequestResponse("filename required") + val dirId = ctx.queryParam("dir")?.toIntOrNull() ?: throw io.javalin.http.BadRequestResponse("dir id required") + + val rs = fileSystem.getFilePartialsByOriginalFilename(filename, dirId) + data class PartInfo(val channelId: String, val messageId: String, val partName: String, val isWebhook: Boolean) + val parts = mutableListOf<PartInfo>() + var mimeType = "application/octet-stream" + + while (rs.next()) { + parts.add(PartInfo( + rs.getString("disc_channel_id"), + rs.getString("disc_message_id"), + rs.getString("part_name"), + rs.getBoolean("uploaded_via_webhook") + )) + mimeType = rs.getString("mime_type") ?: mimeType + } + rs.close() + + if (parts.isEmpty()) { + ctx.status(404).result("No parts found for $filename") + return@get + } + + ctx.header("Content-Disposition", "attachment; filename=\"$filename\"") + ctx.contentType(mimeType) + + ctx.async { + try { + val outputStream = ctx.res().outputStream + for ((index, part) in parts.withIndex()) { + var success = false + var lastError: Exception? = null + for (attempt in 1..3) { + try { + val url = retriever.getFileUrl(part.channelId, part.messageId, part.partName, part.isWebhook) + println("Fetching part ${index + 1}/${parts.size} from: $url (attempt $attempt)") + + val connection = java.net.URL(url).openConnection() as java.net.HttpURLConnection + connection.requestMethod = "GET" + connection.setRequestProperty("User-Agent", "Mozilla/5.0") + connection.connectTimeout = 30000 + connection.readTimeout = 30000 + + val responseCode = connection.responseCode + if (responseCode == 200) { + connection.inputStream.use { input -> + input.copyTo(outputStream) + } + println("Successfully fetched part ${index + 1}/${parts.size}") + success = true + break + } else { + println("HTTP $responseCode for part ${index + 1} on attempt $attempt") + lastError = Exception("HTTP $responseCode: ${connection.responseMessage}") + if (attempt < 3) Thread.sleep(1000 * attempt.toLong()) + } + } catch (e: Exception) { + println("Error fetching part ${index + 1} on attempt $attempt: ${e.message}") + lastError = e + if (attempt < 3) Thread.sleep(1000 * attempt.toLong()) + } + } + + if (!success) { + ctx.status(500) + ctx.result("Error: Failed to retrieve part ${index + 1} after 3 attempts. ${lastError?.message}") + return@async + } + } + outputStream.flush() + } catch (e: Exception) { + println("Error during file reassembly: ${e.message}") + e.printStackTrace() + } + } + } + app.get("/fetch") { ctx -> - val fileId = ctx.queryParam("fileId") - val fileMetadata = fileSystem.getFileById(Integer.parseInt(fileId)); - print("Retrieving: " + fileMetadata.fileName) - ctx.redirect(retriever.getFileUrl(fileMetadata.channelId.toString(), - fileMetadata.messageId.toString(), fileMetadata.fileName)); + val fileIdStr = ctx.queryParam("fileId") ?: "" + if (fileIdStr.startsWith("partial:")) { + val data = fileIdStr.substring("partial:".length).split("|") + val filename = data[0] + val dirId = data[1] + ctx.redirect("/api/reassemble?filename=${URLEncoder.encode(filename, "UTF-8")}&dir=$dirId") + return@get + } + + try { + val fileMetadata = fileSystem.getFileById(Integer.parseInt(fileIdStr)); + println("Retrieving: " + fileMetadata.fileName) + val fileUrl = retriever.getFileUrl(fileMetadata.channelId.toString(), + fileMetadata.messageId.toString(), fileMetadata.fileName) + ctx.redirect(fileUrl) + } catch (e: Exception) { + println("Failed to retrieve file: ${e.message}") + ctx.status(404).result("Error: File not found or has been deleted from Discord. ${e.message}") + } } app.start(7070) } diff --git a/src/main/java/com/pinapelz/frontend/MultipartFileManager.kt b/src/main/java/com/pinapelz/frontend/MultipartFileManager.kt new file mode 100644 index 0000000..257521d --- /dev/null +++ b/src/main/java/com/pinapelz/frontend/MultipartFileManager.kt @@ -0,0 +1,263 @@ +package com.pinapelz.frontend + +import io.javalin.http.Context +import io.javalin.http.UploadedFile +import com.pinapelz.FileSystem +import java.nio.file.Files +import java.nio.file.Path +import java.util.UUID +import kotlin.math.ceil +import kotlin.math.min + + +sealed class SplitConfig { + data class BySize(val sizeInBytes: Long) : SplitConfig() + data class ByParts(val numParts: Int) : SplitConfig() +} + +data class FilePartMeta( + val id: String, + val name: String, + val size: Long, + val path: Path +) + +data class SplitMetadata( + val originalFilename: String, + val totalSize: Long, + val partCount: Int, + val parts: List<FilePartMeta> +) + +data class SplitFileResult( + val directory: Path, + val metadata: SplitMetadata, + val uploadResults: List<WebhookUploadResult>? = null +) + +data class ApiSplitResponse( + val success: Boolean, + val message: String? = null, + val parts: List<ApiPartInfo>? = null +) + +data class ApiPartInfo( + val id: String, + val name: String, + val size: Long, + val uploaded: Boolean = false, + val channelId: String? = null, + val messageId: String? = null +) + +class MultipartFileManager( + private val fileSystem: FileSystem? = null, + private val webhookManager: WebhookManager? = null +) { + fun handleSplitRequest(ctx: Context): ApiSplitResponse { + try { + val uploadedFile = ctx.uploadedFile("file") + ?: return ApiSplitResponse(false, "No file was uploaded") + + val splitMethod = ctx.formParam("split-method") ?: "size" + val useWebhook = ctx.formParam("use-webhook")?.toBoolean() ?: false + val directoryId = ctx.formParam("directory-id")?.toIntOrNull() ?: 1 + var filePrefix = ctx.formParam("file-prefix")?.takeIf { it.isNotBlank() } + ?: uploadedFile.filename().substringBeforeLast(".") + val fileDescription = ctx.formParam("file-description") ?: "" + + // Replace spaces with underscores in prefix + filePrefix = filePrefix.replace(" ", "_") + + // Validate prefix doesn't contain spaces + if (filePrefix.contains(" ")) { + return ApiSplitResponse(false, "File prefix cannot contain spaces. Spaces have been replaced with underscores.") + } + + val splitConfig = when { + useWebhook -> { + SplitConfig.BySize(10 * 1024 * 1024L) // Discord file limit + } + splitMethod == "size" -> { + val partSize = ctx.formParam("part-size")?.toLongOrNull() ?: 25L + val sizeUnit = ctx.formParam("size-unit") ?: "MB" + val sizeInBytes = when (sizeUnit) { + "KB" -> partSize * 1024 + "GB" -> partSize * 1024 * 1024 * 1024 + else -> partSize * 1024 * 1024 // MB default + } + SplitConfig.BySize(sizeInBytes) + } + else -> { + val numParts = ctx.formParam("num-parts")?.toIntOrNull() ?: 5 + SplitConfig.ByParts(numParts) + } + } + + val result = splitFile(uploadedFile, config=splitConfig, prefix=filePrefix) + + if (useWebhook && webhookManager != null && fileSystem != null) { + return handleWebhookUpload(result, directoryId, uploadedFile.filename(), fileDescription) + } else { + val apiParts = result.metadata.parts.map { part -> + ApiPartInfo( + id = part.id, + name = part.name, + size = part.size, + uploaded = false + ) + } + return ApiSplitResponse(true, "File split successfully", apiParts) + } + + } catch (e: Exception) { + e.printStackTrace() + return ApiSplitResponse(false, "Failed to split file: ${e.message}") + } + } + + private fun handleWebhookUpload(splitResult: SplitFileResult, directoryId: Int, originalFilename: String, description: String): ApiSplitResponse { + if (webhookManager == null || fileSystem == null) { + return ApiSplitResponse(false, "Webhook manager or file system not configured") + } + + try { + for (part in splitResult.metadata.parts) { + if (fileSystem.checkPartialNameConstraint(part.name, directoryId)) { + return ApiSplitResponse( + false, + "File part '${part.name}' already exists in this directory. Please use a different prefix or delete the existing parts." + ) + } + } + } catch (e: Exception) { + println("Failed to check for existing parts: ${e.message}") + return ApiSplitResponse(false, "Failed to validate file parts: ${e.message}") + } + + val uploadResults = mutableListOf<ApiPartInfo>() + var uploadedCount = 0 + + try { + for ((index, part) in splitResult.metadata.parts.withIndex()) { + println("Uploading part ${index + 1}/${splitResult.metadata.parts.size}: ${part.name}") + + val uploadResult = webhookManager.uploadFile(part.path) + + if (uploadResult.success && uploadResult.channelId != null && uploadResult.messageId != null) { + try { + val partialId = fileSystem.createFilePartial( + uploadResult.channelId, + uploadResult.messageId, + directoryId, + part.name, + index + 1, + part.size, + originalFilename, + description, + "application/octet-stream" + ) + + uploadResults.add(ApiPartInfo( + id = part.id, + name = part.name, + size = part.size, + uploaded = true, + channelId = uploadResult.channelId, + messageId = uploadResult.messageId + )) + uploadedCount++ + + println("Successfully uploaded and recorded part: ${part.name} (partial_id: $partialId)") + } catch (e: Exception) { + println("Failed to record part in database: ${e.message}") + uploadResults.add(ApiPartInfo( + id = part.id, + name = part.name, + size = part.size, + uploaded = false + )) + } + } else { + println("Failed to upload part ${part.name}: ${uploadResult.error}") + uploadResults.add(ApiPartInfo( + id = part.id, + name = part.name, + size = part.size, + uploaded = false + )) + } + } + try { + splitResult.directory.toFile().deleteRecursively() + } catch (e: Exception) { + println("Failed to clean up temporary files: ${e.message}") + } + + val message = if (uploadedCount == splitResult.metadata.parts.size) { + "All ${uploadedCount} parts uploaded successfully" + } else { + "Uploaded ${uploadedCount}/${splitResult.metadata.parts.size} parts successfully" + } + + return ApiSplitResponse( + success = uploadedCount > 0, + message = message, + parts = uploadResults + ) + + } catch (e: Exception) { + return ApiSplitResponse( + false, + "Upload process failed: ${e.message}", + uploadResults + ) + } + } + } + + private fun splitFile( uploadedFile: UploadedFile, config: SplitConfig, prefix: String, + workingDir: Path = Files.createTempDirectory("split-${prefix}-") ): SplitFileResult { + val fileData = uploadedFile.content().readBytes() + val fileSize = fileData.size.toLong() + val partsMeta = mutableListOf<FilePartMeta>() + + when (config) { + is SplitConfig.BySize -> { + val partSize = config.sizeInBytes - (16 * 1024); + val numParts = ceil(fileSize.toDouble() / partSize.toDouble()).toInt() + println("Splitting file: ${fileSize} bytes into ${numParts} parts of max ${partSize} bytes each") + + for (partIndex in 0 until numParts) { + val startIndex = (partIndex * partSize).toInt() + val endIndex = min(startIndex + partSize.toInt(), fileData.size) + val partBytes = fileData.sliceArray(startIndex until endIndex) + println("Created part ${partIndex + 1}: ${partBytes.size} bytes") + + val partId = UUID.randomUUID().toString() + val partName = "${prefix}.part${String.format("%03d", partIndex + 1)}.nitro" + val partPath = workingDir.resolve(partName) + Files.write(partPath, partBytes) + partsMeta += FilePartMeta( + id = partId, + name = partName, + size = partBytes.size.toLong(), + path = partPath + ) + } + } + + is SplitConfig.ByParts -> { // TODO: stubbed not yet implemented + } + } + val metadata = SplitMetadata( + originalFilename = uploadedFile.filename(), + totalSize = fileSize, + partCount = partsMeta.size, + parts = partsMeta + ) + return SplitFileResult( + directory = workingDir, + metadata = metadata + ) + } diff --git a/src/main/java/com/pinapelz/frontend/WebhookManager.kt b/src/main/java/com/pinapelz/frontend/WebhookManager.kt new file mode 100644 index 0000000..8b35804 --- /dev/null +++ b/src/main/java/com/pinapelz/frontend/WebhookManager.kt @@ -0,0 +1,165 @@ +package com.pinapelz.frontend + +import com.google.gson.Gson +import com.google.gson.JsonObject +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.time.Instant +import java.util.concurrent.TimeUnit +import kotlin.math.max + +data class WebhookUploadResult( + val success: Boolean, + val channelId: String? = null, + val messageId: String? = null, + val error: String? = null +) + +class WebhookManager(webhooksFilePath: String) { + private val webhooks: List<String> + private val webhookCooldowns = mutableMapOf<String, Long>() + private var currentWebhookIndex = 0 + private val cooldownPeriodMs = 1000L + private val gson = Gson() + + private val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .build() + + init { + val webhooksFile = File(webhooksFilePath) + if (!webhooksFile.exists()) { + throw IllegalArgumentException("Webhooks file not found: $webhooksFilePath") + } + + webhooks = webhooksFile.readLines() + .map { it.trim() } + .filter { it.isNotEmpty() && !it.startsWith("#") } + + if (webhooks.isEmpty()) { + throw IllegalArgumentException("No valid webhooks found in file: $webhooksFilePath") + } + + println("Loaded ${webhooks.size} webhooks from $webhooksFilePath") + } + + private fun getNextAvailableWebhook(): String? { + val currentTime = System.currentTimeMillis() + val startIndex = currentWebhookIndex + + do { + val webhook = webhooks[currentWebhookIndex] + val lastUsed = webhookCooldowns[webhook] ?: 0 + val timeSinceLastUse = currentTime - lastUsed + + if (timeSinceLastUse >= cooldownPeriodMs) { + return webhook + } + + currentWebhookIndex = (currentWebhookIndex + 1) % webhooks.size + } while (currentWebhookIndex != startIndex) + val nextAvailableTime = webhookCooldowns.values.minOrNull() ?: 0 + val waitTime = max(0, (nextAvailableTime + cooldownPeriodMs) - currentTime) + + if (waitTime > 0) { + Thread.sleep(waitTime) + return getNextAvailableWebhook() + } + + return null + } + + fun uploadFile(filePath: Path): WebhookUploadResult { + val webhook = getNextAvailableWebhook() + ?: return WebhookUploadResult(false, error = "No available webhooks") + + val file = filePath.toFile() + if (!file.exists()) { + return WebhookUploadResult(false, error = "File does not exist: $filePath") + } + + try { + val mimeType = Files.probeContentType(filePath) ?: "application/octet-stream" + val requestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart( + "file", + file.name, + file.asRequestBody(mimeType.toMediaTypeOrNull()) + ) + .build() + + val request = Request.Builder() + .url(webhook) + .post(requestBody) + .build() + + webhookCooldowns[webhook] = System.currentTimeMillis() + currentWebhookIndex = (currentWebhookIndex + 1) % webhooks.size + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + return WebhookUploadResult( + false, + error = "HTTP ${response.code}: ${response.message}" + ) + } + + val responseBody = response.body?.string() + if (responseBody.isNullOrEmpty()) { + return WebhookUploadResult(false, error = "Empty response from Discord") + } + + try { + val jsonObject = gson.fromJson(responseBody, JsonObject::class.java) + val channelId = jsonObject.get("channel_id")?.asString + val messageId = jsonObject.get("id")?.asString + + println("Discord webhook response - Channel ID: $channelId, Message ID: $messageId") + + if (channelId != null && messageId != null) { + return WebhookUploadResult( + success = true, + channelId = channelId, + messageId = messageId + ) + } else { + println("Failed to extract IDs from response: $responseBody") + return WebhookUploadResult( + false, + error = "Could not extract channel/message IDs from response" + ) + } + } catch (e: Exception) { + println("Failed to parse JSON response: ${e.message}") + println("Response was: $responseBody") + return WebhookUploadResult( + false, + error = "Failed to parse Discord response: ${e.message}" + ) + } + } + } catch (e: IOException) { + return WebhookUploadResult(false, error = "Network error: ${e.message}") + } catch (e: Exception) { + return WebhookUploadResult(false, error = "Unexpected error: ${e.message}") + } + } + + fun getWebhookCount(): Int = webhooks.size + + fun getAvailableWebhookCount(): Long { + val currentTime = System.currentTimeMillis() + return webhooks.count { webhook -> + val lastUsed = webhookCooldowns[webhook] ?: 0 + (currentTime - lastUsed) >= cooldownPeriodMs + }.toLong() + } +} |
