diff --git a/build.gradle b/build.gradle index 96bc6d1..5707d6c 100644 --- a/build.gradle +++ b/build.gradle @@ -44,11 +44,16 @@ dependencies { implementation('net.synedra:validatorfx:0.5.0') { exclude group: 'org.openjfx' } + //For upload files + implementation 'org.slf4j:slf4j-simple:2.0.13' + implementation 'com.sparkjava:spark-core:2.9.4' + implementation 'com.mpatric:mp3agic:0.9.1' //for mp3 implementation 'org.json:json:20231013' implementation 'org.kordamp.ikonli:ikonli-javafx:12.3.1' implementation 'org.kordamp.bootstrapfx:bootstrapfx-core:0.4.0' implementation('eu.hansolo:tilesfx:21.0.3') { exclude group: 'org.openjfx' + } test { diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 0024ec0..274c4f7 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -11,6 +11,10 @@ module org.to.telegramfinalproject { requires eu.hansolo.tilesfx; requires org.json; requires java.sql; + requires java.desktop; + requires spark.core; + requires javax.servlet.api; + requires mp3agic; opens org.to.telegramfinalproject to javafx.fxml; exports org.to.telegramfinalproject; exports org.to.telegramfinalproject.Client; diff --git a/src/main/java/org/to/telegramfinalproject/Client/ActionHandler.java b/src/main/java/org/to/telegramfinalproject/Client/ActionHandler.java index b2b16ed..6870747 100644 --- a/src/main/java/org/to/telegramfinalproject/Client/ActionHandler.java +++ b/src/main/java/org/to/telegramfinalproject/Client/ActionHandler.java @@ -2,22 +2,18 @@ package org.to.telegramfinalproject.Client; import org.json.JSONArray; import org.json.JSONObject; -import org.to.telegramfinalproject.Database.PrivateChatDatabase; -import org.to.telegramfinalproject.Database.ContactDatabase; import org.to.telegramfinalproject.Models.ChatEntry; import org.to.telegramfinalproject.Models.ContactEntry; import org.to.telegramfinalproject.Models.SearchRequestModel; -import org.to.telegramfinalproject.Models.SearchResultModel; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.PrintWriter; +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.time.LocalDateTime; import java.util.*; -import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.TimeUnit; public class ActionHandler { @@ -26,6 +22,7 @@ public class ActionHandler { private final Scanner scanner; public static volatile boolean forceExitChat = false; public static ActionHandler instance; + private final DataOutputStream outBin; //use for UI private volatile String lastStatus = "error"; // success | error @@ -57,12 +54,12 @@ public class ActionHandler { } } - public ActionHandler(PrintWriter out, BufferedReader in, Scanner scanner) { + public ActionHandler(PrintWriter out, BufferedReader in, DataOutputStream outBin, Scanner scanner) { this.out = out; this.in = in; + this.outBin = outBin; this.scanner = scanner; ActionHandler.instance = this; - } public void login(String username , String password){ @@ -545,6 +542,12 @@ public class ActionHandler { case "register": Session.currentUser = response.getJSONObject("data"); + //for downloaded medias + UUID accountId = UUID.fromString(Session.currentUser.getString("internal_uuid")); + Session.downloadsIndex = new DownloadsIndex(accountId); + + + JSONArray chatListJson = Session.currentUser.getJSONArray("chat_list"); JSONArray Archived = Session.currentUser.getJSONArray("archived_chat_list"); JSONArray Active = Session.currentUser.getJSONArray("active_chat_list"); @@ -659,7 +662,6 @@ public class ActionHandler { - Session.activeChats = activeChats; Session.archivedChats = archivedChats; Session.chatList = chatList; @@ -1469,7 +1471,7 @@ public class ActionHandler { JSONObject m = messages.getJSONObject(i); String senderId = m.getString("sender_id"); String senderName = m.optString("sender_name", "Other"); - String content = m.getString("content"); + String content = m.optString("content", ""); String time = m.getString("send_at"); String label = senderId.equals(Session.currentUser.getString("internal_uuid")) ? "You" : senderName; @@ -1691,7 +1693,7 @@ public class ActionHandler { String input = scanner.nextLine().trim(); switch (input) { - case "1" -> sendMessage(chatId, "private"); + case "1" -> sendMessageInteractive(chatId, "private"); case "2" -> { viewMessagesInChat(chat); } case "3" -> { return false; } default -> System.out.println("Invalid choice."); @@ -1710,7 +1712,7 @@ public class ActionHandler { String input = scanner.nextLine().trim(); switch (input) { - case "1" -> sendMessage(chatId, "private"); + case "1" -> sendMessageInteractive(chat.getId(), "private"); case "2" -> toggleBlock(chat.getOtherUserId()); case "3" -> { deleteChat(chatId, false); return true; } case "4" -> { deleteChat(chatId, true); return true; } @@ -1806,7 +1808,7 @@ public class ActionHandler { String input = scanner.nextLine(); switch (input) { - case "1" -> sendMessage(chat.getId(), "group"); + case "1" -> sendMessageInteractive(chat.getId(), "group"); case "2" -> viewGroupMembers(chat.getId()); case "3" -> { if (isOwner || (isAdmin && perms.optBoolean("can_add_members", false))) @@ -1933,7 +1935,7 @@ public class ActionHandler { switch (input) { case "1" -> { if (isOwner || (isAdmin && perms.optBoolean("can_post", false))) { - sendMessage(chat.getId(), "channel"); + sendMessageInteractive(chat.getId(), "channel"); } else { System.out.println("❌ You don't have permission to post."); } @@ -3673,82 +3675,135 @@ public class ActionHandler { - public void sendMessage(UUID chatId, String receiverType) { - Scanner scanner = new Scanner(System.in); +// public void sendMessage(UUID chatId, String receiverType) { +// Scanner scanner = new Scanner(System.in); +// +// System.out.print("Enter your message: "); +// String content = scanner.nextLine(); +// +// System.out.print("Enter message type (TEXT / IMAGE / VIDEO / FILE): "); +// String messageType = scanner.nextLine().toUpperCase(); +// Set allowedTypes = Set.of("TEXT", "IMAGE", "VIDEO", "FILE"); +// while (!allowedTypes.contains(messageType)) { +// System.out.print("❌ Invalid type. Try again (TEXT / IMAGE / VIDEO / FILE): "); +// messageType = scanner.nextLine().toUpperCase(); +// } +// +// JSONArray attachmentsArray = new JSONArray(); +// System.out.print("Do you want to attach files? (yes/no): "); +// if (scanner.nextLine().equalsIgnoreCase("yes")) { +// while (true) { +// System.out.print("File URL: "); +// String fileUrl = scanner.nextLine(); +// System.out.print("File Type (IMAGE / VIDEO / FILE): "); +// String fileType = scanner.nextLine().toUpperCase(); +// +// JSONObject fileJson = new JSONObject(); +// fileJson.put("file_url", fileUrl); +// fileJson.put("file_type", fileType); +// attachmentsArray.put(fileJson); +// +// System.out.print("Add another file? (yes/no): "); +// if (!scanner.nextLine().equalsIgnoreCase("yes")) break; +// } +// } +// +// +// +// JSONObject messageJson = new JSONObject(); +// messageJson.put("action", "send_message"); +// messageJson.put("receiver_type", receiverType); +// messageJson.put("receiver_id", chatId.toString()); +// messageJson.put("content", content); +// messageJson.put("message_type", messageType); +// +// if (!attachmentsArray.isEmpty()) { +// messageJson.put("attachments", attachmentsArray); +// } +// +// JSONObject response = sendWithResponse(messageJson); +// if (response != null && response.getString("status").equals("success")) { +// System.out.println("✅ Message sent! ID: " + response.getJSONObject("data").getString("message_id")); +// } else { +// System.out.println("❌ Failed to send message: " + (response != null ? response.getString("message") : "No response")); +// } +// } - System.out.print("Enter your message: "); - String content = scanner.nextLine(); - System.out.print("Enter message type (TEXT / IMAGE / VIDEO / FILE / AUDIO): "); - String messageType = scanner.nextLine().toUpperCase(); - Set allowedTypes = Set.of("TEXT", "IMAGE", "VIDEO", "FILE"); - while (!allowedTypes.contains(messageType)) { - System.out.print("❌ Invalid type. Try again (TEXT / IMAGE / VIDEO / FILE / AUDIO): "); - messageType = scanner.nextLine().toUpperCase(); - } - - JSONArray attachmentsArray = new JSONArray(); - System.out.print("Do you want to attach files? (yes/no): "); - if (scanner.nextLine().equalsIgnoreCase("yes")) { - while (true) { - System.out.print("File URL: "); - String fileUrl = scanner.nextLine(); - - // URL validation - if (fileUrl.isEmpty()) { - System.out.print("URL can not be empty. Try again."); - continue; - } - - if (fileUrl.contains(" ")) { - System.out.println("URL cannot contain spaces. Try again."); - continue; - } - - if (!fileUrl.isEmpty() && !fileUrl.matches("^(http|https)://.*$")) { - System.out.println("Invalid URL format. Please enter a valid HTTP/HTTPS link."); - continue; - } - - System.out.print("File Type (IMAGE / VIDEO / FILE / AUDIO): "); - String fileType = scanner.nextLine().toUpperCase(); - - Set allowedFileTypes = Set.of("TEXT", "IMAGE", "VIDEO", "FILE"); - while (!allowedFileTypes.contains(fileType)) { - System.out.print("❌ Invalid type. Try again (IMAGE / VIDEO / FILE / AUDIO): "); - fileType = scanner.nextLine().toUpperCase(); - } - - JSONObject fileJson = new JSONObject(); - fileJson.put("file_url", fileUrl); - fileJson.put("file_type", fileType); - attachmentsArray.put(fileJson); - - System.out.print("Add another file? (yes/no): "); - if (!scanner.nextLine().equalsIgnoreCase("yes")) break; - } - } - - JSONObject messageJson = new JSONObject(); - messageJson.put("action", "send_message"); - messageJson.put("receiver_type", receiverType); - messageJson.put("receiver_id", chatId.toString()); - messageJson.put("content", content); - messageJson.put("message_type", messageType); - - if (!attachmentsArray.isEmpty()) { - messageJson.put("attachments", attachmentsArray); - } - - JSONObject response = sendWithResponse(messageJson); - if (response != null && response.getString("status").equals("success")) { - System.out.println("✅ Message sent! ID: " + response.getJSONObject("data").getString("message_id")); - } else { - System.out.println("❌ Failed to send message: " + (response != null ? response.getString("message") : "No response")); - } - } - - public void refreshContactList() { +// public void sendMessage(UUID chatId, String receiverType) { +// Scanner scanner = new Scanner(System.in); +// +// System.out.print("Enter your message (leave empty if file only): "); +// String content = scanner.nextLine(); +// +// System.out.print("Enter message type (TEXT / IMAGE / AUDIO / FILE / GIF): "); +// String messageType = scanner.nextLine().toUpperCase(); +// Set allowedTypes = Set.of("TEXT", "IMAGE", "AUDIO", "FILE", "GIF"); +// while (!allowedTypes.contains(messageType)) { +// System.out.print("❌ Invalid type. Try again (TEXT / IMAGE / AUDIO / FILE / GIF): "); +// messageType = scanner.nextLine().toUpperCase(); +// } +// +// JSONArray attachmentsArray = new JSONArray(); +// System.out.print("Attach files? (yes/no): "); +// if (scanner.nextLine().equalsIgnoreCase("yes")) { +// while (true) { +// System.out.println("Paste the JSON you got from /upload (or leave empty to enter minimal fields):"); +// String jsonLine = scanner.nextLine().trim(); +// +// JSONObject fileJson; +// if (!jsonLine.isEmpty()) { +// // انتظار خروجی کامل /upload +// fileJson = new JSONObject(jsonLine); +// // اگه خروجی /upload تو ریشه‌ست، تبدیلش کن به ساختار attachment +// fileJson = new JSONObject() +// .put("file_url", fileJson.optString("file_url", "")) +// .put("file_type", fileJson.optString("file_type", "FILE")) +// .put("file_name", fileJson.optString("file_name", "")) +// .put("file_size", fileJson.optLong("file_size", 0)) +// .put("mime_type", fileJson.optString("mime_type", "")) +// .put("width", fileJson.isNull("width") ? JSONObject.NULL : fileJson.optInt("width")) +// .put("height", fileJson.isNull("height") ? JSONObject.NULL : fileJson.optInt("height")) +// .put("duration_seconds", fileJson.isNull("duration_seconds") ? JSONObject.NULL : fileJson.optInt("duration_seconds")) +// .put("thumbnail_url", fileJson.isNull("thumbnail_url") ? JSONObject.NULL : fileJson.optString("thumbnail_url", null)); +// } else { +// // ورودی حداقلی +// System.out.print("File URL: "); +// String fileUrl = scanner.nextLine(); +// System.out.print("File Type (IMAGE / AUDIO / FILE / GIF): "); +// String fileType = scanner.nextLine().toUpperCase(); +// +// fileJson = new JSONObject(); +// fileJson.put("file_url", fileUrl); +// fileJson.put("file_type", fileType); +// } +// +// attachmentsArray.put(fileJson); +// +// System.out.print("Add another file? (yes/no): "); +// if (!scanner.nextLine().equalsIgnoreCase("yes")) break; +// } +// } +// +// JSONObject messageJson = new JSONObject(); +// messageJson.put("action", "send_message"); +// messageJson.put("receiver_type", receiverType); // "private"/"group"/"channel" +// messageJson.put("receiver_id", chatId.toString()); // در private = chat_id +// messageJson.put("content", content); +// messageJson.put("message_type", messageType); +// if (attachmentsArray.length() > 0) { +// messageJson.put("attachments", attachmentsArray); +// } +// +// JSONObject response = sendWithResponse(messageJson); +// if (response != null && response.getString("status").equals("success")) { +// System.out.println("✅ Message sent! ID: " + response.getJSONObject("data").getString("message_id")); +// } else { +// System.out.println("❌ Failed to send message: " + (response != null ? response.optString("message","No message") : "No response")); +// } +// } +// + private void refreshContactList() { JSONObject req = new JSONObject(); req.put("action", "get_contact_list"); req.put("user_id", Session.currentUser.getString("user_id")); @@ -3860,6 +3915,18 @@ public class ActionHandler { replyLabel = "↪️ Reply to " + repliedSender + ": \"" + repliedContent + "\""; } + JSONArray atts = msg.optJSONArray("attachments"); + if (atts != null && atts.length() > 0) { + System.out.println(" 📎 " + atts.length() + " attachment(s)"); + for (int a = 0; a < atts.length(); a++) { + JSONObject att = atts.getJSONObject(a); + String fn = att.optString("file_name", "(unnamed)"); + long sz = att.optLong("file_size", 0); + System.out.printf(" - #%d %s (%s)\n", a + 1, fn, humanSize(sz)); + } + } + + JSONArray reactions = msg.optJSONArray("reactions"); if (reactions != null && !reactions.isEmpty()) { System.out.print(" 💬 Reactions: "); @@ -3894,7 +3961,7 @@ public class ActionHandler { } if(input.equalsIgnoreCase("S")){ - sendMessage(chat.getId(), chat.getType()); + sendMessageInteractive(chat.getId(), chat.getType()); } try { int index = Integer.parseInt(input); @@ -3909,6 +3976,10 @@ public class ActionHandler { boolean isSender = senderId.toString().equals(Session.currentUser.getString("internal_uuid")); boolean isChannel = chat.getType().equals("channel"); boolean isOwnerOrAdmin = chat.isOwner() || chat.isAdmin(); + //for media + JSONArray atts = selected.optJSONArray("attachments"); + boolean hasAttachments = (atts != null && atts.length() > 0); + System.out.println("\n🎯 Selected message by " + selected.getString("sender_name")); @@ -3934,6 +4005,9 @@ public class ActionHandler { System.out.println("5. Delete"); } } + if (hasAttachments) { + System.out.println("D. Download attachment"); + } System.out.println("0. Back to message list"); System.out.print("➤ Select an action: "); @@ -3966,6 +4040,15 @@ public class ActionHandler { System.out.println("❌ You are not allowed to delete this message."); } case "0" -> {} + + case "D", "d" -> { + if (hasAttachments) { + downloadAttachmentFlow(chat, selected); + } else { + System.out.println("🚫 No attachments to download."); + } + } + default -> System.out.println("❌ Invalid option."); } @@ -3976,6 +4059,122 @@ public class ActionHandler { } public void editMessage(UUID messageId) { + private void downloadAttachmentFlow(ChatEntry chat, JSONObject msg) { + JSONArray atts = msg.optJSONArray("attachments"); + if (atts == null || atts.length() == 0) { + System.out.println("🚫 No attachments."); + return; + } + + int idx = 0; + if (atts.length() > 1) { + System.out.print("Which attachment [1.." + atts.length() + "]? "); + try { + String ans = scanner.nextLine().trim(); + if (!ans.isEmpty()) { + int n = Integer.parseInt(ans); + if (n >= 1 && n <= atts.length()) idx = n - 1; + } + } catch (Exception ignored) { idx = 0; } + } + + JSONObject att = atts.getJSONObject(idx); + String mediaKeyStr = att.optString("media_key", ""); + if (mediaKeyStr.isBlank()) { + System.out.println("❌ Attachment missing media_key."); + return; + } + + UUID mediaKey = UUID.fromString(mediaKeyStr); + String rawName = att.optString("file_name", mediaKey.toString()); + String fileName = sanitizeFileName(rawName); + long declaredSize = att.optLong("file_size", 0L); + + // ~/Downloads/TeleSock/// + String accFolder = accountFolderName(); + String chatFolder = chatFolderName(chat); + Path saveDir = Paths.get(System.getProperty("user.home"), + "Downloads", "TeleSock", accFolder, chatFolder); + + System.out.println("👤 AccountFolder = " + accFolder); + System.out.println("💬 ChatFolder = " + chatFolder); + System.out.println("📁 SaveDir = " + saveDir); + + try { Files.createDirectories(saveDir); } + catch (IOException e) { + System.out.println("❌ Cannot create folder: " + saveDir + " -> " + e.getMessage()); + return; + } + + DownloadsIndex di = Session.downloadsIndex; + if (di != null) { + Path existing = di.find(mediaKey); + if (existing != null) { + System.out.println("✅ Already downloaded: " + existing); + return; + } + } + Path target = uniquePath(saveDir, fileName); + + TelegramClient.mediaBusy.set(true); + try { + Path saved = TelegramClient.getDownloader() + .download(mediaKey, saveDir, target.getFileName().toString()); + + long sizeToRecord = declaredSize > 0 ? declaredSize : Files.size(saved); + if (di != null) di.put(mediaKey, saved, sizeToRecord); + + System.out.println("✅ Saved to: " + saved + " (" + humanSize(sizeToRecord) + ")"); + } catch (Exception ex) { + System.out.println("❌ Download failed: " + ex.getMessage()); + } finally { + TelegramClient.mediaBusy.set(false); + } + } + + + + + private static Path uniquePath(Path dir, String fileName) { + Path p = dir.resolve(fileName); + if (!Files.exists(p)) return p; + + String name = fileName; + String ext = ""; + int dot = fileName.lastIndexOf('.'); + if (dot > 0 && dot < fileName.length()-1) { + name = fileName.substring(0, dot); + ext = fileName.substring(dot); // includes dot + } + int i = 1; + while (true) { + Path cand = dir.resolve(String.format("%s (%d)%s", name, i, ext)); + if (!Files.exists(cand)) return cand; + i++; + } + } + + private static String sanitizeFileName(String s) { + s = s.replace("\\", "/"); + if (s.contains("/")) s = s.substring(s.lastIndexOf('/') + 1); + s = s.replaceAll("[\\\\/:*?\"<>|]", "_"); + if (s.equals(".") || s.equals("..") || s.isBlank()) s = "file"; + return s; + } + + private static String humanSize(long b) { + if (b <= 0) return "0 B"; + String[] u = {"B","KB","MB","GB","TB"}; + int i = (int) Math.floor(Math.log(b) / Math.log(1024)); + if (i < 0) i = 0; + if (i >= u.length) i = u.length - 1; + double v = b / Math.pow(1024, i); + return String.format("%.1f %s", v, u[i]); + } + + + + private void editMessage(UUID messageId) { System.out.print("📝 Enter new content: "); String newContent = scanner.nextLine().trim(); @@ -4191,6 +4390,183 @@ public class ActionHandler { } + + public void sendMessageInteractive(UUID receiverId, String receiverType) { + Scanner sc = new Scanner(System.in); + + System.out.print("Type (TEXT / IMAGE / AUDIO): "); + String type = sc.nextLine().trim().toUpperCase(); + while (!Set.of("TEXT","IMAGE","AUDIO").contains(type)) { + System.out.print("❌ Invalid. Try (TEXT / IMAGE / AUDIO): "); + type = sc.nextLine().trim().toUpperCase(); + } + + System.out.print("Text (optional for media; empty = no caption): "); + String text = sc.nextLine(); + + if ("TEXT".equals(type)) { + sendTextMessage(receiverId, receiverType, text); + } else { + System.out.print("File path: "); + String path = sc.nextLine().trim(); + File f = new File(path); + if (!f.isFile()) { + System.out.println("❌ File not found"); + return; + } + try { + sendMediaMessage(receiverId, receiverType, type, f, text); + } catch (Exception e) { + e.printStackTrace(); + System.out.println("❌ Media send failed: " + e.getMessage()); + } + } + } + + private void sendTextMessage(UUID receiverId, String receiverType, String content) { + JSONObject req = new JSONObject() + .put("action", "send_message") + .put("receiver_type", receiverType) + .put("receiver_id", receiverId.toString()) + .put("message_type", "TEXT") + .put("content", content == null ? "" : content); + + JSONObject resp = sendWithResponse(req); + if (resp != null && "success".equalsIgnoreCase(resp.optString("status"))) { + System.out.println("✅ Sent. id=" + resp.optJSONObject("data").optString("message_id","")); + } else { + System.out.println("❌ Failed: " + (resp != null ? resp.optString("message") : "no response")); + } + } + + + public void sendMediaMessage(UUID receiverId, String receiverType, String type /* IMAGE/AUDIO */, File file, String caption) { + if (file == null) { + System.out.println("❌ File is null"); + return; + } + if (!file.exists()) { + System.out.println("❌ File not found: " + file.getAbsolutePath()); + return; + } + if (file.isDirectory()) { + System.out.println("❌ Path is a directory, expected a file: " + file.getAbsolutePath()); + return; + } + + final UUID messageId = UUID.randomUUID(); + + try { + String mime = detectMime(file, type.toUpperCase()); + if (mime == null) mime = type.equalsIgnoreCase("IMAGE") ? "image/*" : "audio/*"; + + JSONObject header = new JSONObject() + .put("message_id", messageId.toString()) + .put("sender_id", TelegramClient.loggedInUserId.toString()) + .put("receiver_type", receiverType) // private|group|channel + .put("receiver_id", receiverId.toString()) + .put("message_type", type.toUpperCase()) // IMAGE | AUDIO + .put("file_name", file.getName()) + .put("mime_type", mime) + .put("text", caption == null ? "" : caption); + + byte[] headerBytes = header.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + long contentLen = file.length(); + + BlockingQueue q = new LinkedBlockingQueue<>(1); + TelegramClient.pendingResponses.put(messageId.toString(), q); + + try { + + outBin.write("MEDIA\n".getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + outBin.flush(); + + // 2) binary frame: magic + headerLen + header + contentLen + content + outBin.writeInt(0x4D444D31); // "MDM1" + outBin.writeInt(headerBytes.length); // headerLen (int) + outBin.write(headerBytes); // header + outBin.writeLong(contentLen); // contentLen (long) + + try (InputStream fis = new BufferedInputStream(new FileInputStream(file))) { + byte[] buf = new byte[8192]; + int n; + while ((n = fis.read(buf)) != -1) { + outBin.write(buf, 0, n); + } + } + outBin.flush(); + + JSONObject ack = q.poll(20, java.util.concurrent.TimeUnit.SECONDS); + if (ack == null) { + System.out.println("❌ Media ACK timeout for " + messageId); + return; + } + + String status = ack.optString("status", "error"); + if ("success".equalsIgnoreCase(status)) { + System.out.println("✅ Media sent. id=" + ack.optString("message_id") + + " url=" + ack.optString("file_url")); + } else { + System.out.println("❌ Media failed: " + ack.optString("message")); + } + + } finally { + TelegramClient.pendingResponses.remove(messageId.toString()); + } + + } catch (Exception e) { + e.printStackTrace(); + System.out.println("❌ sendMediaMessage error: " + e.getMessage()); + } + } + + + private static String detectMime(File f, String typeUpper /* IMAGE or AUDIO */) { + try { + String m = java.nio.file.Files.probeContentType(f.toPath()); + if (m != null) return m; + } catch (Exception ignored) {} + String name = f.getName().toLowerCase(); + if (name.endsWith(".png")) return "image/png"; + if (name.endsWith(".jpg") || name.endsWith(".jpeg")) return "image/jpeg"; + if (name.endsWith(".gif")) return "image/gif"; + if (name.endsWith(".mp3")) return "audio/mpeg"; + if (name.endsWith(".wav")) return "audio/wav"; + if (name.endsWith(".ogg")) return "audio/ogg"; + return typeUpper.equals("IMAGE") ? "image/*" : "audio/*"; + } + + + + private static String safeName(String s) { + if (s == null) return "unknown"; + s = s.replace("\\", "/"); + if (s.contains("/")) s = s.substring(s.lastIndexOf('/') + 1); + s = s.replaceAll("[\\\\/:*?\"<>|]", "_").trim(); + if (s.isEmpty() || s.equals(".") || s.equals("..")) s = "unknown"; + return s; + } + + private static String accountFolderName() { + JSONObject me = Session.currentUser; + String acc = me.optString("username", + me.optString("user_id", + me.optString("profile_name", + me.optString("internal_uuid", "me")))); + return safeName(acc); + } + + private static String chatFolderName(ChatEntry chat) { + String name = chat.getName(); + if (name == null || name.isBlank()) { + name = chat.getDisplayId() != null && !chat.getDisplayId().isBlank() + ? chat.getDisplayId() + : String.valueOf(chat.getId()); + } + return safeName(name); + } + + } diff --git a/src/main/java/org/to/telegramfinalproject/Client/DownloadIndexRegistry.java b/src/main/java/org/to/telegramfinalproject/Client/DownloadIndexRegistry.java new file mode 100644 index 0000000..f89bd8f --- /dev/null +++ b/src/main/java/org/to/telegramfinalproject/Client/DownloadIndexRegistry.java @@ -0,0 +1,30 @@ +// DownloadIndexRegistry.java +package org.to.telegramfinalproject.Client; + +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class DownloadIndexRegistry { + private static final ConcurrentHashMap INSTANCES = new ConcurrentHashMap<>(); + private static volatile boolean HOOK_REGISTERED = false; + + private DownloadIndexRegistry() {} + + public static DownloadsIndex forAccount(UUID accountId) { + registerHookOnce(); + return INSTANCES.computeIfAbsent(accountId, DownloadsIndex::new); + } + + public static void closeAccount(UUID accountId) { + DownloadsIndex idx = INSTANCES.remove(accountId); + if (idx != null) idx.saveQuietly(); + } + + private static synchronized void registerHookOnce() { + if (HOOK_REGISTERED) return; + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + for (DownloadsIndex idx : INSTANCES.values()) idx.saveQuietly(); + })); + HOOK_REGISTERED = true; + } +} \ No newline at end of file diff --git a/src/main/java/org/to/telegramfinalproject/Client/DownloadsIndex.java b/src/main/java/org/to/telegramfinalproject/Client/DownloadsIndex.java new file mode 100644 index 0000000..50bdc00 --- /dev/null +++ b/src/main/java/org/to/telegramfinalproject/Client/DownloadsIndex.java @@ -0,0 +1,116 @@ + +package org.to.telegramfinalproject.Client; + +import org.json.JSONObject; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class DownloadsIndex { + private final UUID accountId; + private final Path indexFile; + private final Map map = new ConcurrentHashMap<>(); + + public DownloadsIndex(UUID accountId) { + this.accountId = accountId; + this.indexFile = resolveIndexPath(accountId.toString()); + load(); + } + + + private static Path resolveIndexPath(String accountId) { + String os = System.getProperty("os.name", "").toLowerCase(); + String home = System.getProperty("user.home"); + + Path dir; + if (os.contains("win")) { + String appData = System.getenv("APPDATA"); + dir = (appData != null) + ? Paths.get(appData, "TeleSock") + : Paths.get(home, "AppData", "Roaming", "TeleSock"); + } else { + dir = Paths.get(home, ".telesock"); + } + try { Files.createDirectories(dir); } catch (IOException ignored) {} + return dir.resolve("downloads-index-" + accountId + ".json"); + } + + private synchronized void load() { + map.clear(); + try { + if (!Files.exists(indexFile)) return; + String json = Files.readString(indexFile, StandardCharsets.UTF_8); + if (json == null || json.isBlank()) return; + + JSONObject root = new JSONObject(json); + JSONObject items = root.optJSONObject("items"); + if (items == null) return; + + for (String key : items.keySet()) { + JSONObject e = items.getJSONObject(key); + map.put(UUID.fromString(key), new Entry( + e.getString("path"), + e.optLong("size", 0L), + e.optLong("ts", System.currentTimeMillis()) + )); + } + } catch (Exception e) { + System.err.println("⚠️ DownloadsIndex load failed: " + e.getMessage()); + } + } + + private synchronized void save() throws IOException { + JSONObject items = new JSONObject(); + for (Map.Entry it : map.entrySet()) { + JSONObject e = new JSONObject(); + e.put("path", it.getValue().path); + e.put("size", it.getValue().size); + e.put("ts", it.getValue().ts); + items.put(it.getKey().toString(), e); + } + byte[] data = new JSONObject().put("items", items).toString(2).getBytes(StandardCharsets.UTF_8); + + Path tmp = indexFile.resolveSibling(indexFile.getFileName() + ".tmp"); + Files.write(tmp, data); + try { + Files.move(tmp, indexFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ex) { + Files.move(tmp, indexFile, StandardCopyOption.REPLACE_EXISTING); + } + } + + public void saveQuietly() { + try { save(); } catch (Exception ignored) {} + } + + public Path find(UUID mediaKey) { + Entry e = map.get(mediaKey); + if (e == null) return null; + Path p = Paths.get(e.path); + if (Files.exists(p)) return p; + map.remove(mediaKey); + saveQuietly(); + return null; + } + + public void put(UUID mediaKey, Path path, long size) { + map.put(mediaKey, new Entry(path.toString(), size, System.currentTimeMillis())); + saveQuietly(); + } + + public void remove(UUID mediaKey) { + map.remove(mediaKey); + saveQuietly(); + } + + private static final class Entry { + final String path; final long size; final long ts; + Entry(String path, long size, long ts) { + this.path = path; this.size = size; this.ts = ts; + } + } +} diff --git a/src/main/java/org/to/telegramfinalproject/Client/IncomingMessageListener.java b/src/main/java/org/to/telegramfinalproject/Client/IncomingMessageListener.java index afded31..8f7a435 100644 --- a/src/main/java/org/to/telegramfinalproject/Client/IncomingMessageListener.java +++ b/src/main/java/org/to/telegramfinalproject/Client/IncomingMessageListener.java @@ -8,6 +8,9 @@ import org.to.telegramfinalproject.UI.MainController; import java.io.BufferedReader; import java.io.IOException; import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.util.Optional; import java.util.UUID; @@ -15,6 +18,7 @@ import java.util.concurrent.BlockingQueue; public class IncomingMessageListener implements Runnable { private final BufferedReader in; + private volatile boolean running = true; public enum UIMode { CONSOLE, UI } private final UIMode uiMode; // runtime mode @@ -36,16 +40,47 @@ public class IncomingMessageListener implements Runnable { try { System.out.println("👂 Real-Time Listener started."); - String line; - while ((line = in.readLine()) != null) { + while (running) { + if (TelegramClient.mediaBusy.get()) { + try { Thread.sleep(15); } catch (InterruptedException ignored) {} + continue; + } + if (!in.ready()) { + try { Thread.sleep(10); } catch (InterruptedException ignored) {} + continue; + } + + String line = in.readLine(); + if (line == null) { + break; + } + + if (line.isBlank()) continue; + + final JSONObject response; + try { + response = new JSONObject(line); + } catch (Exception badJson) { + System.out.println("⚠️ [Listener] Non-JSON line ignored: " + line); + continue; + } - JSONObject response = new JSONObject(line); System.out.println("📥 Received raw line: " + line); - //if it has reqID answer + // --- Media ACK routing by message_id --- + String mid = response.optString("message_id", ""); + if (!mid.isEmpty()) { + BlockingQueue q = TelegramClient.pendingResponses.get(mid); + if (q != null) { + q.put(response); + continue; + } + } + + // --- General request_id response routing --- if (response.has("request_id")) { - String requestId = response.getString("request_id"); + String requestId = response.optString("request_id", ""); System.out.println("📬 Response with request_id: " + requestId); System.out.println("📬 Full response: " + response.toString(2)); @@ -56,15 +91,12 @@ public class IncomingMessageListener implements Runnable { System.out.println("⚠️ No pending queue for request_id = " + requestId + ". Putting in responseQueue..."); TelegramClient.responseQueue.put(response); } - continue; } - - - //if it has action check it + // --- Real-time actions --- if (response.has("action")) { - String action = response.getString("action"); + String action = response.optString("action", ""); System.out.println("🎯 [Listener] Action received: " + response.toString(2)); System.out.println("🎯 Received action: " + action); @@ -73,11 +105,12 @@ public class IncomingMessageListener implements Runnable { } else { TelegramClient.responseQueue.put(response); } - } else if (response.has("status") && response.has("message")) { - TelegramClient.responseQueue.put(response); // general answer + // General success/error + TelegramClient.responseQueue.put(response); } else { - TelegramClient.responseQueue.put(response); // fallback + // Fallback + TelegramClient.responseQueue.put(response); } } @@ -94,7 +127,9 @@ public class IncomingMessageListener implements Runnable { "update_group_or_channel", "chat_deleted", "blocked_by_user", "unblocked_by_user", "message_seen", "removed_from_group", "removed_from_channel", - "became_admin", "removed_admin", "ownership_transferred","admin_permissions_updated","created_private_chat" , "message_reacted" , "message_unreacted","chat_updated" -> true; + "became_admin", "removed_admin", "ownership_transferred", + "admin_permissions_updated", "created_private_chat", + "message_reacted", "message_unreacted" , "chat_updated"-> true; default -> false; }; } @@ -113,9 +148,11 @@ public class IncomingMessageListener implements Runnable { System.out.println("🔄 Chat list changed. Updating..."); Session.forceRefreshChatList = true; - String chatId = msg.getString("chat_id"); - String chatType = msg.getString("chat_type"); - ActionHandler.requestChatInfo(chatId, chatType); + String chatId = msg.optString("chat_id", ""); + String chatType = msg.optString("chat_type", ""); + if (!chatId.isBlank() && !chatType.isBlank()) { + ActionHandler.requestChatInfo(chatId, chatType); + } if (action.equals("removed_from_group") || action.equals("removed_from_channel") || action.equals("chat_deleted")) { System.out.println("🚫 You were removed from the chat or chat was deleted. Exiting..."); @@ -198,78 +235,136 @@ public class IncomingMessageListener implements Runnable { System.out.println("\n❓ Unknown real-time action: " + action); System.out.println(msg.toString(2)); } + default -> displayRealTimeMessage(action, msg); } System.out.print(">> "); } +// private void updateLastMessageTime(JSONObject msg) { +// try { +// UUID chatUUID = UUID.fromString(msg.getString("chat_id")); +// String newTime = msg.optString("last_message_time", null); +// +// Session.chatList.stream().filter(chat -> chat.getId().equals(chatUUID)).findFirst() +// .ifPresent(chat -> { +// chat.setLastMessageTime(newTime); +// System.out.println("✅ Updated last message time for chat: " + chat.getDisplayId()); +// }); +// +// Session.activeChats.stream().filter(chat -> chat.getId().equals(chatUUID)).findFirst() +// .ifPresent(chat -> { +// chat.setLastMessageTime(newTime); +// System.out.println("✅ Updated last message time for chat: " + chat.getDisplayId()); +// }); +// +// Session.archivedChats.stream().filter(chat -> chat.getId().equals(chatUUID)).findFirst() +// .ifPresent(chat -> { +// chat.setLastMessageTime(newTime); +// System.out.println("✅ Updated last message time for chat: " + chat.getDisplayId()); +// }); +// +// Session.chatList.sort((c1, c2) -> { +// if (c1.getLastMessageTime() == null && c2.getLastMessageTime() == null) return 0; +// if (c1.getLastMessageTime() == null) return 1; +// if (c2.getLastMessageTime() == null) return -1; +// return c2.getLastMessageTime().compareTo(c1.getLastMessageTime()); +// }); +// Session.activeChats.sort((c1, c2) -> { +// if (c1.getLastMessageTime() == null && c2.getLastMessageTime() == null) return 0; +// if (c1.getLastMessageTime() == null) return 1; +// if (c2.getLastMessageTime() == null) return -1; +// return c2.getLastMessageTime().compareTo(c1.getLastMessageTime()); +// }); +// Session.archivedChats.sort((c1, c2) -> { +// if (c1.getLastMessageTime() == null && c2.getLastMessageTime() == null) return 0; +// if (c1.getLastMessageTime() == null) return 1; +// if (c2.getLastMessageTime() == null) return -1; +// return c2.getLastMessageTime().compareTo(c1.getLastMessageTime()); +// }); +// +// if (Session.inChatListMenu) { +// ActionHandler.displayChatList(); +// System.out.print("Select a chat by number: "); +// } +// +// } catch (Exception e) { +// System.out.println("❌ Failed to update last message time: " + e.getMessage()); +// } +// } + + private void updateLastMessageTime(JSONObject msg) { try { UUID chatUUID = UUID.fromString(msg.getString("chat_id")); String newTime = msg.optString("last_message_time", null); + if (newTime == null || newTime.isBlank()) return; - Session.chatList.stream() - .filter(chat -> chat.getId().equals(chatUUID)) - .findFirst() - .ifPresent(chat -> { - chat.setLastMessageTime(newTime); - System.out.println("✅ Updated last message time for chat: " + chat.getDisplayId()); - }); - - Session.activeChats.stream() - .filter(chat -> chat.getId().equals(chatUUID)) - .findFirst() - .ifPresent(chat -> { - chat.setLastMessageTime(newTime); - System.out.println("✅ Updated last message time for chat: " + chat.getDisplayId()); - }); - - Session.archivedChats.stream() - .filter(chat -> chat.getId().equals(chatUUID)) - .findFirst() - .ifPresent(chat -> { - chat.setLastMessageTime(newTime); - System.out.println("✅ Updated last message time for chat: " + chat.getDisplayId()); - }); - - Session.chatList.sort((c1, c2) -> { - if (c1.getLastMessageTime() == null && c2.getLastMessageTime() == null) return 0; - if (c1.getLastMessageTime() == null) return 1; - if (c2.getLastMessageTime() == null) return -1; - return c2.getLastMessageTime().compareTo(c1.getLastMessageTime()); // descending - }); - Session.activeChats.sort((c1, c2) -> { - if (c1.getLastMessageTime() == null && c2.getLastMessageTime() == null) return 0; - if (c1.getLastMessageTime() == null) return 1; - if (c2.getLastMessageTime() == null) return -1; - return c2.getLastMessageTime().compareTo(c1.getLastMessageTime()); // descending - }); - Session.archivedChats.sort((c1, c2) -> { - if (c1.getLastMessageTime() == null && c2.getLastMessageTime() == null) return 0; - if (c1.getLastMessageTime() == null) return 1; - if (c2.getLastMessageTime() == null) return -1; - return c2.getLastMessageTime().compareTo(c1.getLastMessageTime()); // descending - }); - - + updateOneList(Session.chatList, chatUUID, newTime); + updateOneList(Session.activeChats, chatUUID, newTime); + updateOneList(Session.archivedChats, chatUUID, newTime); + sortByLastMessageTime(Session.chatList); + sortByLastMessageTime(Session.activeChats); + sortByLastMessageTime(Session.archivedChats); if (Session.inChatListMenu) { ActionHandler.displayChatList(); System.out.print("Select a chat by number: "); } - } catch (Exception e) { System.out.println("❌ Failed to update last message time: " + e.getMessage()); } } + private void updateOneList(List list, UUID chatUUID, String newTime) { + if (list == null) return; + for (ChatEntry chat : list) { + if (chatUUID.equals(chat.getId())) { // ✅ internal UUID + chat.setLastMessageTime(newTime); + System.out.println("✅ Updated last message time for chat: " + chat.getDisplayId()); + break; + } + } + } + + private static void sortByLastMessageTime(java.util.List list) { + if (list == null) return; + list.sort((a, b) -> { + var ta = parseTs(String.valueOf(a.getLastMessageTime())); + var tb = parseTs(String.valueOf(b.getLastMessageTime())); + if (ta == null && tb == null) return 0; + if (ta == null) return 1; + if (tb == null) return -1; + return tb.compareTo(ta); + }); + + + for (int i = 0; i < list.size(); i++) { + if (list.get(i).isSavedMessages()) { + list.add(0, list.remove(i)); + break; + } + } + } + + private static java.time.LocalDateTime parseTs(String s) { + if (s == null) return null; + s = s.trim(); + if (s.isEmpty() || s.equalsIgnoreCase("null")) return null; + try { return java.time.OffsetDateTime.parse(s).toLocalDateTime(); } catch (Exception ignore) {} + try { return java.time.LocalDateTime.parse(s, java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME); } catch (Exception ignore) {} + return null; + } + + private void handleAdminRoleChanged(JSONObject data) throws IOException { - String chatType = data.getString("chat_type"); - String chatId = data.optString("group_id", data.optString("channel_id", data.optString("chat_id", null))); + String chatType = data.optString("chat_type", ""); + String chatId = data.optString("group_id", + data.optString("channel_id", data.optString("chat_id", ""))); - if (chatId == null) { + if (chatId.isBlank()) { System.out.println("⚠️ No valid ID found in real-time data: " + data.toString(2)); return; } @@ -277,11 +372,10 @@ public class IncomingMessageListener implements Runnable { System.out.println("\n🔄 Your admin status changed. Updating chat info..."); try { - // 1. get chat info - JSONObject chatInfoReq = new JSONObject(); - chatInfoReq.put("action", "get_chat_info"); - chatInfoReq.put("receiver_id", chatId); - chatInfoReq.put("receiver_type", chatType); + JSONObject chatInfoReq = new JSONObject() + .put("action", "get_chat_info") + .put("receiver_id", chatId) + .put("receiver_type", chatType); System.out.println("📤 Sending get_chat_info: " + chatInfoReq); JSONObject chatInfoResp = ActionHandler.sendWithResponse(chatInfoReq); JSONObject chatData = chatInfoResp.getJSONObject("data"); @@ -307,21 +401,17 @@ public class IncomingMessageListener implements Runnable { Session.currentChatEntry = chat; }); - // 2. get permission JSONObject permissionReq = new JSONObject(); if (chatType.equalsIgnoreCase("group")) { - permissionReq.put("action", "get_group_permissions"); - permissionReq.put("group_id", chatId); + permissionReq.put("action", "get_group_permissions").put("group_id", chatId); } else { - permissionReq.put("action", "get_channel_permissions"); - permissionReq.put("channel_id", chatId); + permissionReq.put("action", "get_channel_permissions").put("channel_id", chatId); } JSONObject permissionResp = ActionHandler.sendWithResponse(permissionReq); JSONObject perm = permissionResp.getJSONObject("data"); entry.ifPresent(chat -> chat.setPermissions(perm)); - // 3. set currentChatId Session.currentChatId = chatUUID.toString(); System.out.println("🧪 Checking refresh conditions..."); @@ -344,20 +434,11 @@ public class IncomingMessageListener implements Runnable { } } - - - - - - - - - private void displayRealTimeMessage(String action, JSONObject msg) { switch (action) { case "new_message" -> { String senderName = msg.optString("sender_name","Unknown"); - String content = msg.optString("content","(empty)"); + String content = msg.optString("content",""); String sendAt = msg.optString("send_at","-"); String chatId = msg.optString("receiver_id", msg.optString("chat_id","")); String kind = msg.optString("kind","plain"); @@ -376,47 +457,47 @@ public class IncomingMessageListener implements Runnable { Session.currentChatId != null && Session.currentChatId.equals(chatId); if (isInCurrentChat) { + if (content.isBlank()) content = "(no content)"; // برای مدیا بدون کپشن System.out.println(senderName + ": " + prefix + content + " (" + sendAt + ")"); } else { - System.out.println("💬 Message from " + senderName + ": " + prefix + content); + String preview = content.isBlank() ? "[media]" : content; + System.out.println("💬 Message from " + senderName + ": " + prefix + preview); Session.forceRefreshChatList = true; } } - case "message_edited" -> { System.out.println("\n✏️ Message Edited:"); - System.out.println("ID: " + msg.getString("message_id")); - System.out.println("New Content: " + msg.getString("new_content")); - System.out.println("Edit Time: " + msg.getString("edited_at")); + System.out.println("ID: " + msg.optString("message_id","")); + System.out.println("New Content: " + msg.optString("new_content","")); + System.out.println("Edit Time: " + msg.optString("edited_at","")); } case "message_deleted_global" -> { System.out.println("\n🗑️ Message Deleted:"); - System.out.println("Message ID: " + msg.getString("message_id")); + System.out.println("Message ID: " + msg.optString("message_id","")); } case "message_reacted", "message_unreacted" -> { - String mid = msg.getString("message_id"); - String emoji = msg.getString("emoji"); - JSONObject counts = msg.optJSONObject("counts"); + String mid = msg.optString("message_id",""); + String emoji = msg.optString("emoji",""); int n = msg.optInt("count_for_emoji", 0); System.out.println("\n⭐ Reaction update on " + mid + " : " + emoji + " → " + n); } case "user_status_changed" -> { System.out.println("\n🔄 User Status Changed:"); - System.out.println("User: " + msg.getString("user_id")); - System.out.println("Status: " + msg.getString("status")); + System.out.println("User: " + msg.optString("user_id","")); + System.out.println("Status: " + msg.optString("status","")); } case "blocked_by_user" -> { - System.out.println("\n⛔ You were blocked by user: " + msg.getString("blocker_id")); + System.out.println("\n⛔ You were blocked by user: " + msg.optString("blocker_id","")); } case "unblocked_by_user" -> { - System.out.println("\n✅ You were unblocked by user: " + msg.getString("unblocker_id")); + System.out.println("\n✅ You were unblocked by user: " + msg.optString("unblocker_id","")); } case "message_seen" -> { System.out.println("\n👁️ Your message was seen:"); - System.out.println("Message ID: " + msg.getString("message_id")); - System.out.println("Seen at: " + msg.getString("seen_at")); + System.out.println("Message ID: " + msg.optString("message_id","")); + System.out.println("Seen at: " + msg.optString("seen_at","")); } default -> { System.out.println("\n❓ Unknown real-time action: " + action); @@ -427,6 +508,9 @@ public class IncomingMessageListener implements Runnable { + + + private static LocalDateTime parseIsoFlexible(String iso) { if (iso == null || iso.isBlank()) return null; try { return LocalDateTime.parse(iso); } catch (Exception ignore) {} diff --git a/src/main/java/org/to/telegramfinalproject/Client/MediaSender.java b/src/main/java/org/to/telegramfinalproject/Client/MediaSender.java new file mode 100644 index 0000000..112fd0d --- /dev/null +++ b/src/main/java/org/to/telegramfinalproject/Client/MediaSender.java @@ -0,0 +1,82 @@ +package org.to.telegramfinalproject.Client; + +import org.json.JSONObject; +import java.io.*; +import java.net.Socket; +import java.nio.file.Files; +import java.util.UUID; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; + + + +class MediaSender { + + public static void sendImageOrAudio(Socket socket, + UUID senderId, + String receiverType, + UUID receiverId, + File file, + String messageType, // "IMAGE" یا "AUDIO" + String captionOrEmpty) throws Exception { + + if (!"IMAGE".equals(messageType) && !"AUDIO".equals(messageType)) + throw new IllegalArgumentException("Only IMAGE/AUDIO"); + + // 1) اعلام سوییچ به باینری + PrintWriter textOut = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), "UTF-8"), true); + textOut.println("MEDIA"); + + // 2) متادیتا + String mime = Files.probeContentType(file.toPath()); + if (mime == null) mime = "application/octet-stream"; + + Integer width = null, height = null; + if ("IMAGE".equals(messageType)) { + try { + BufferedImage img = ImageIO.read(file); + if (img != null) { width = img.getWidth(); height = img.getHeight(); } + } catch (Exception ignore) {} + } + + UUID messageId = UUID.randomUUID(); + JSONObject header = new JSONObject() + .put("message_id", messageId.toString()) + .put("sender_id", senderId.toString()) + .put("receiver_type", receiverType) + .put("receiver_id", receiverId.toString()) + .put("message_type", messageType) + .put("file_name", file.getName()) + .put("mime_type", mime) + .put("file_size", file.length()) + .put("text", captionOrEmpty == null ? "" : captionOrEmpty); + + if (width != null) header.put("width", width); + if (height != null) header.put("height", height); + + byte[] headerBytes = header.toString().getBytes("UTF-8"); + + // 3) ارسال فریم باینری + DataOutputStream dos = new DataOutputStream(new BufferedOutputStream(socket.getOutputStream())); + dos.writeInt(0x4D444D31); // MAGIC + dos.writeInt(headerBytes.length); // headerLen + dos.write(headerBytes); // header + dos.writeLong(file.length()); // contentLen + + try (InputStream fis = new BufferedInputStream(new FileInputStream(file))) { + byte[] buf = new byte[8192]; + int n; + while ((n = fis.read(buf)) != -1) { + dos.write(buf, 0, n); + } + } + dos.flush(); + + // (اختیاری) Ack متنی + BufferedReader textIn = new BufferedReader(new InputStreamReader(socket.getInputStream(), "UTF-8")); + String ack = textIn.readLine(); + if (!"OK".equalsIgnoreCase(ack)) { + throw new IOException("Server did not ACK: " + ack); + } + } +} diff --git a/src/main/java/org/to/telegramfinalproject/Client/Session.java b/src/main/java/org/to/telegramfinalproject/Client/Session.java index a13892c..f4f9475 100644 --- a/src/main/java/org/to/telegramfinalproject/Client/Session.java +++ b/src/main/java/org/to/telegramfinalproject/Client/Session.java @@ -28,7 +28,7 @@ public class Session { public static ChatEntry currentChatEntry = null; public static List contactEntries = new ArrayList<>(); public static boolean inContactListMenu = false; - + public static DownloadsIndex downloadsIndex = null; public static String getUserUUID() { if (currentUser.has("uuid")) return currentUser.getString("uuid"); @@ -110,4 +110,7 @@ public class Session { .filter(ChatEntry::isArchived) .toList(); } + + public void setDownloadIndex(DownloadsIndex idx){ this.downloadsIndex = idx; } + } \ No newline at end of file diff --git a/src/main/java/org/to/telegramfinalproject/Client/SidebarHandler.java b/src/main/java/org/to/telegramfinalproject/Client/SidebarHandler.java index dab17f2..2960707 100644 --- a/src/main/java/org/to/telegramfinalproject/Client/SidebarHandler.java +++ b/src/main/java/org/to/telegramfinalproject/Client/SidebarHandler.java @@ -255,234 +255,7 @@ public class SidebarHandler { actionHandler.showContactList(); } -// public void getSavedMessagesData(String userId) { -// try { -// // Step 1: Create the request -// JSONObject request = new JSONObject(); -// request.put("action", "get_saved_messages"); -// request.put("user_id", userId); -// -// // Step 2: Send request and wait for response -// JSONObject response = ActionHandler.sendWithResponse(request); -// -// // Step 3: Check the response -// if (!response.optString("status", "fail").equals("success")) { -// System.out.println("Failed to open Saved Messages chat: " + response.optString("message", "Unknown error")); -// return; -// } -// -// // Step 4: Extract "data" object -// JSONObject data = response.getJSONObject("data"); -// UUID chatId = UUID.fromString(data.getString("chat_id")); -// JSONArray messagesArray = data.getJSONArray("messages"); -// -// // Step 5: Parse messages -// List messages = new ArrayList<>(); -// if (!messagesArray.isEmpty()) { -// for (int i = 0; i < messagesArray.length(); i++) { -// JSONObject msgJson = messagesArray.getJSONObject(i); -// -// // Safely extract optional UUIDs -// UUID replyToId = null; -// String replyToIdStr = msgJson.optString("reply_to_id", null); -// if (replyToIdStr != null && !replyToIdStr.equals("null")) { -// replyToId = UUID.fromString(replyToIdStr); -// } -// -// UUID originalMessageId = null; -// String originalMessageIdStr = msgJson.optString("original_message_id", null); -// if (originalMessageIdStr != null && !originalMessageIdStr.equals("null")) { -// originalMessageId = UUID.fromString(originalMessageIdStr); -// } -// -// UUID forwardedBy = null; -// String forwardedByStr = msgJson.optString("forwarded_by", null); -// if (forwardedByStr != null && !forwardedByStr.equals("null")) { -// forwardedBy = UUID.fromString(forwardedByStr); -// } -// -// UUID forwardedFrom = null; -// String forwardedFromStr = msgJson.optString("forwarded_from", null); -// if (forwardedFromStr != null && !forwardedFromStr.equals("null")) { -// forwardedFrom = UUID.fromString(forwardedFromStr); -// } -// -// Message msg = new Message( -// UUID.fromString(msgJson.getString("message_id")), -// UUID.fromString(msgJson.getString("sender_id")), -// msgJson.getString("receiver_type"), -// UUID.fromString(msgJson.getString("receiver_id")), -// msgJson.getString("content"), -// msgJson.getString("message_type"), -// LocalDateTime.parse(msgJson.getString("send_at").replace(" ", "T")), -// msgJson.getString("status"), -// replyToId, -// msgJson.getBoolean("is_edited"), -// originalMessageId, -// forwardedBy, -// forwardedFrom, -// msgJson.getBoolean("is_deleted_globally"), -// LocalDateTime.parse(msgJson.getString("edited_at").replace(" ", "T")) -// ); -// -// messages.add(msg); -// } -// } -// -// // Step 6: Add to active chats if not already present -// boolean alreadyExists = Session.activeChats.stream() -// .anyMatch(entry -> entry.getId().equals(chatId)); -// if (!alreadyExists) { -// ChatEntry savedEntry = new ChatEntry( -// chatId, -// "Saved-Messages", -// "Saved Messages", -// "📌", // or use a URL string if you have an icon for saved messages -// "private", -// messages.isEmpty() ? null : messages.get(messages.size() - 1).getSend_at() -// ); -// savedEntry.setSavedMessages(true); -// Session.activeChats.add(savedEntry); -// } -// -// // Step 7: Show chat -// showSavedMessages(chatId, messages); -// -// } catch (Exception e) { -// System.out.println("An error occurred while retrieving Saved Messages."); -// e.printStackTrace(); -// } -// } -// private void showSavedMessages(UUID chatId, List messages) { -// Scanner scanner = new Scanner(System.in); -// -// System.out.println("==== Saved Messages ===="); -// if (messages == null || messages.isEmpty()) { -// System.out.println("No messages yet."); -// } else { -// for (Message msg : messages) { -// System.out.println("[" + msg.getSend_at() + "] " + msg.getContent()); -// } -// } -// -// System.out.println("\n(Type your message below, or type 0 to exit)"); -// -// while (true) { -// System.out.print("You: "); -// String content = scanner.nextLine().trim(); -// if (content.equals("0")) { -// System.out.println("Exiting Saved Messages."); -// break; -// } -// -// System.out.print("Enter message type (TEXT / IMAGE / VIDEO / FILE / AUDIO): "); -// String messageType = scanner.nextLine().trim().toUpperCase(); -// Set allowedTypes = Set.of("TEXT", "IMAGE", "VIDEO", "FILE", "AUDIO"); -// while (!allowedTypes.contains(messageType)) { -// System.out.print("Invalid type. Try again (TEXT / IMAGE / VIDEO / FILE / AUDIO): "); -// messageType = scanner.nextLine().trim().toUpperCase(); -// } -// -// // Attachments (اختیاری) -// JSONArray attachmentsArray = new JSONArray(); -// System.out.print("Do you want to attach files? (yes/no): "); -// if (scanner.nextLine().equalsIgnoreCase("yes")) { -// while (true) { -// System.out.print("File URL: "); -// String fileUrl = scanner.nextLine().trim(); -// -// if (fileUrl.isEmpty()) { -// System.out.println("URL can not be empty. Try again."); -// continue; -// } -// if (fileUrl.contains(" ")) { -// System.out.println("URL cannot contain spaces. Try again."); -// continue; -// } -// if (!fileUrl.matches("^(http|https)://.*$")) { -// System.out.println("Invalid URL format. Please enter a valid HTTP/HTTPS link."); -// continue; -// } -// -// System.out.print("File Type (IMAGE / VIDEO / FILE / AUDIO): "); -// String fileType = scanner.nextLine().trim().toUpperCase(); -// Set allowedFileTypes = Set.of("IMAGE", "VIDEO", "FILE", "AUDIO"); -// while (!allowedFileTypes.contains(fileType)) { -// System.out.print("Invalid type. Try again (IMAGE / VIDEO / FILE / AUDIO): "); -// fileType = scanner.nextLine().trim().toUpperCase(); -// } -// -// JSONObject fileJson = new JSONObject(); -// fileJson.put("file_url", fileUrl); -// fileJson.put("file_type", fileType); -// attachmentsArray.put(fileJson); -// -// System.out.print("Add another file? (yes/no): "); -// if (!scanner.nextLine().equalsIgnoreCase("yes")) break; -// } -// } -// -// // درخواست مطابق هندلر send_message -// JSONObject request = new JSONObject(); -// request.put("action", "send_message"); -// request.put("receiver_type", "private"); -// request.put("receiver_id", chatId.toString()); // chat_id -// request.put("content", content); -// request.put("message_type", messageType); -// if (attachmentsArray.length() > 0) { -// request.put("attachments", attachmentsArray); -// } -// -// JSONObject response = ActionHandler.sendWithResponse(request); -// if (!"success".equalsIgnoreCase(response.optString("status"))) { -// System.out.println("Failed to send message: " + response.optString("message", "Unknown error")); -// } else { -// System.out.println("Message sent."); -// -// Message justSent = new Message( -// UUID.fromString(response.getJSONObject("data").getString("message_id")), -// /* senderId */ userUUID, -// /* receiverId */ chatId, -// /* type */ "private", -// /* content */ content, -// /* msgType */ messageType, -// /* send_at */ java.time.LocalDateTime.now() -// ); -// messages.add(justSent); -// System.out.println("[" + justSent.getSend_at() + "] " + justSent.getContent()); -// } -// } -// } - - -// public void openSavedMessages() { -// JSONObject req = new JSONObject().put("action", "get_or_create_saved_messages"); -// JSONObject res = sendWithResponse(req); -// if (res == null || !"success".equals(res.optString("status"))) { -// System.out.println("❌ Could not open Saved Messages: " + res.optString("message","")); -// return; -// } -// -// ActionHandler.requestChatList(); -// String chatId = res.getJSONObject("data").getString("chat_id"); -// -// JSONObject mreq = new JSONObject() -// .put("action", "get_messages") -// .put("receiver_type", "private") -// .put("receiver_id", chatId) -// .put("offset", 0) -// .put("limit", 50); -// -// JSONObject mres = sendWithResponse(mreq); -// JSONArray msgs = (mres != null && mres.has("data")) -// ? mres.getJSONObject("data").optJSONArray("messages") -// : new JSONArray(); -// -// List messages = parseMessages(msgs); // تبدیل JSON → Message -// showSavedMessages(UUID.fromString(chatId), messages); -// } -// @@ -774,7 +547,6 @@ public class SidebarHandler { } private String padBoxLine(String text, int width) { - // عرض: width، دو طرف │ │ final int inner = width - 2; if (text.length() > inner) { text = text.substring(0, inner - 1) + "…"; @@ -804,58 +576,5 @@ public class SidebarHandler { return approved ; } - private List parseMessages(JSONArray msgs) { - List list = new ArrayList<>(); - if (msgs == null) return list; - for (int i = 0; i < msgs.length(); i++) { - try { - JSONObject obj = msgs.getJSONObject(i); - - UUID messageId = UUID.fromString(obj.getString("message_id")); - UUID senderId = UUID.fromString(obj.getString("sender_id")); - String receiverType= obj.getString("receiver_type"); // "private" | "group" | "channel" - UUID receiverId = UUID.fromString(obj.getString("receiver_id")); // برای private = chat_id - String content = obj.optString("content", ""); - String messageType = obj.optString("message_type", "TEXT"); - LocalDateTime sent = LocalDateTime.parse(obj.getString("send_at")); - - Message m = new Message( - messageId, senderId, receiverId, receiverType, content, messageType, sent - ); - - if (obj.has("status") && !obj.isNull("status")) { - try { m.setStatus(obj.getString("status")); } catch (Exception ignore) {} - } - if (obj.has("reply_to_id") && !obj.isNull("reply_to_id")) { - try { m.setReply_to_id(UUID.fromString(obj.getString("reply_to_id"))); } catch (Exception ignore) {} - } - if (obj.has("forwarded_by") && !obj.isNull("forwarded_by")) { - try { m.setForwarded_by(UUID.fromString(obj.getString("forwarded_by"))); } catch (Exception ignore) {} - } - if (obj.has("forwarded_from") && !obj.isNull("forwarded_from")) { - try { m.setForwarded_from(UUID.fromString(obj.getString("forwarded_from"))); } catch (Exception ignore) {} - } - -// // ضمیمه‌ها (اگر در مدل Message متد addAttachment داری) -// if (obj.has("attachments") && !obj.isNull("attachments")) { -// try { -// JSONArray atts = obj.getJSONArray("attachments"); -// for (int j = 0; j < atts.length(); j++) { -// JSONObject a = atts.getJSONObject(j); -// String fileUrl = a.getString("file_url"); -// String fileType = a.getString("file_type"); -// FileAttachment fa = new FileAttachment(fileUrl, fileType); -// try { m.addAttachment(fa); } catch (Exception ignore) {} -// } -// } catch (Exception ignore) {} -// } - - list.add(m); - } catch (Exception perItem) { - perItem.printStackTrace(); - } - } - return list; - } } \ No newline at end of file diff --git a/src/main/java/org/to/telegramfinalproject/Client/SocketMediaDownloader.java b/src/main/java/org/to/telegramfinalproject/Client/SocketMediaDownloader.java new file mode 100644 index 0000000..4a43d89 --- /dev/null +++ b/src/main/java/org/to/telegramfinalproject/Client/SocketMediaDownloader.java @@ -0,0 +1,75 @@ +package org.to.telegramfinalproject.Client; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.PrintWriter; + +public final class SocketMediaDownloader { + private static final int MAGIC_DL = 0x4D444D32; + private final PrintWriter outText; // NEW + private final DataInputStream inBin; + private final DataOutputStream outBin; + + public SocketMediaDownloader(PrintWriter outText, DataInputStream inBin, DataOutputStream outBin) { + this.outText = outText; + this.inBin = inBin; + this.outBin = outBin; + } + + + public java.nio.file.Path download(java.util.UUID mediaKey, java.nio.file.Path saveDir, String fileNameHint) throws Exception { + outText.print("MEDIA_DL\n"); + outText.flush(); + + org.json.JSONObject req = new org.json.JSONObject() + .put("op","download") + .put("media_key", mediaKey.toString()) + .put("offset", 0); + byte[] hb = req.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + + outBin.writeInt(MAGIC_DL); + outBin.writeInt(hb.length); + outBin.write(hb); + outBin.flush(); + + int magic = inBin.readInt(); + if (magic != MAGIC_DL) throw new java.io.IOException("bad magic"); + + int hlen = inBin.readInt(); + byte[] hbytes = inBin.readNBytes(hlen); + org.json.JSONObject hdr = new org.json.JSONObject(new String(hbytes, java.nio.charset.StandardCharsets.UTF_8)); + if (!"success".equalsIgnoreCase(hdr.optString("status"))) { + throw new java.io.IOException("download error: " + hdr.optString("message")); + } + + long contentLen = inBin.readLong(); + String serverName = hdr.optString("file_name", fileNameHint != null ? fileNameHint : mediaKey.toString()); + + java.nio.file.Files.createDirectories(saveDir); + java.nio.file.Path dest = uniquePath(saveDir, serverName); + + try (java.io.OutputStream os = java.nio.file.Files.newOutputStream(dest)) { + byte[] buf = new byte[8192]; + long remain = contentLen; + while (remain > 0) { + int toRead = (int) Math.min(buf.length, remain); + int n = inBin.read(buf, 0, toRead); + if (n == -1) throw new java.io.EOFException("unexpected EOF"); + os.write(buf, 0, n); + remain -= n; + } + } + return dest; + } + + private static java.nio.file.Path uniquePath(java.nio.file.Path dir, String name) throws java.io.IOException { + java.nio.file.Path p = dir.resolve(name); + if (!java.nio.file.Files.exists(p)) return p; + String base = name, ext = ""; + int dot = name.lastIndexOf('.'); + if (dot >= 0) { base = name.substring(0, dot); ext = name.substring(dot); } + int i = 1; + while (java.nio.file.Files.exists(dir.resolve(base + " (" + i + ")" + ext))) i++; + return dir.resolve(base + " (" + i + ")" + ext); + } +} \ No newline at end of file diff --git a/src/main/java/org/to/telegramfinalproject/Client/TelegramClient.java b/src/main/java/org/to/telegramfinalproject/Client/TelegramClient.java index eb68156..dec1eaf 100644 --- a/src/main/java/org/to/telegramfinalproject/Client/TelegramClient.java +++ b/src/main/java/org/to/telegramfinalproject/Client/TelegramClient.java @@ -128,10 +128,7 @@ package org.to.telegramfinalproject.Client; import org.json.JSONObject; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.io.PrintWriter; +import java.io.*; import java.net.Socket; import java.nio.charset.StandardCharsets; import java.util.Map; @@ -140,9 +137,11 @@ import java.util.UUID; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicBoolean; public class TelegramClient { private static final String SERVER_HOST = "localhost"; + private static final int SERVER_PORT = 8080; private static final int SERVER_PORT = 8000; private static TelegramClient instance; @@ -158,6 +157,13 @@ public class TelegramClient { public static final BlockingQueue responseQueue = new LinkedBlockingQueue<>(); public static final Map> pendingResponses = new ConcurrentHashMap<>(); public static UUID loggedInUserId = null; + public static final Map> pendingResponses = new ConcurrentHashMap<>(); + private DataInputStream inBin; // NEW + private static SocketMediaDownloader downloader; // NEW + public static final AtomicBoolean mediaBusy = new AtomicBoolean(false); // + private DownloadsIndex downloadIndex; + + private static TelegramClient instance; private volatile boolean listenerStarted = false; @@ -168,8 +174,14 @@ public class TelegramClient { public static synchronized TelegramClient getInstance() { if (instance == null) instance = new TelegramClient(); + public static SocketMediaDownloader getDownloader() { + return downloader; + } + + public static TelegramClient getInstance() { return instance; } + private DataOutputStream outBin; // public void startConsole() { // try { @@ -184,6 +196,18 @@ public class TelegramClient { public void startConsole() { try { + socket = new Socket(SERVER_HOST, SERVER_PORT); + InputStream rawIn = socket.getInputStream(); + OutputStream rawOut = socket.getOutputStream(); + in = new BufferedReader(new InputStreamReader(rawIn, StandardCharsets.UTF_8)); + out = new PrintWriter(new OutputStreamWriter(rawOut, StandardCharsets.UTF_8), true); + + inBin = new DataInputStream(rawIn); + outBin = new DataOutputStream(rawOut); + downloader = new SocketMediaDownloader(out, inBin, outBin); + + System.out.println("✅ Connected to Telegram Server"); + handler = new ActionHandler(out, in, outBin, scanner); connectIfNeeded(); initHandlerIfNeeded(); startListenerOnce(IncomingMessageListener.UIMode.CONSOLE); // ← کنسول @@ -269,7 +293,7 @@ public class TelegramClient { UUID internalId = UUID.fromString(Session.currentUser.getString("internal_uuid")); loggedInUserId = internalId; - + this.downloadIndex = DownloadIndexRegistry.forAccount(internalId); handler.userMenu(internalId); } else { System.out.println("❌ Login failed."); diff --git a/src/main/java/org/to/telegramfinalproject/Database/ChannelDatabase.java b/src/main/java/org/to/telegramfinalproject/Database/ChannelDatabase.java index 6804ad4..4429721 100644 --- a/src/main/java/org/to/telegramfinalproject/Database/ChannelDatabase.java +++ b/src/main/java/org/to/telegramfinalproject/Database/ChannelDatabase.java @@ -195,7 +195,7 @@ public class ChannelDatabase { """; try (Connection conn = ConnectionDb.connect()) { - // مرحله اول: ساخت کانال + PreparedStatement stmt = conn.prepareStatement(sql); stmt.setString(1, channel.getChannel_id()); stmt.setString(2, channel.getChannel_name()); @@ -208,9 +208,8 @@ public class ChannelDatabase { if (!rs.next()) return false; UUID internalUUID = (UUID) rs.getObject("internal_uuid"); - channel.setInternal_uuid(internalUUID); // اختیاری برای پیگیری بعدی + channel.setInternal_uuid(internalUUID); - // مرحله دوم: افزودن کاربر به لیست سابسکرایبرها PreparedStatement subStmt = conn.prepareStatement(subscriberSql); subStmt.setObject(1, internalUUID); subStmt.setObject(2, creatorId); diff --git a/src/main/java/org/to/telegramfinalproject/Database/MessageDatabase.java b/src/main/java/org/to/telegramfinalproject/Database/MessageDatabase.java index af0033d..aa4ef9b 100644 --- a/src/main/java/org/to/telegramfinalproject/Database/MessageDatabase.java +++ b/src/main/java/org/to/telegramfinalproject/Database/MessageDatabase.java @@ -1,7 +1,9 @@ package org.to.telegramfinalproject.Database; import org.to.telegramfinalproject.Models.FileAttachment; +import org.to.telegramfinalproject.Models.MediaRow; import org.to.telegramfinalproject.Models.Message; +import org.to.telegramfinalproject.Utils.ChannelPermissionUtil; import java.sql.*; import java.time.LocalDateTime; @@ -66,6 +68,134 @@ public class MessageDatabase { } } + + public static boolean insertMessageTx(Connection conn, UUID messageId, UUID senderId, UUID receiverId, + String receiverType, String content, String messageType) throws SQLException { + String sql = "INSERT INTO messages (message_id, sender_id, receiver_type, receiver_id, content, message_type) " + + "VALUES (?, ?, ?, ?, ?, ?)"; + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setObject(1, messageId); + ps.setObject(2, senderId); + ps.setString(3, receiverType); + ps.setObject(4, receiverId); + if (content == null || content.isBlank()) ps.setNull(5, java.sql.Types.VARCHAR); else ps.setString(5, content); + ps.setString(6, messageType); + return ps.executeUpdate() > 0; + } + } + + public static boolean insertAttachmentsTx(Connection conn, UUID messageId, List attachments) throws SQLException { + if (attachments == null || attachments.isEmpty()) return true; + + final String sql = """ + INSERT INTO message_attachments( + attachment_id, message_id, + file_url, file_type, file_name, file_size, mime_type, + width, height, duration_seconds, thumbnail_url, + media_key, storage_path + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + """; + + try (PreparedStatement ps = conn.prepareStatement(sql)) { + for (FileAttachment att : attachments) { + if (att == null) throw new IllegalArgumentException("Attachment is null"); + UUID attachmentId = att.getAttachmentId() != null ? att.getAttachmentId() : UUID.randomUUID(); + UUID mediaKey = att.getMediaKey() != null ? att.getMediaKey() : attachmentId; // ساده‌ترین حالت + + String ft = att.getFileType(); + if (!"IMAGE".equalsIgnoreCase(ft) && !"AUDIO".equalsIgnoreCase(ft)) { + throw new IllegalArgumentException("file_type must be IMAGE or AUDIO"); + } + if (att.getStoragePath() == null || att.getStoragePath().isBlank()) { + throw new IllegalArgumentException("storage_path is required for socket downloads"); + } + + int i = 1; + ps.setObject(i++, attachmentId); + ps.setObject(i++, messageId); + //file url (display link) + if (att.getFileUrl() == null || att.getFileUrl().isBlank()) ps.setNull(i++, java.sql.Types.VARCHAR); + else ps.setString(i++, att.getFileUrl()); + + ps.setString(i++, ft.toUpperCase()); + ps.setString(i++, att.getFileName()); + if (att.getFileSize() == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, att.getFileSize()); + if (att.getMimeType() == null) ps.setNull(i++, java.sql.Types.VARCHAR); else ps.setString(i++, att.getMimeType()); + if (att.getWidth() == null) ps.setNull(i++, java.sql.Types.INTEGER); else ps.setInt(i++, att.getWidth()); + if (att.getHeight() == null) ps.setNull(i++, java.sql.Types.INTEGER); else ps.setInt(i++, att.getHeight()); + if (att.getDurationSeconds() == null) ps.setNull(i++, java.sql.Types.INTEGER); else ps.setInt(i++, att.getDurationSeconds()); + if (att.getThumbnailUrl() == null || att.getThumbnailUrl().isBlank()) ps.setNull(i++, java.sql.Types.VARCHAR); + else ps.setString(i++, att.getThumbnailUrl()); + + ps.setObject(i++, mediaKey); + ps.setString(i++, att.getStoragePath()); + + ps.addBatch(); + + att.setAttachmentId(attachmentId); + att.setMediaKey(mediaKey); + } + ps.executeBatch(); + return true; + } + } + + + + public static boolean saveMessageWithOptionalAttachments( + UUID messageId, UUID senderId, UUID receiverId, + String receiverType, String content, String messageType, + List attachments + ) { + Connection conn = null; + try { + conn = ConnectionDb.connect(); + conn.setAutoCommit(false); + + boolean isText = "TEXT".equalsIgnoreCase(messageType); + boolean isImage = "IMAGE".equalsIgnoreCase(messageType); + boolean isAudio = "AUDIO".equalsIgnoreCase(messageType); + if (!isText && !isImage && !isAudio) { + throw new IllegalArgumentException("messageType must be TEXT, IMAGE, or AUDIO"); + } + + if (isText) { + if (attachments != null && !attachments.isEmpty()) + throw new IllegalArgumentException("TEXT must not have attachments"); + if (content == null || content.isBlank()) + throw new IllegalArgumentException("TEXT must have non-empty content"); + } else { + if (attachments == null || attachments.isEmpty()) + throw new IllegalArgumentException("Non-TEXT must have at least one attachment"); + + for (FileAttachment a : attachments) { + if (a == null) throw new IllegalArgumentException("Attachment is null"); + String ft = a.getFileType(); + if (isImage && !"IMAGE".equalsIgnoreCase(ft)) + throw new IllegalArgumentException("All attachments must be IMAGE for messageType=IMAGE"); + if (isAudio && !"AUDIO".equalsIgnoreCase(ft)) + throw new IllegalArgumentException("All attachments must be AUDIO for messageType=AUDIO"); + } + } + + insertMessageTx(conn, messageId, senderId, receiverId, receiverType, content, messageType.toUpperCase()); + if (!isText) insertAttachmentsTx(conn, messageId, attachments); + + conn.commit(); + return true; + } catch (Exception e) { + if (conn != null) try { conn.rollback(); } catch (SQLException ignored) {} + e.printStackTrace(); + return false; + } finally { + if (conn != null) { + try { conn.setAutoCommit(true); } catch (SQLException ignored) {} + try { conn.close(); } catch (SQLException ignored) {} + } + } + } + + public static void markGloballyDeleted(UUID chatId) { String sql = "UPDATE messages SET is_deleted_globally = true WHERE receiver_id = ? AND receiver_type = 'private'"; try (Connection conn = ConnectionDb.connect(); PreparedStatement ps = conn.prepareStatement(sql)) { @@ -554,30 +684,34 @@ public class MessageDatabase { public static List getAttachments(UUID messageId) { List attachments = new ArrayList<>(); - String sql = "SELECT file_url, file_type FROM message_attachments WHERE message_id = ?"; - + String sql = "SELECT file_url, file_type, file_name, file_size, mime_type, width, height, duration_seconds, thumbnail_url " + + "FROM message_attachments WHERE message_id = ? ORDER BY uploaded_at"; try (Connection conn = ConnectionDb.connect(); PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setObject(1, messageId); ResultSet rs = stmt.executeQuery(); - while (rs.next()) { attachments.add(new FileAttachment( rs.getString("file_url"), - rs.getString("file_type") + rs.getString("file_type"), + rs.getString("file_name"), + (Long) rs.getObject("file_size"), + rs.getString("mime_type"), + (Integer) rs.getObject("width"), + (Integer) rs.getObject("height"), + (Integer) rs.getObject("duration_seconds"), + rs.getString("thumbnail_url") )); } - } catch (SQLException e) { e.printStackTrace(); } - return attachments; } + public static LocalDateTime getLastMessageTimeBetween(UUID user1, UUID user2, String type) { String sql = """ SELECT MAX(send_at) FROM messages @@ -1163,6 +1297,173 @@ public class MessageDatabase { } +// +// public static MediaRow findMediaByKey(UUID mediaKey) throws SQLException { +// final String sql = """ +// SELECT a.message_id, a.storage_path, a.file_name, a.mime_type, a.file_size, +// m.receiver_type, m.receiver_id, m.sender_id +// FROM message_attachments a +// JOIN messages m ON m.message_id = a.message_id +// WHERE a.media_key = ? +// """; +// try (Connection c = ConnectionDb.connect(); +// PreparedStatement ps = c.prepareStatement(sql)) { +// ps.setObject(1, mediaKey); +// try (ResultSet rs = ps.executeQuery()) { +// if (!rs.next()) return null; +// MediaRow mr = new MediaRow(); +// mr.messageId = (UUID) rs.getObject(1); +// mr.storagePath = rs.getString(2); +// mr.fileName = rs.getString(3); +// mr.mimeType = rs.getString(4); +// mr.fileSize = rs.getLong(5); +// mr.receiverType= rs.getString(6); +// mr.receiverId = (UUID) rs.getObject(7); +// mr.senderId = (UUID) rs.getObject(8); +// return mr; +// } +// } +// } + +// public static boolean canAccess(UUID requester, MediaRow mr) { +// if ("private".equals(mr.receiverType)) { +// return requester.equals(mr.senderId) || requester.equals(mr.receiverId); +// } else if ("group".equals(mr.receiverType)) { +// return GroupDatabase.isMember(mr.receiverId, requester); +// } else if ("channel".equals(mr.receiverType)) { +// return ChannelDatabase.isUserInChannel(mr.receiverId, requester); +// } +// return false; +// } + + public static Map> findAttachmentsForMessages(List ids) throws SQLException { + Map> map = new java.util.HashMap<>(); + if (ids == null || ids.isEmpty()) return map; + + // ساخت IN به‌صورت امن + String placeholders = ids.stream().map(x -> "?").collect(java.util.stream.Collectors.joining(",")); + String sql = """ + SELECT attachment_id, message_id, media_key, file_name, file_size, mime_type, file_type, + width, height, duration_seconds, thumbnail_url, file_url, storage_path + FROM message_attachments + WHERE message_id IN (""" + placeholders + ") ORDER BY uploaded_at ASC"; + + try (Connection c = ConnectionDb.connect(); + PreparedStatement ps = c.prepareStatement(sql)) { + int i = 1; + for (UUID id : ids) ps.setObject(i++, id); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + MediaRow a = new MediaRow(); + a.attachmentId = (UUID) rs.getObject("attachment_id"); + a.messageId = (UUID) rs.getObject("message_id"); + a.mediaKey = (UUID) rs.getObject("media_key"); + a.fileName = rs.getString("file_name"); + long sz = rs.getLong("file_size"); + a.fileSize = rs.wasNull() ? null : sz; + a.mimeType = rs.getString("mime_type"); + a.fileType = rs.getString("file_type"); + int w = rs.getInt("width"); + a.width = rs.wasNull() ? null : w; + int h = rs.getInt("height"); + a.height = rs.wasNull() ? null : h; + int d = rs.getInt("duration_seconds"); + a.durationSeconds = rs.wasNull() ? null : d; + a.thumbnailUrl = rs.getString("thumbnail_url"); + a.fileUrl = rs.getString("file_url"); + a.storagePath = rs.getString("storage_path"); + + map.computeIfAbsent(a.messageId, k -> new java.util.ArrayList<>()).add(a); + } + } + } + return map; + } + + + + + + public static MediaRow findMediaByKey(UUID mediaKey) throws SQLException { + String sql = """ + SELECT + ma.attachment_id, + ma.message_id, + ma.media_key, + ma.file_name, + ma.file_size, + ma.mime_type, + ma.file_type, + ma.width, + ma.height, + ma.duration_seconds, + ma.thumbnail_url, + ma.file_url, + ma.storage_path, + m.receiver_type, + m.receiver_id, + m.sender_id + FROM message_attachments ma + JOIN messages m ON m.message_id = ma.message_id + WHERE ma.media_key = ? + LIMIT 1 + """; + + try (Connection c = ConnectionDb.connect(); + PreparedStatement ps = c.prepareStatement(sql)) { + ps.setObject(1, mediaKey); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + + MediaRow a = new MediaRow(); + a.attachmentId = (UUID) rs.getObject("attachment_id"); + a.messageId = (UUID) rs.getObject("message_id"); + a.mediaKey = (UUID) rs.getObject("media_key"); + a.fileName = rs.getString("file_name"); + + long sz = rs.getLong("file_size"); + a.fileSize = rs.wasNull() ? null : sz; // MediaRow.fileSize = Long + + a.mimeType = rs.getString("mime_type"); + a.fileType = rs.getString("file_type"); + int w = rs.getInt("width"); a.width = rs.wasNull() ? null : w; + int h = rs.getInt("height"); a.height = rs.wasNull() ? null : h; + int d = rs.getInt("duration_seconds"); a.durationSeconds = rs.wasNull() ? null : d; + a.thumbnailUrl = rs.getString("thumbnail_url"); + a.fileUrl = rs.getString("file_url"); + a.storagePath = rs.getString("storage_path"); + a.receiverType = rs.getString("receiver_type"); + a.receiverId = (UUID) rs.getObject("receiver_id"); + a.senderId = (UUID) rs.getObject("sender_id"); + return a; + } + } + } + + + public static boolean canAccess(UUID requester, MediaRow mr) { + if (requester == null || mr == null || mr.receiverType == null) return false; + + // اختیاری: فرستنده همیشه مجاز + if (requester.equals(mr.senderId)) return true; + + switch (mr.receiverType.toLowerCase(Locale.ROOT)) { + case "private": + // receiver_id در پیام‌های private = UUID چت خصوصی + return PrivateChatDatabase.isParticipant(mr.receiverId, requester); + + case "group": + return GroupDatabase.isMember(mr.receiverId, requester); + + case "channel": + return ChannelPermissionUtil.isUserInChannel(requester, mr.receiverId); + + default: + return false; + } + } + + public static Message getLastMessage(UUID targetId, String type) { final String sql = "SELECT * FROM messages " + diff --git a/src/main/java/org/to/telegramfinalproject/Database/MessageReactionDatabase.java b/src/main/java/org/to/telegramfinalproject/Database/MessageReactionDatabase.java index d027a5a..65ade2d 100644 --- a/src/main/java/org/to/telegramfinalproject/Database/MessageReactionDatabase.java +++ b/src/main/java/org/to/telegramfinalproject/Database/MessageReactionDatabase.java @@ -1,6 +1,7 @@ package org.to.telegramfinalproject.Database; import org.json.JSONObject; +import org.to.telegramfinalproject.Models.MediaRow; import java.sql.Connection; import java.sql.PreparedStatement; @@ -8,6 +9,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.UUID; public class MessageReactionDatabase { @@ -73,4 +75,8 @@ public class MessageReactionDatabase { return counts; // مثال: {"❤️":2,"👍":1} } + + + + } diff --git a/src/main/java/org/to/telegramfinalproject/Database/PrivateChatDatabase.java b/src/main/java/org/to/telegramfinalproject/Database/PrivateChatDatabase.java index a666c34..6424fdb 100644 --- a/src/main/java/org/to/telegramfinalproject/Database/PrivateChatDatabase.java +++ b/src/main/java/org/to/telegramfinalproject/Database/PrivateChatDatabase.java @@ -388,5 +388,26 @@ public class PrivateChatDatabase { rs.getBoolean("user2_deleted") ); } + public static boolean isParticipant(java.util.UUID chatId, java.util.UUID userId) { + String sql = """ + SELECT 1 + FROM private_chat + WHERE chat_id = ? + AND (user1_id = ? OR user2_id = ?) + LIMIT 1 + """; + try (var c = ConnectionDb.connect(); + var ps = c.prepareStatement(sql)) { + ps.setObject(1, chatId); + ps.setObject(2, userId); + ps.setObject(3, userId); + try (var rs = ps.executeQuery()) { + return rs.next(); + } + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } } diff --git a/src/main/java/org/to/telegramfinalproject/Models/Contact.java b/src/main/java/org/to/telegramfinalproject/Models/Contact.java index d85ae00..1240f21 100644 --- a/src/main/java/org/to/telegramfinalproject/Models/Contact.java +++ b/src/main/java/org/to/telegramfinalproject/Models/Contact.java @@ -19,6 +19,7 @@ public class Contact { this.added_at = LocalDateTime.now(); } + public void setUser_id(UUID user_id){this.user_id = user_id;} public void setContact_id(UUID contact_id){this.contact_id = contact_id;} public void setAdd_at(LocalDateTime add_at){this.added_at =add_at;} diff --git a/src/main/java/org/to/telegramfinalproject/Models/FileAttachment.java b/src/main/java/org/to/telegramfinalproject/Models/FileAttachment.java index 430e457..f3f5d9c 100644 --- a/src/main/java/org/to/telegramfinalproject/Models/FileAttachment.java +++ b/src/main/java/org/to/telegramfinalproject/Models/FileAttachment.java @@ -1,19 +1,162 @@ package org.to.telegramfinalproject.Models; -public class FileAttachment { - private String fileUrl; - private String fileType; +import org.json.JSONObject; +import java.util.Objects; +import java.util.UUID; - public FileAttachment(String fileUrl, String fileType) { +public class FileAttachment { + + private UUID attachmentId; // اختیاری؛ اگر null بود، تولید می‌کنیم + private UUID mediaKey; + private String fileUrl; + private String fileType; // IMAGE, VIDEO, AUDIO, FILE, GIF, STICKER + private String fileName; + private Long fileSize; + private String mimeType; // e.g., image/png + private Integer width; + private Integer height; + private Integer durationSeconds; // for audio/video + private String thumbnailUrl; + private String storagePath; + + public FileAttachment(String fileUrl, + String fileType, + String fileName, + Long fileSize, + String mimeType, + Integer width, + Integer height, + Integer durationSeconds, + String thumbnailUrl) { this.fileUrl = fileUrl; this.fileType = fileType; + this.fileName = fileName; + this.fileSize = fileSize; + this.mimeType = mimeType; + this.width = width; + this.height = height; + this.durationSeconds = durationSeconds; + this.thumbnailUrl = thumbnailUrl; } - public String getFileUrl() { - return fileUrl; + public FileAttachment(String fileUrl, String fileType) { + this(fileUrl, fileType, null, null, null, null, null, null, null); } - public String getFileType() { - return fileType; + public FileAttachment() { + } + + + // ساخت از JSON /upload + public static FileAttachment fromUploadJson(JSONObject j) { + return new FileAttachment( + j.optString("file_url", ""), + j.optString("file_type", "FILE"), + emptyToNull(j.optString("file_name", null)), + j.has("file_size") && !j.isNull("file_size") ? j.getLong("file_size") : null, + emptyToNull(j.optString("mime_type", null)), + j.has("width") && !j.isNull("width") ? j.getInt("width") : null, + j.has("height") && !j.isNull("height") ? j.getInt("height") : null, + j.has("duration_seconds") && !j.isNull("duration_seconds") ? j.getInt("duration_seconds") : null, + j.isNull("thumbnail_url") ? null : emptyToNull(j.optString("thumbnail_url", null)) + ); + } + + public JSONObject toJson() { + JSONObject out = new JSONObject() + .put("file_url", fileUrl) + .put("file_type", fileType); + + out.put("file_name", fileName == null ? JSONObject.NULL : fileName); + out.put("file_size", fileSize == null ? JSONObject.NULL : fileSize); + out.put("mime_type", mimeType == null ? JSONObject.NULL : mimeType); + out.put("width", width == null ? JSONObject.NULL : width); + out.put("height", height == null ? JSONObject.NULL : height); + out.put("duration_seconds", durationSeconds == null ? JSONObject.NULL : durationSeconds); + out.put("thumbnail_url", thumbnailUrl == null ? JSONObject.NULL : thumbnailUrl); + return out; + } + + // Helpers + public boolean isImage() { return "IMAGE".equalsIgnoreCase(fileType) || "GIF".equalsIgnoreCase(fileType); } + public boolean isAudio() { return "AUDIO".equalsIgnoreCase(fileType); } + public boolean hasDimensions() { return width != null && height != null; } + + private static String emptyToNull(String s) { + return (s == null || s.isBlank()) ? null : s; + } + + // Getters + public String getFileUrl() { return fileUrl; } + public String getFileType() { return fileType; } + public String getFileName() { return fileName; } + public Long getFileSize() { return fileSize; } + public String getMimeType() { return mimeType; } + public Integer getWidth() { return width; } + public Integer getHeight() { return height; } + public Integer getDurationSeconds() { return durationSeconds; } + public String getThumbnailUrl() { return thumbnailUrl; } + + // equals/hashCode/toString + @Override public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof FileAttachment)) return false; + FileAttachment that = (FileAttachment) o; + return Objects.equals(fileUrl, that.fileUrl) && + Objects.equals(fileType, that.fileType) && + Objects.equals(fileName, that.fileName) && + Objects.equals(fileSize, that.fileSize) && + Objects.equals(mimeType, that.mimeType) && + Objects.equals(width, that.width) && + Objects.equals(height, that.height) && + Objects.equals(durationSeconds, that.durationSeconds) && + Objects.equals(thumbnailUrl, that.thumbnailUrl); + } + + @Override public int hashCode() { + return Objects.hash(fileUrl, fileType, fileName, fileSize, mimeType, width, height, durationSeconds, thumbnailUrl); + } + + @Override public String toString() { + return "FileAttachment{" + + "fileUrl='" + fileUrl + '\'' + + ", fileType='" + fileType + '\'' + + ", fileName='" + fileName + '\'' + + ", fileSize=" + fileSize + + ", mimeType='" + mimeType + '\'' + + ", width=" + width + + ", height=" + height + + ", durationSeconds=" + durationSeconds + + ", thumbnailUrl='" + thumbnailUrl + '\'' + + '}'; + } + + public UUID getAttachmentId() {return attachmentId; + } + + public UUID getMediaKey() {return mediaKey; + } + + public String getStoragePath() {return storagePath; + } + + public void setAttachmentId(UUID attachmentId) {this.attachmentId = attachmentId; + } + + public void setMediaKey(UUID mediaKey) {this.mediaKey = mediaKey; + } + + + public void setFileUrl(String fileUrl) {this.fileUrl = fileUrl; + } + public void setFileType(String fileType){this.fileType = fileType;} + public void setFileName(String fileName){this.fileName = fileName;} + public void setFileSize(Long fileSize){this.fileSize = fileSize;} + public void setMimeType(String mimeType){this.mimeType = mimeType;} + public void setWidth(int width){this.width = width;} + public void setHeight(int height){this.height = height;} + public void setDurationSeconds(Integer durationSeconds){this.durationSeconds = durationSeconds;} + public void setThumbnailUrl(String thumbnailUrl){this.thumbnailUrl = thumbnailUrl;} + public void setStoragePath(String storagePath) {this.storagePath = storagePath; } } diff --git a/src/main/java/org/to/telegramfinalproject/Models/MediaRow.java b/src/main/java/org/to/telegramfinalproject/Models/MediaRow.java new file mode 100644 index 0000000..0332ff3 --- /dev/null +++ b/src/main/java/org/to/telegramfinalproject/Models/MediaRow.java @@ -0,0 +1,24 @@ +package org.to.telegramfinalproject.Models; + +import java.util.UUID; + +public class MediaRow { + public UUID messageId; + public String storagePath; + public String fileName; + public String mimeType; + public Long fileSize; + public String receiverType; + public UUID receiverId; + public UUID senderId; + public java.util.UUID attachmentId; + public java.util.UUID mediaKey; + public String fileType; // IMAGE/AUDIO/... + public Integer width; + public Integer height; + public Integer durationSeconds; //for audio only + public String thumbnailUrl; + public String fileUrl; //display link + public String chatType; + public UUID chatId; +} diff --git a/src/main/java/org/to/telegramfinalproject/Server/ClientHandler.java b/src/main/java/org/to/telegramfinalproject/Server/ClientHandler.java index cccdc4c..21c9754 100644 --- a/src/main/java/org/to/telegramfinalproject/Server/ClientHandler.java +++ b/src/main/java/org/to/telegramfinalproject/Server/ClientHandler.java @@ -12,6 +12,10 @@ import org.to.telegramfinalproject.Utils.GroupPermissionUtil; import java.io.*; import java.net.Socket; import java.sql.Connection; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; import java.time.LocalDateTime; import java.util.*; @@ -20,6 +24,15 @@ public class ClientHandler implements Runnable { private final AuthService authService = new AuthService(); private User currentUser; + // ClientHandler.java + private static void log(String msg) { + System.out.println(java.time.LocalDateTime.now() + " [ClientHandler] " + msg); + } + private static void logf(String fmt, Object... args) { + log(String.format(fmt, args)); + } + + public ClientHandler(Socket socket) { this.socket = socket; @@ -30,11 +43,60 @@ public class ClientHandler implements Runnable { UUID userId = null; try ( - BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); - PrintWriter out = new PrintWriter(socket.getOutputStream(), true) + +// InputStream rawIn = socket.getInputStream(); +// OutputStream rawOut = socket.getOutputStream(); +// +// BufferedReader in = new BufferedReader(new InputStreamReader(rawIn, java.nio.charset.StandardCharsets.UTF_8)); +// PrintWriter out = new PrintWriter(new OutputStreamWriter(rawOut, java.nio.charset.StandardCharsets.UTF_8), true); + +// DataInputStream dis = new DataInputStream(rawIn); +// DataOutputStream dos = new DataOutputStream(new BufferedOutputStream(rawOut)); + +// BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); +// PrintWriter out = new PrintWriter(socket.getOutputStream(), true) +// BufferedInputStream bis = new BufferedInputStream(socket.getInputStream()); + // DataInputStream dis = new DataInputStream(bis); //for binary headers + + //PrintWriter out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), java.nio.charset.StandardCharsets.UTF_8), true); + InputStream rawIn = socket.getInputStream(); + OutputStream rawOut = socket.getOutputStream(); + + BufferedInputStream bis = new BufferedInputStream(rawIn); + BufferedOutputStream bos = new BufferedOutputStream(rawOut); + + DataInputStream dis = new DataInputStream(bis); + DataOutputStream dos = new DataOutputStream(bos); + PrintWriter out = new PrintWriter(new OutputStreamWriter(bos, java.nio.charset.StandardCharsets.UTF_8), true); + ) { + +// DataInputStream bin = new DataInputStream(new BufferedInputStream(socket.getInputStream())); + String inputLine; - while ((inputLine = in.readLine()) != null) { + while ((inputLine = readUtf8Line(bis)) != null) { + + String line = inputLine.trim(); + + if ("MEDIA".equalsIgnoreCase(inputLine.trim())) { + handleMediaFrame(dis, out); + continue; + } + + + if ("MEDIA_DL".equalsIgnoreCase(line)) { + UUID cu = (currentUser == null ? null : currentUser.getInternal_uuid()); + logf("MEDIA_DL received. currentUser.internal_uuid=%s", cu); + + if (cu == null) { + log("MEDIA_DL rejected: currentUser is null or no internal_uuid"); + sendDlErr(dos, "not authorized"); + continue; + } + handleMediaDownload(dis, dos, cu); + continue; + } + JSONObject requestJson = new JSONObject(inputLine); String action = requestJson.getString("action"); ResponseModel response = null; @@ -2275,6 +2337,13 @@ public class ClientHandler implements Runnable { List messages = MessageDatabase.getMessagesForChat(chatId, chatType, currentUser.getInternal_uuid(), offset, limit); + java.util.List mids = new java.util.ArrayList<>(); + for (Message m : messages) mids.add(m.getMessage_id()); + + // ⬅️ همهٔ اتچمنت‌ها را یک‌جا بگیر: message_id -> list(attachments) + java.util.Map> attMap = + MessageDatabase.findAttachmentsForMessages(mids); + JSONArray result = new JSONArray(); for (Message m : messages) { JSONObject obj = new JSONObject(); @@ -2342,6 +2411,26 @@ public class ClientHandler implements Runnable { obj.put("reactions", new JSONArray(reactions)); + JSONArray atts = new JSONArray(); + java.util.List list = attMap.getOrDefault(m.getMessage_id(), java.util.Collections.emptyList()); + for (MediaRow a : list) { + JSONObject aj = new JSONObject() + .put("media_key", a.mediaKey != null ? a.mediaKey.toString() : JSONObject.NULL) + .put("file_name", a.fileName != null ? a.fileName : JSONObject.NULL) + .put("file_size", a.fileSize != null ? a.fileSize : JSONObject.NULL) + .put("mime_type", a.mimeType != null ? a.mimeType : JSONObject.NULL) + .put("file_type", a.fileType != null ? a.fileType : JSONObject.NULL) + .put("width", a.width != null ? a.width : JSONObject.NULL) + .put("height", a.height != null ? a.height : JSONObject.NULL) + .put("duration_seconds", a.durationSeconds != null ? a.durationSeconds : JSONObject.NULL) + .put("thumbnail_url", a.thumbnailUrl != null ? a.thumbnailUrl : JSONObject.NULL) + // اختیاری/دیباگ + .put("file_url", a.fileUrl != null ? a.fileUrl : JSONObject.NULL); + atts.put(aj); + } + obj.put("attachments", atts); + + result.put(obj); } @@ -2870,7 +2959,9 @@ public class ClientHandler implements Runnable { userDatabase.updateLastSeen(userId); SessionManager.removeUser(userId); } - } finally { + } catch (SQLException e) { + throw new RuntimeException(e); + } finally { try { if (currentUser != null) { //RealTime @@ -2895,9 +2986,606 @@ public class ClientHandler implements Runnable { } + private static String readUtf8Line(BufferedInputStream bis) throws java.io.IOException { + StringBuilder sb = new StringBuilder(); + while (true) { + int b = bis.read(); + if (b == -1) { + return sb.length() == 0 ? null : sb.toString(); + } + if (b == '\n') { + int len = sb.length(); + if (len > 0 && sb.charAt(len - 1) == '\r') sb.setLength(len - 1); + return sb.toString(); + } + sb.append((char) b); + } + } + + +// private void handleMediaFrame(DataInputStream dis, PrintWriter out) { +// try { +// // MAGIC = "MDM1" +// final int MAGIC_EXPECTED = 0x4D444D31; +// int magic = dis.readInt(); +// if (magic != MAGIC_EXPECTED) { +// out.println(new JSONObject().put("status","error").put("message","bad magic").toString()); +// out.flush(); +// return; +// } +// +// int headerLen = dis.readInt(); +// if (headerLen <= 0 || headerLen > (64 * 1024)) { +// out.println(new JSONObject().put("status","error").put("message","bad header length").toString()); +// out.flush(); +// return; +// } +// +// byte[] headerBytes = dis.readNBytes(headerLen); +// if (headerBytes.length != headerLen) { +// out.println(new JSONObject().put("status","error").put("message","header truncated").toString()); +// out.flush(); +// return; +// } +// JSONObject h = new JSONObject(new String(headerBytes, java.nio.charset.StandardCharsets.UTF_8)); +// +// long contentLen = dis.readLong(); +// long MAX_MEDIA = 25L * 1024 * 1024; +// if (contentLen <= 0 || contentLen > MAX_MEDIA) { +// skip(dis, contentLen); +// out.println(new JSONObject().put("status","error").put("message","file too large/invalid").toString()); +// out.flush(); +// return; +// } +// +// UUID messageId = UUID.fromString(h.getString("message_id")); +// UUID senderId = UUID.fromString(h.getString("sender_id")); +// String rType = h.getString("receiver_type"); // private/group/channel +// UUID receiverId = UUID.fromString(h.getString("receiver_id")); +// String messageType = h.getString("message_type"); // IMAGE | AUDIO +// +// if (!"IMAGE".equalsIgnoreCase(messageType) && !"AUDIO".equalsIgnoreCase(messageType)) { +// skip(dis, contentLen); +// out.println(new JSONObject().put("status","error").put("message","unsupported message_type").toString()); +// out.flush(); +// return; +// } +// +// String fileName = h.optString("file_name", "file.bin"); +// String mimeType = h.optString("mime_type", "application/octet-stream"); +// String text = h.optString("text", ""); +// +// Integer width = h.has("width") && !h.isNull("width") ? h.getInt("width") : null; +// Integer height = h.has("height") && !h.isNull("height") ? h.getInt("height") : null; +// +// if (fileName.length() > 200) fileName = fileName.substring(0, 200); +// +// // مسیر ذخیره +// java.nio.file.Path baseDir = java.nio.file.Paths.get("uploads").toAbsolutePath().normalize(); +// java.nio.file.Files.createDirectories(baseDir); +// String kind = "IMAGE".equalsIgnoreCase(messageType) ? "images" : "audios"; +// String subdir = kind + "/" + java.time.LocalDate.now(); +// java.nio.file.Path dir = baseDir.resolve(subdir).normalize(); +// java.nio.file.Files.createDirectories(dir); +// +// String ext = guessExt(fileName, mimeType); +// String storedName = java.util.UUID.randomUUID() + ext; +// java.nio.file.Path target = dir.resolve(storedName).normalize(); +// +// // دریافت بایت‌های فایل +// try (OutputStream fos = new BufferedOutputStream(java.nio.file.Files.newOutputStream( +// target, java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.TRUNCATE_EXISTING))) { +// long remaining = contentLen; +// byte[] buf = new byte[8192]; +// while (remaining > 0) { +// int toRead = (int) Math.min(buf.length, remaining); +// int n = dis.read(buf, 0, toRead); +// if (n == -1) throw new EOFException("stream ended early"); +// fos.write(buf, 0, n); +// remaining -= n; +// } +// } +// +// long fileSize = java.nio.file.Files.size(target); +// String fileUrl = "/" + subdir.replace('\\','/') + "/" + storedName; +// +// FileAttachment att = new FileAttachment( +// fileUrl, +// messageType.toUpperCase(), // IMAGE/AUDIO +// fileName, +// fileSize, +// mimeType, +// width, +// height, +// null, // durationSeconds +// null // thumbnailUrl +// ); +// +// boolean ok = MessageDatabase.saveMessageWithOptionalAttachments( +// messageId, senderId, receiverId, rType, text, messageType.toUpperCase(), java.util.List.of(att) +// ); +// +// JSONObject ack = new JSONObject() +// .put("status", ok ? "success" : "error") +// .put("message_id", messageId.toString()) +// .put("file_url", fileUrl) +// .put("file_size", fileSize) +// .put("mime_type", mimeType); +// +// out.println(ack.toString()); +// out.flush(); +// +// } catch (Exception e) { +// e.printStackTrace(); +// out.println(new JSONObject().put("status","error").put("message","exception").toString()); +// out.flush(); +// } +// } + + + private void handleMediaFrame(DataInputStream dis, PrintWriter out) { + try { + final int MAGIC_EXPECTED = 0x4D444D31; // "MDM1" + int magic = dis.readInt(); + if (magic != MAGIC_EXPECTED) { + out.println(new JSONObject().put("status","error").put("message","bad magic").toString()); out.flush(); return; + } + + int headerLen = dis.readInt(); + if (headerLen <= 0 || headerLen > 64 * 1024) { + out.println(new JSONObject().put("status","error").put("message","bad header length").toString()); out.flush(); return; + } + + byte[] headerBytes = dis.readNBytes(headerLen); + if (headerBytes.length != headerLen) { + out.println(new JSONObject().put("status","error").put("message","header truncated").toString()); out.flush(); return; + } + + JSONObject h = new JSONObject(new String(headerBytes, java.nio.charset.StandardCharsets.UTF_8)); + + long contentLen = dis.readLong(); + long MAX_MEDIA = 25L * 1024 * 1024; + if (contentLen <= 0 || contentLen > MAX_MEDIA) { + skip(dis, contentLen); + out.println(new JSONObject().put("status","error").put("message","file too large/invalid").toString()); out.flush(); return; + } + + UUID messageId = UUID.fromString(h.getString("message_id")); + UUID senderId = UUID.fromString(h.getString("sender_id")); + String rType = h.getString("receiver_type"); // private/group/channel + UUID receiverId = UUID.fromString(h.getString("receiver_id")); + String messageType = h.getString("message_type").toUpperCase(); // IMAGE | AUDIO + + if (!"IMAGE".equals(messageType) && !"AUDIO".equals(messageType)) { + skip(dis, contentLen); + out.println(new JSONObject().put("status","error").put("message","unsupported message_type").toString()); out.flush(); return; + } + + String fileName = h.optString("file_name", "file.bin"); + String mimeType = h.optString("mime_type", "application/octet-stream"); + String text = h.optString("text", ""); // کپشن اختیاری + + Integer width = h.has("width") && !h.isNull("width") ? h.getInt("width") : null; + Integer height = h.has("height") && !h.isNull("height") ? h.getInt("height") : null; + + if (fileName.length() > 200) fileName = fileName.substring(0, 200); + + java.nio.file.Path baseDir = java.nio.file.Paths.get("uploads").toAbsolutePath().normalize(); + java.nio.file.Files.createDirectories(baseDir); + String kind = "IMAGE".equals(messageType) ? "images" : "audios"; + String subdir = kind + "/" + java.time.LocalDate.now(); + java.nio.file.Path dir = baseDir.resolve(subdir).normalize(); + java.nio.file.Files.createDirectories(dir); + + String ext = guessExt(fileName, mimeType); + String storedName = java.util.UUID.randomUUID() + ext; + java.nio.file.Path target = dir.resolve(storedName).normalize(); + + try (OutputStream fos = new BufferedOutputStream(java.nio.file.Files.newOutputStream( + target, java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.TRUNCATE_EXISTING))) { + long remaining = contentLen; + byte[] buf = new byte[8192]; + while (remaining > 0) { + int toRead = (int) Math.min(buf.length, remaining); + int n = dis.read(buf, 0, toRead); + if (n == -1) throw new EOFException("stream ended early"); + fos.write(buf, 0, n); + remaining -= n; + } + } + + long fileSize = java.nio.file.Files.size(target); + + String storagePath = target.toString(); + String fileUrl = "/" + subdir.replace('\\','/') + "/" + storedName; + String mt = messageType; // "IMAGE" یا "AUDIO" + int safeWidth = ("IMAGE".equals(mt) && width != null) ? width : 0; + int safeHeight = ("IMAGE".equals(mt) && height != null) ? height : 0; + FileAttachment att = new FileAttachment(); + att.setFileUrl(fileUrl); + att.setFileType(messageType); // IMAGE/AUDIO + att.setFileName(fileName); + att.setFileSize(fileSize); + att.setMimeType(mimeType); + att.setWidth(safeWidth); + att.setHeight(safeHeight); + att.setDurationSeconds(0); + att.setThumbnailUrl(null); + att.setStoragePath(storagePath); + java.util.List atts = java.util.List.of(att); + + boolean ok = MessageDatabase.saveMessageWithOptionalAttachments( + messageId, senderId, receiverId, rType, text, messageType, atts + ); + + UUID mediaKey = null; + try (PreparedStatement q = ConnectionDb.connect().prepareStatement( + "SELECT media_key FROM message_attachments WHERE message_id = ? AND storage_path = ? LIMIT 1" + )) { + q.setObject(1, messageId); + q.setString(2, storagePath); + try (ResultSet rs = q.executeQuery()) { + if (rs.next()) mediaKey = (UUID) rs.getObject(1); + } + } catch (SQLException sqle) { + sqle.printStackTrace(); + } + + JSONObject ack = new JSONObject() + .put("status", ok ? "success" : "error") + .put("message_id", messageId.toString()) + .put("media_key", mediaKey != null ? mediaKey.toString() : JSONObject.NULL) + .put("file_name", fileName) + .put("file_size", fileSize) + .put("mime_type", mimeType) + .put("display_path", fileUrl); + + out.println(ack.toString()); + out.flush(); + + + // بعد از out.flush(); و فقط اگر ok==true + if (ok) { + try { + // 1) دریافت پیام از DB تا send_at و... دقیق باشد + Message m = MessageDatabase.findById(messageId); // اگر چنین متدی نداری، با پارامترهای همین متد بساز/پر کن + + // 2) لیست دریافت‌کنندگان بر اساس نوع چت + List receivers = getReceiversForChat(receiverId, rType.toLowerCase()); + + + // 3) ساخت payload شامل اتچمنت (media) + User sender = userDatabase.findByInternalUUID(senderId); + JSONObject payload = new JSONObject() + .put("action", "new_message") + .put("data", new JSONObject() + .put("id", m.getMessage_id().toString()) + .put("chat_id", receiverId.toString()) + .put("chat_type", rType.toLowerCase()) + .put("sender_id", senderId.toString()) + .put("sender_name", sender != null ? sender.getProfile_name() : JSONObject.NULL) + .put("message_type", messageType.toLowerCase()) + .put("text", (text == null || text.isEmpty()) ? JSONObject.NULL : text) + .put("media", new JSONObject() + .put("media_id", mediaKey != null ? mediaKey.toString() : JSONObject.NULL) + .put("file_name", fileName) + .put("mime_type", mimeType) + .put("size_bytes", fileSize) + .put("url", fileUrl) + .put("thumbnail_url", JSONObject.NULL) + .put("width", safeWidth) + .put("height", safeHeight) + .put("duration_ms", 0) + ) + .put("send_at", m.getSend_at().toString()) + .put("status", "SENT") + ); + + // 4) ارسال به همه اعضا (از جمله خودِ فرستنده اگر می‌خواهی UI آن هم یکپارچه آپدیت شود) + for (UUID uid : receivers) { + RealTimeEventDispatcher.sendToUser(uid, payload); + } + + // (اختیاری) رویداد آپدیت چت‌لیست برای sort بر اساس آخرین پیام + RealTimeEventDispatcher.notifyChatUpdated(receiverId, rType, m); + + } catch (Exception ex) { + ex.printStackTrace(); + // اگر ذخیره شد ولی Broadcast شکست خورد، می‌توانی Log کنی یا Retry سبک انجام دهی + } + } + + + } catch (Exception e) { + e.printStackTrace(); + out.println(new JSONObject().put("status","error").put("message","exception").toString()); + out.flush(); + } + } + + private static final int MAGIC_DL = 0x4D444D32; // "MDM2" + +// private void handleMediaDownload(DataInputStream inBin, DataOutputStream outBin, UUID requesterId) { +// try { +// int magic = inBin.readInt(); +// if (magic != MAGIC_DL) { sendDlErr(outBin, "bad magic"); return; } +// +// int hlen = inBin.readInt(); +// if (hlen <= 0 || hlen > 64 * 1024) { sendDlErr(outBin, "bad header length"); return; } +// +// byte[] hb = inBin.readNBytes(hlen); +// if (hb.length != hlen) { sendDlErr(outBin, "header truncated"); return; } +// +// JSONObject hdr = new JSONObject(new String(hb, java.nio.charset.StandardCharsets.UTF_8)); +// if (!"download".equalsIgnoreCase(hdr.optString("op"))) { sendDlErr(outBin, "bad op"); return; } +// +// UUID mediaKey = UUID.fromString(hdr.getString("media_key")); +// long offset = Math.max(0L, hdr.optLong("offset", 0L)); +// +// MediaRow mr = MessageDatabase.findMediaByKey(mediaKey); +// if (mr == null) { sendDlErr(outBin, "not found"); return; } +// if (!MessageDatabase.canAccess(requesterId, mr)) { sendDlErr(outBin, "not authorized"); return; } +// +// java.nio.file.Path path = java.nio.file.Paths.get(mr.storagePath).normalize(); +// long size = java.nio.file.Files.size(path); +// if (offset > size) offset = 0L; +// +// JSONObject ok = new JSONObject() +// .put("status","success") +// .put("media_key", mediaKey.toString()) +// .put("file_name", mr.fileName) +// .put("mime_type", mr.mimeType) +// .put("file_size", size); +// +// byte[] okb = ok.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); +// +// outBin.writeInt(MAGIC_DL); +// outBin.writeInt(okb.length); +// outBin.write(okb); +// outBin.writeLong(size - offset); +// +// try (java.io.InputStream fis = new java.io.BufferedInputStream(java.nio.file.Files.newInputStream(path))) { +// if (offset > 0) fis.skipNBytes(offset); +// byte[] buf = new byte[8192]; +// long remain = size - offset; +// while (remain > 0) { +// int n = fis.read(buf, 0, (int) Math.min(buf.length, remain)); +// if (n == -1) break; +// outBin.write(buf, 0, n); +// remain -= n; +// } +// } +// outBin.flush(); +// +// } catch (Exception e) { +// e.printStackTrace(); +// try { sendDlErr(outBin, "exception"); } catch (Exception ignored) {} +// } +// } + + private void handleMediaDownload(DataInputStream inBin, DataOutputStream outBin, UUID requesterId) { + try { + logf("MEDIA_DL start. requester=%s", requesterId); + + int magic = inBin.readInt(); + if (magic != MAGIC_DL) { sendDlErr(outBin, "bad magic"); return; } + + int hlen = inBin.readInt(); + if (hlen <= 0 || hlen > 64 * 1024) { sendDlErr(outBin, "bad header length"); return; } + + byte[] hb = inBin.readNBytes(hlen); + if (hb.length != hlen) { sendDlErr(outBin, "header truncated"); return; } + + String hdrStr = new String(hb, java.nio.charset.StandardCharsets.UTF_8); + logf("MEDIA_DL header: %s", hdrStr); + + JSONObject hdr = new JSONObject(hdrStr); + if (!"download".equalsIgnoreCase(hdr.optString("op"))) { sendDlErr(outBin, "bad op"); return; } + + UUID mediaKey = UUID.fromString(hdr.getString("media_key")); + long offset = Math.max(0L, hdr.optLong("offset", 0L)); + logf("Parsed mediaKey=%s offset=%d", mediaKey, offset); + + MediaRow mr = MessageDatabase.findMediaByKey(mediaKey); + if (mr == null) { sendDlErr(outBin, "not found"); return; } + + logf("MediaRow: chatType=%s chatId=%s sender=%s receiver=%s storage=%s", + mr.chatType, mr.chatId, mr.senderId, mr.receiverId, mr.storagePath); + + try (java.sql.Connection c = ConnectionDb.connect(); + java.sql.PreparedStatement st = c.prepareStatement( + "SELECT 1 FROM channel_subscribers WHERE channel_id = ? AND user_id = ? LIMIT 1")) { + st.setObject(1, mr.chatId, java.sql.Types.OTHER); + st.setObject(2, requesterId, java.sql.Types.OTHER); + boolean direct; + try (java.sql.ResultSet r = st.executeQuery()) { direct = r.next(); } + logf("[DL] direct channel membership ch=%s user=%s => %s", mr.chatId, requesterId, direct); + } catch (Exception e) { + logf("[DL] direct membership check ERROR: %s", e.toString()); + } + + boolean allowed = MessageDatabase.canAccess(requesterId, mr); + logf("canAccess(..) -> %s", allowed); + if (!allowed) { sendDlErr(outBin, "not authorized"); return; } + + java.nio.file.Path path = java.nio.file.Paths.get(mr.storagePath).normalize(); + long size = java.nio.file.Files.size(path); + if (offset > size) offset = 0L; + + JSONObject ok = new JSONObject() + .put("status","success") + .put("media_key", mediaKey.toString()) + .put("file_name", mr.fileName) + .put("mime_type", mr.mimeType) + .put("file_size", size); + + byte[] okb = ok.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + + outBin.writeInt(MAGIC_DL); + outBin.writeInt(okb.length); + outBin.write(okb); + outBin.writeLong(size - offset); + logf("Sending OK header. file=%s size=%d offset=%d", mr.fileName, size, offset); + + try (java.io.InputStream fis = new java.io.BufferedInputStream(java.nio.file.Files.newInputStream(path))) { + if (offset > 0) fis.skipNBytes(offset); + byte[] buf = new byte[8192]; + long remain = size - offset; + while (remain > 0) { + int n = fis.read(buf, 0, (int) Math.min(buf.length, remain)); + if (n == -1) break; + outBin.write(buf, 0, n); + remain -= n; + } + } + outBin.flush(); + log("MEDIA_DL done."); + + } catch (Exception e) { + e.printStackTrace(); + try { sendDlErr(outBin, "exception"); } catch (Exception ignored) {} + } + } + + private void sendDlErr(DataOutputStream outBin, String msg) throws java.io.IOException { + JSONObject j = new JSONObject().put("status","error").put("message", msg); + byte[] b = j.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + outBin.writeInt(MAGIC_DL); + outBin.writeInt(b.length); + outBin.write(b); + outBin.writeLong(0L); + outBin.flush(); + } + + private static void skip(DataInputStream dis, long n) throws IOException { + if (n <= 0) return; + byte[] buf = new byte[8192]; + long left = n; + while (left > 0) { + int toRead = (int) Math.min(buf.length, left); + int r = dis.read(buf, 0, toRead); + if (r == -1) break; // EOF + left -= r; + } + } + + private static String guessExt(String original, String mime) { + if (original != null && original.contains(".")) { + String ext = original.substring(original.lastIndexOf('.')); + if (ext.length() <= 10) return ext.toLowerCase(); + } + if (mime == null) return ""; + + String m = mime.toLowerCase(); + + if (m.equals("image/png")) return ".png"; + if (m.equals("image/jpeg") || m.equals("image/jpg")) return ".jpg"; + if (m.equals("image/gif")) return ".gif"; + if (m.equals("image/webp")) return ".webp"; + + if (m.equals("audio/mpeg") || m.equals("audio/mp3")) return ".mp3"; + if (m.equals("audio/ogg")) return ".ogg"; + if (m.equals("audio/opus")) return ".opus"; + if (m.equals("audio/wav") || m.equals("audio/x-wav")) return ".wav"; + if (m.equals("audio/m4a") || m.equals("audio/mp4")) return ".m4a"; + +// if (m.equals("video/mp4")) return ".mp4"; +// if (m.equals("video/webm")) return ".webm"; + + // fallback + if (m.startsWith("image/")) return ""; + if (m.startsWith("audio/")) return ""; + if (m.startsWith("video/")) return ""; + + return ""; + } + + + + + + +// private ResponseModel handleSendMessage(JSONObject json) { +// +// try { +// if (currentUser == null) +// return new ResponseModel("error", "Unauthorized. Please login first."); +// +// UUID messageId = UUID.randomUUID(); +// UUID senderId = currentUser.getInternal_uuid(); +// String receiverType = json.getString("receiver_type"); +// UUID receiverId; +// receiverId = UUID.fromString(json.getString("receiver_id")); +// +// if(Objects.equals(receiverType, "private")){ +// PrivateChatDatabase.clearDeletedFlag(senderId, receiverId); +// UUID other = PrivateChatDatabase.getOtherParticipant(receiverId, senderId); +// if (other == null) { +// return new ResponseModel("error", "Invalid private chat."); +// } +// if (ContactDatabase.isBlocked(senderId, other) || ContactDatabase.isBlocked(other, senderId)) { +// return new ResponseModel("error", "You can't message this user (blocked)."); +// } +// } +// +// +// String content = json.optString("content", ""); +// String messageType = json.optString("message_type", "TEXT"); +// +// boolean inserted = MessageDatabase.insertMessage(messageId, senderId, receiverId, receiverType, content, messageType); +// if (!inserted) +// return new ResponseModel("error", "Failed to insert message."); +// +// if (json.has("attachments")) { +// JSONArray attachmentsArray = json.getJSONArray("attachments"); +// List attachments = new ArrayList<>(); +// +// for (int i = 0; i < attachmentsArray.length(); i++) { +// JSONObject attJson = attachmentsArray.getJSONObject(i); +// attachments.add(new FileAttachment( +// attJson.getString("file_url"), +// attJson.getString("file_type") +// )); +// } +// +// boolean attInserted = MessageDatabase.insertAttachments(messageId, attachments); +// if (!attInserted) +// return new ResponseModel("error", "Message inserted but failed to attach files."); +// } +// +// // Send real-time message +// Message msg = new Message(messageId, senderId, receiverId, receiverType, content, messageType, LocalDateTime.now()); +// List receivers = getReceiversForChat(receiverId, receiverType); +// receivers.remove(senderId); +// RealTimeEventDispatcher.sendNewMessage(msg, receivers); +// +// // Update chat list (last_message_time) +// JSONObject chatUpdate = new JSONObject(); +// chatUpdate.put("chat_id", receiverId.toString()); +// chatUpdate.put("chat_type", receiverType); +// chatUpdate.put("last_message_time", LocalDateTime.now().toString()); +// +// JSONObject chatPayload = new JSONObject(); +// chatPayload.put("action", "chat_updated"); +// chatPayload.put("data", chatUpdate); +// +// for (UUID receiver : receivers) { +// RealTimeEventDispatcher.sendToUser(receiver, chatPayload); +// } +// +// JSONObject data = new JSONObject(); +// data.put("message_id", messageId.toString()); +// return new ResponseModel("success", "Message sent successfully.", data); +// +// } catch (Exception e) { +// e.printStackTrace(); +// return new ResponseModel("error", "Exception occurred while sending message."); +// } +// } + + private ResponseModel handleSendMessage(JSONObject json) { - try { if (currentUser == null) return new ResponseModel("error", "Unauthorized. Please login first."); @@ -2905,57 +3593,71 @@ public class ClientHandler implements Runnable { UUID messageId = UUID.randomUUID(); UUID senderId = currentUser.getInternal_uuid(); String receiverType = json.getString("receiver_type"); - UUID receiverId; - receiverId = UUID.fromString(json.getString("receiver_id")); + UUID receiverId = UUID.fromString(json.getString("receiver_id")); - if(Objects.equals(receiverType, "private")){ - PrivateChatDatabase.clearDeletedFlag(senderId, receiverId); - UUID other = PrivateChatDatabase.getOtherParticipant(receiverId, senderId); - if (other == null) { - return new ResponseModel("error", "Invalid private chat."); - } - if (ContactDatabase.isBlocked(senderId, other) || ContactDatabase.isBlocked(other, senderId)) { - return new ResponseModel("error", "You can't message this user (blocked)."); - } - } String content = json.optString("content", ""); String messageType = json.optString("message_type", "TEXT"); - boolean inserted = MessageDatabase.insertMessage(messageId, senderId, receiverId, receiverType, content, messageType); - if (!inserted) - return new ResponseModel("error", "Failed to insert message."); - + // Parse attachments + List attachments = new ArrayList<>(); if (json.has("attachments")) { - JSONArray attachmentsArray = json.getJSONArray("attachments"); - List attachments = new ArrayList<>(); - - for (int i = 0; i < attachmentsArray.length(); i++) { - JSONObject attJson = attachmentsArray.getJSONObject(i); + JSONArray arr = json.getJSONArray("attachments"); + for (int i = 0; i < arr.length(); i++) { + JSONObject a = arr.getJSONObject(i); attachments.add(new FileAttachment( - attJson.getString("file_url"), - attJson.getString("file_type") + a.optString("file_url",""), + a.optString("file_type","FILE"), + a.optString("file_name",""), + a.has("file_size") && !a.isNull("file_size") ? a.getLong("file_size") : null, + a.optString("mime_type", null), + a.has("width") && !a.isNull("width") ? a.getInt("width") : null, + a.has("height") && !a.isNull("height") ? a.getInt("height") : null, + a.has("duration_seconds") && !a.isNull("duration_seconds") ? a.getInt("duration_seconds") : null, + a.isNull("thumbnail_url") ? null : a.optString("thumbnail_url", null) )); } - - boolean attInserted = MessageDatabase.insertAttachments(messageId, attachments); - if (!attInserted) - return new ResponseModel("error", "Message inserted but failed to attach files."); } - // Send real-time message - Message msg = new Message(messageId, senderId, receiverId, receiverType, content, messageType, LocalDateTime.now()); - List receivers = getReceiversForChat(receiverId, receiverType); - receivers.remove(senderId); - RealTimeEventDispatcher.sendNewMessage(msg, receivers); + if ((content == null || content.isBlank()) && attachments.isEmpty()) { + return new ResponseModel("error", "Empty message: no content or attachment."); + } - // Update chat list (last_message_time) - JSONObject chatUpdate = new JSONObject(); - chatUpdate.put("chat_id", receiverId.toString()); - chatUpdate.put("chat_type", receiverType); - chatUpdate.put("last_message_time", LocalDateTime.now().toString()); + // Harmonize message_type + if (!attachments.isEmpty()) { + String firstType = attachments.get(0).getFileType(); + if ("TEXT".equalsIgnoreCase(messageType)) { + messageType = firstType; + } else if (!messageType.equalsIgnoreCase(firstType) && !messageType.equalsIgnoreCase("FILE")) { + return new ResponseModel("error", "message_type and attachment.file_type mismatch."); + } + } + + // DB transaction + try (Connection conn = ConnectionDb.connect()) { + conn.setAutoCommit(false); + + boolean inserted = MessageDatabase.insertMessageTx(conn, messageId, senderId, receiverId, receiverType, content, messageType); + if (!inserted) { + conn.rollback(); + return new ResponseModel("error", "Failed to insert message."); + } + + if (!attachments.isEmpty()) { + boolean attInserted = MessageDatabase.insertAttachmentsTx(conn, messageId, attachments); + if (!attInserted) { + conn.rollback(); + return new ResponseModel("error", "Message inserted but failed to attach files."); + } + } + + conn.commit(); + } + + // Real-Time + Message msg = new Message(messageId, senderId, receiverId, receiverType, content, messageType, LocalDateTime.now()); JSONObject chatPayload = new JSONObject(); chatPayload.put("action", "chat_updated"); @@ -2967,9 +3669,76 @@ public class ClientHandler implements Runnable { RealTimeEventDispatcher.sendToUser(senderId, chatPayload); + JSONObject payload = new JSONObject(); + payload.put("action", "new_message"); JSONObject data = new JSONObject(); - data.put("message_id", messageId.toString()); - return new ResponseModel("success", "Message sent successfully.", data); + data.put("id", messageId.toString()); + data.put("sender_id", senderId.toString()); + data.put("receiver_id", receiverId.toString()); + data.put("receiver_type", receiverType); + data.put("content", content); + data.put("message_type", messageType); + data.put("send_at", msg.getSend_at().toString()); + + if (!attachments.isEmpty()) { + JSONArray out = new JSONArray(); + for (FileAttachment a : attachments) { + JSONObject ao = new JSONObject() + .put("file_url", a.getFileUrl()) + .put("file_type", a.getFileType()) + .put("file_name", a.getFileName() == null ? JSONObject.NULL : a.getFileName()) + .put("file_size", a.getFileSize() == null ? JSONObject.NULL : a.getFileSize()) + .put("mime_type", a.getMimeType() == null ? JSONObject.NULL : a.getMimeType()) + .put("width", a.getWidth() == null ? JSONObject.NULL : a.getWidth()) + .put("height", a.getHeight() == null ? JSONObject.NULL : a.getHeight()) + .put("duration_seconds", a.getDurationSeconds() == null ? JSONObject.NULL : a.getDurationSeconds()) + .put("thumbnail_url", a.getThumbnailUrl() == null ? JSONObject.NULL : a.getThumbnailUrl()); + out.put(ao); + } + data.put("attachments", out); + } + + User sender = userDatabase.findByInternalUUID(senderId); + if (sender != null) data.put("sender_name", sender.getProfile_name()); + payload.put("data", data); + +// List receivers = getReceiversForChat(receiverId, receiverType); +// receivers.remove(senderId); +// RealTimeEventDispatcher.broadcastToUsers(receivers, payload); +// +// +// +// // chat_updated +// JSONObject chatUpdate = new JSONObject() +// .put("chat_id", receiverId.toString()) +// .put("chat_type", receiverType) +// .put("last_message_time", LocalDateTime.now().toString()); +// +// JSONObject chatPayload = new JSONObject() +// .put("action", "chat_updated") +// .put("data", chatUpdate); +// +// for (UUID r : receivers) RealTimeEventDispatcher.sendToUser(r, chatPayload); + + + List allMembers = getReceiversForChat(receiverId, receiverType); // شامل sender + // به همه chat_updated بده + JSONObject chatUpdate = new JSONObject() + .put("chat_id", receiverId.toString()) + .put("chat_type", receiverType) + .put("last_message_time", LocalDateTime.now().toString()); + JSONObject chatPayload = new JSONObject() + .put("action", "chat_updated") + .put("data", chatUpdate); + for (UUID u : allMembers) RealTimeEventDispatcher.sendToUser(u, chatPayload); + + List others = new ArrayList<>(allMembers); + others.remove(senderId); + RealTimeEventDispatcher.broadcastToUsers(others, payload); + + + JSONObject respData = new JSONObject().put("message_id", messageId.toString()); + return new ResponseModel("success", "Message sent successfully.", respData); } catch (Exception e) { e.printStackTrace(); @@ -2978,6 +3747,7 @@ public class ClientHandler implements Runnable { } + private List getReceiversForChat(UUID receiverId, String receiverType) { switch (receiverType) { case "private": diff --git a/src/main/java/org/to/telegramfinalproject/Server/MainServer.java b/src/main/java/org/to/telegramfinalproject/Server/MainServer.java index 2318fa3..a7882a3 100644 --- a/src/main/java/org/to/telegramfinalproject/Server/MainServer.java +++ b/src/main/java/org/to/telegramfinalproject/Server/MainServer.java @@ -7,7 +7,8 @@ import java.net.ServerSocket; import java.net.Socket; public class MainServer { - private static final int PORT = 8000; + private static final int PORT = 8080; + public static void main(String[] args) { try (ServerSocket serverSocket = new ServerSocket(PORT)) { diff --git a/src/main/java/org/to/telegramfinalproject/Server/RealTimeEventDispatcher.java b/src/main/java/org/to/telegramfinalproject/Server/RealTimeEventDispatcher.java index db8c6a7..6834bef 100644 --- a/src/main/java/org/to/telegramfinalproject/Server/RealTimeEventDispatcher.java +++ b/src/main/java/org/to/telegramfinalproject/Server/RealTimeEventDispatcher.java @@ -1,10 +1,7 @@ package org.to.telegramfinalproject.Server; import org.json.JSONObject; -import org.to.telegramfinalproject.Database.ChannelDatabase; -import org.to.telegramfinalproject.Database.ContactDatabase; -import org.to.telegramfinalproject.Database.GroupDatabase; -import org.to.telegramfinalproject.Database.userDatabase; +import org.to.telegramfinalproject.Database.*; import org.to.telegramfinalproject.Models.Message; import org.to.telegramfinalproject.Models.User; @@ -12,7 +9,9 @@ import java.io.IOException; import java.io.PrintWriter; import java.net.Socket; import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; +import java.util.Locale; import java.util.UUID; public class RealTimeEventDispatcher { @@ -425,4 +424,81 @@ public class RealTimeEventDispatcher { } + public static void notifyChatUpdated(UUID chatId, String chatType, Message lastMsg) { + if (chatId == null || chatType == null) return; + + final String type = chatType.toLowerCase(Locale.ROOT); + + List receivers; + switch (type) { + case "private": + receivers = PrivateChatDatabase.getMembers(chatId); + break; + + case "group": + receivers = GroupDatabase.getMemberUUIDs(chatId); + break; + case "channel": + receivers = ChannelDatabase.getSubscriberUUIDs(chatId); + break; + default: + receivers = Collections.emptyList(); + } + if (receivers == null || receivers.isEmpty()) return; + + // 2) ساخت خلاصه آخرین پیام برای نمایش در لیست چت + String senderName = null; + if (lastMsg != null && lastMsg.getSender_id() != null) { + User u = userDatabase.findByInternalUUID(lastMsg.getSender_id()); + if (u != null) senderName = u.getProfile_name(); + } + + String messageType = lastMsg != null && lastMsg.getMessage_type() != null + ? lastMsg.getMessage_type().toLowerCase(Locale.ROOT) : "text"; + + // preview ساده: برای مدیا، برچسب کوتاه؛ برای متن، کوتاه‌سازی + String preview; + if (!"text".equals(messageType)) { + switch (messageType) { + case "image": preview = "[Photo]"; break; + case "video": preview = "[Video]"; break; + case "audio": preview = "[Audio]"; break; + case "file": preview = "[File]"; break; + default: preview = "[Media]"; + } + } else { + String t = lastMsg != null ? nullToEmpty(lastMsg.getContent()) : ""; + preview = t.length() > 80 ? t.substring(0, 80) + "…" : t; + } + + String sendAt = (lastMsg != null && lastMsg.getSend_at() != null) + ? lastMsg.getSend_at().toString() + : java.time.OffsetDateTime.now().toString(); + + // 3) payload رویداد chat_updated + JSONObject payload = new JSONObject() + .put("action", "chat_updated") + .put("data", new JSONObject() + .put("chat_id", chatId.toString()) + .put("chat_type", type) + .put("last_message", new JSONObject() + .put("id", lastMsg != null ? lastMsg.getMessage_id().toString() : JSONObject.NULL) + .put("sender_id", lastMsg != null ? lastMsg.getSender_id().toString() : JSONObject.NULL) + .put("sender_name", senderName != null ? senderName : JSONObject.NULL) + .put("message_type", messageType) + .put("preview", preview) + .put("send_at", sendAt) + ) + .put("last_message_time", sendAt) + .put("update_reason", "new_message") // برای کلاینت مفید است + ); + + // 4) ارسال به همه اعضای چت + for (UUID uid : receivers) { + sendToUser(uid, payload); + } + } + + private static String nullToEmpty(String s) { return s == null ? "" : s; } + } diff --git a/src/main/java/org/to/telegramfinalproject/Server/TestServer.java b/src/main/java/org/to/telegramfinalproject/Server/TestServer.java new file mode 100644 index 0000000..ddbf0c3 --- /dev/null +++ b/src/main/java/org/to/telegramfinalproject/Server/TestServer.java @@ -0,0 +1,57 @@ +package org.to.telegramfinalproject.Server; + +import org.to.telegramfinalproject.Database.userDatabase; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class TestServer { + private static final int SOCKET_PORT = 8000; // سرور سوکت + private static final int HTTP_PORT = 8080; // سرور آپلود + private static final String UPLOAD_BASE_DIR = "uploads"; // پوشه‌ی ذخیره فایل‌ها + + public static void main(String[] args) { + // 1) استارت HTTP Upload در ترد جدا + Thread httpThread = new Thread(() -> { + try { + UploadHttp.start(HTTP_PORT, UPLOAD_BASE_DIR); + } catch (IOException e) { + System.err.println("Upload HTTP failed to start: " + e.getMessage()); + e.printStackTrace(); + } + }, "upload-http"); + httpThread.setDaemon(true); + httpThread.start(); + + // 2) سرور سوکت با Thread Pool + ExecutorService pool = Executors.newCachedThreadPool(); + try (ServerSocket serverSocket = new ServerSocket(SOCKET_PORT)) { + System.out.println("Socket server started on port " + SOCKET_PORT); + userDatabase.setAllUsersOffline(); + + // 3) Shutdown Hook برای خاموشی تمیز + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + System.out.println("\nShutting down..."); + try { serverSocket.close(); } catch (IOException ignore) {} + pool.shutdownNow(); + userDatabase.setAllUsersOffline(); + System.out.println("Goodbye."); + })); + + // 4) حلقه پذیرش اتصال‌ها + while (!serverSocket.isClosed()) { + Socket clientSocket = serverSocket.accept(); + clientSocket.setTcpNoDelay(true); + System.out.println("New client connected: " + clientSocket.getInetAddress()); + pool.submit(new ClientHandler(clientSocket)); + } + + } catch (IOException e) { + System.err.println("Socket server error: " + e.getMessage()); + e.printStackTrace(); + } + } +} diff --git a/src/main/java/org/to/telegramfinalproject/Server/UploadHttp.java b/src/main/java/org/to/telegramfinalproject/Server/UploadHttp.java new file mode 100644 index 0000000..fd1e77a --- /dev/null +++ b/src/main/java/org/to/telegramfinalproject/Server/UploadHttp.java @@ -0,0 +1,183 @@ +package org.to.telegramfinalproject.Server; + +import static spark.Spark.*; + +import javax.imageio.ImageIO; +import javax.servlet.MultipartConfigElement; +import javax.servlet.http.Part; + +import java.awt.image.BufferedImage; +import java.io.InputStream; +import java.io.IOException; +import java.nio.file.*; +import java.time.LocalDate; + +import javax.sound.sampled.*; // برای WAV + +import org.json.JSONObject; +import com.mpatric.mp3agic.Mp3File; + +public class UploadHttp { + + public static void start(int httpPort, String baseDir) throws IOException { + port(httpPort); + + Path basePath = Paths.get(baseDir).toAbsolutePath().normalize(); + Files.createDirectories(basePath); + staticFiles.externalLocation(basePath.toString()); + + post("/upload", (req, res) -> { + res.type("application/json"); + try { + long MAX_FILE = 25L * 1024 * 1024; // 25MB + req.attribute("org.eclipse.jetty.multipartConfig", + new MultipartConfigElement("/tmp", MAX_FILE, MAX_FILE, 0)); + + Part filePart = req.raw().getPart("file"); + if (filePart == null || filePart.getSize() == 0) { + res.status(400); + return jsonError("empty file"); + } + if (filePart.getSize() > MAX_FILE) { + res.status(413); + return jsonError("file too large"); + } + + String mime = filePart.getContentType(); + if (mime == null) { + res.status(415); + return jsonError("unknown mime"); + } + + String original = filePart.getSubmittedFileName(); + String ext = guessExt(original, mime); + String day = LocalDate.now().toString(); + String typeDir = subdirFor(mime); // images/audios/files + String subdir = typeDir + "/" + day; + String name = java.util.UUID.randomUUID() + ext; + + Path dir = basePath.resolve(subdir).normalize(); + Files.createDirectories(dir); + Path target = dir.resolve(name).normalize(); + + try (InputStream in = filePart.getInputStream()) { + Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); + } finally { + filePart.delete(); + } + + String fileUrl = "/" + subdir.replace('\\', '/') + "/" + name; + String fileType = mapToFileType(mime); + + //Meta deta only for audio and image + Integer width = null, height = null, durationSeconds = null; + String thumbnailUrl = null; + + if ("IMAGE".equals(fileType) || "GIF".equals(fileType)) { + int[] wh = imageSize(target); + if (wh != null) { width = wh[0]; height = wh[1]; } + } else if ("AUDIO".equals(fileType)) { + durationSeconds = audioDurationSeconds(target, mime, ext); + } + + res.status(200); + return new JSONObject() + .put("file_url", fileUrl) + .put("file_type", fileType) + .put("file_name", original == null ? "" : safeName(original)) + .put("file_size", Files.size(target)) + .put("mime_type", mime) + .put("width", width == null ? JSONObject.NULL : width) + .put("height", height == null ? JSONObject.NULL : height) + .put("duration_seconds", durationSeconds == null ? JSONObject.NULL : durationSeconds) + .put("thumbnail_url", JSONObject.NULL) + .toString(); + + } catch (Exception e) { + e.printStackTrace(); + res.status(500); + return jsonError("internal error"); + } + }); + + init(); + awaitInitialization(); + System.out.println("Upload HTTP server on http://localhost:" + httpPort + " baseDir=" + basePath); + } + + // ---------- Helpers ---------- + + private static String jsonError(String msg) { + return new JSONObject().put("error", msg).toString(); + } + + private static String subdirFor(String mime) { + String m = mime.toLowerCase(); + if (m.startsWith("image/")) return "images"; + if (m.startsWith("audio/")) return "audios"; + return "files"; + } + + private static String mapToFileType(String mime) { + String m = mime.toLowerCase(); + if (m.startsWith("image/")) { + if (m.contains("gif")) return "GIF"; + return "IMAGE"; + } + if (m.startsWith("audio/")) return "AUDIO"; + return "FILE"; + } + + private static String guessExt(String original, String mime) { + if (original != null && original.contains(".")) { + String ext = original.substring(original.lastIndexOf('.')); + if (ext.length() <= 10) return ext; + } + if ("image/png".equalsIgnoreCase(mime)) return ".png"; + if ("image/jpeg".equalsIgnoreCase(mime)) return ".jpg"; + if ("image/gif".equalsIgnoreCase(mime)) return ".gif"; + if ("audio/mpeg".equalsIgnoreCase(mime)) return ".mp3"; + if ("audio/wav".equalsIgnoreCase(mime) || "audio/x-wav".equalsIgnoreCase(mime)) return ".wav"; + if ("application/pdf".equalsIgnoreCase(mime)) return ".pdf"; + return ""; + } + + private static String safeName(String name) { + return name.replace("\"", "").replace("\n", "").replace("\r", ""); + } + + private static int[] imageSize(Path file) { + try { + BufferedImage bi = ImageIO.read(file.toFile()); + if (bi != null) return new int[]{bi.getWidth(), bi.getHeight()}; + } catch (Exception ignore) {} + return null; + } + + //only audio + private static Integer audioDurationSeconds(Path file, String mime, String ext) { + try { + if ("audio/mpeg".equalsIgnoreCase(mime) || ".mp3".equalsIgnoreCase(ext)) { + Mp3File mp3 = new Mp3File(file.toFile()); + return (int) mp3.getLengthInSeconds(); + } + + // WAV با javax.sound.sampled + if ("audio/wav".equalsIgnoreCase(mime) || "audio/x-wav".equalsIgnoreCase(mime) || ".wav".equalsIgnoreCase(ext)) { + try (AudioInputStream ais = AudioSystem.getAudioInputStream(file.toFile())) { + AudioFormat format = ais.getFormat(); + long frames = ais.getFrameLength(); + if (frames > 0 && format.getFrameRate() > 0) { + double seconds = frames / format.getFrameRate(); + return (int)Math.round(seconds); + } + } + } + } catch (UnsupportedAudioFileException | IOException ignore) { + // فرمت صوتی پشتیبانی نشده برای AudioSystem + } catch (Exception ignore) { + // mp3agic یا سایر استثناها + } + return null; + } +} diff --git a/src/main/java/org/to/telegramfinalproject/Utils/ChannelPermissionUtil.java b/src/main/java/org/to/telegramfinalproject/Utils/ChannelPermissionUtil.java index 709fee2..0b82779 100644 --- a/src/main/java/org/to/telegramfinalproject/Utils/ChannelPermissionUtil.java +++ b/src/main/java/org/to/telegramfinalproject/Utils/ChannelPermissionUtil.java @@ -73,4 +73,29 @@ public class ChannelPermissionUtil { } return false; } + + + + public static boolean isUserInChannel(UUID userId, UUID channelId) { + final String SQL = "SELECT 1 FROM channel_subscribers WHERE channel_id = ? AND user_id = ? LIMIT 1"; + try (Connection conn = ConnectionDb.connect(); + PreparedStatement ps = conn.prepareStatement(SQL)) { + + ps.setObject(1, channelId, java.sql.Types.OTHER); // 👈 مهم برای Postgres UUID + ps.setObject(2, userId, java.sql.Types.OTHER); + + System.out.println("[SQL] isUserInChannel ch=" + channelId + " user=" + userId + + " db=" + conn.getMetaData().getURL()); + + try (ResultSet rs = ps.executeQuery()) { + boolean ok = rs.next(); + System.out.println("[SQL] isUserInChannel -> " + ok); + return ok; + } + } catch (SQLException e) { + e.printStackTrace(); + return false; + } + } + } diff --git a/src/main/resources/init.sql b/src/main/resources/init.sql index e5b9720..5325bc0 100644 --- a/src/main/resources/init.sql +++ b/src/main/resources/init.sql @@ -156,5 +156,14 @@ CREATE TABLE IF NOT EXISTS message_attachments ( ); +--Run this part in your pg +ALTER TABLE message_attachments + ADD COLUMN IF NOT EXISTS media_key UUID; +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +UPDATE message_attachments +SET media_key = gen_random_uuid() +WHERE media_key IS NULL; +