From ea2da5729d24471de545b4f2f478e1694196ab3d Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 21:13:23 +0200 Subject: [PATCH] ADD - implement DarkChat service --- sky_phone/config/config.lua | 22 + sky_phone/config/locales/en.lua | 21 + sky_phone/fxmanifest.lua | 1 + sky_phone/source/client/main.lua | 33 + sky_phone/source/server/darkchat.lua | 848 +++++++++++++++++++++++++ sky_phone/source/server/db_migrate.lua | 155 +++++ 6 files changed, 1080 insertions(+) create mode 100644 sky_phone/source/server/darkchat.lua diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index 79cdbe3..7a8db69 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -47,6 +47,28 @@ Config.Messages = { DeleteBatchSize = 20, } +Config.DarkChat = { + AliasMaxLength = 32, + BodyMaxLength = 2000, + ThreadPageSize = 200, + SendsPerMinute = 30, + ActionsPerMinute = 60, + ReportsPerDay = 10, + VoiceMaxDurationMs = 60000, + VoiceMaxBase64Length = 360000, + VoiceWaveformSamples = 48, + CleanupIntervalSeconds = 30, + AllowedDisappearTimers = { + [0] = true, + [-1] = true, -- after reading + [60] = true, + [300] = true, + [3600] = true, + [86400] = true, + [604800] = true, + }, +} + Config.Mail = { Domain = "ifruit.com", LocalPartMinLength = 3, diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 0e6ecaf..2ecaeb2 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -36,6 +36,27 @@ Locales["en"] = { }, }, Apps = { + darkchat = { + name = "DarkChat", newMessage = "New DarkChat message from {sender}", privateNotification = "New DarkChat message", + signInBody = "DarkChat identities are linked to your private iFruit account.", signInHint = "Sign in through Settings to continue.", + security = "Security", privateNetwork = "Private invitation-only network", noResults = "No Results", noResultsBody = "Try another alias or Dark-ID.", + noChats = "No Private Chats", noChatsBody = "Connect with a Dark-ID or invitation code. There is no public search.", newChat = "New Chat", + connectPrivately = "Connect privately", newChatBody = "Enter an exact Dark-ID or invitation code. Unknown identities require confirmation.", darkIdOrInvite = "Dark-ID or invitation code", continue = "Continue", contacts = "DarkChat Contacts", shareIdentity = "Tap to share your private identity", + message = "Dark message", activeNow = "Activity shared", encryptedSession = "Private session", serverPrivate = "Private server-stored conversation", + emoji = "Emoji", gif = "GIF", gifs = "GIFs", searchGifs = "Search GIFs", loadMore = "Load More", photosLater = "Photos · coming later", videosLater = "Videos · coming later", + voiceMessage = "Voice message", sending = "Sending", failed = "Not delivered", delivered = "Delivered", read = "Read", replying = "Replying to", + messageDeleted = "Message deleted", securityUpdate = "Security settings updated", timerChanged = "Disappearing messages: {timer}", + timerOff = "Off", timerAfterRead = "After reading", timerMinute = "1 minute", timerFiveMinutes = "5 minutes", timerHour = "1 hour", timerDay = "24 hours", timerWeek = "7 days", + copied = "Copied", reply = "Reply", copy = "Copy", deleteForMe = "Delete for me", deleteForBoth = "Delete for both", report = "Report", + contactSecurity = "Contact & Security", chatSince = "Private chat since {date}", notifications = "Notifications", readReceipts = "Read receipts", disappearing = "Disappearing messages", contactAlias = "Contact alias", saveContact = "Save Contact", addContact = "Add Contact", contactSaved = "Contact saved", removeContact = "Remove Contact", block = "Block User", unblock = "Unblock User", clearChat = "Clear Chat", chatCleared = "Chat cleared", + myIdentity = "My Dark Identity", alias = "Alias", notificationPrivacy = "Notification privacy", notificationFull = "Full · alias and message", notificationPrivate = "Private · generic message", notificationHidden = "Invisible · badge only", shareActivity = "Share activity status", inviteCode = "Private invitation code", copyInvite = "Copy Invite", privacyDisclaimer = "DarkChat stores messages on the server and does not claim end-to-end encryption.", + unknownIdentity = "Unknown Identity", unknownIdentityBody = "Only continue if you expected this identity. The account is not discoverable through public search.", openSecureChat = "Open Private Chat", + reportUser = "Report User", reportSpam = "Spam", reportHarassment = "Harassment", reportThreats = "Threats", reportIllegal = "Illegal content", reportOther = "Other", reportDetails = "Optional details", submitReport = "Submit Report", reported = "Report submitted", + microphoneUnavailable = "The microphone is unavailable.", recordingTooLarge = "The voice message is too large.", + errors = { + not_authenticated = "Sign in to your iFruit account first.", invalid_dark_id = "Enter a valid Dark-ID or invitation code.", profile_not_found = "This private identity was not found.", self_chat = "You cannot message your own identity.", conversation_not_found = "This conversation is unavailable.", blocked = "Messages are blocked in this conversation.", invalid_message = "Enter a valid message.", invalid_gif = "This GIF is invalid.", invalid_voice = "This voice message is invalid.", invalid_profile = "Check your alias and privacy settings.", rate_limited = "Too many requests. Try again shortly.", gif_provider_unconfigured = "GIF search is not configured.", gif_provider_unauthorized = "The GIF provider key is invalid.", gif_provider_rate_limited = "GIF search is busy. Try again shortly.", gif_provider_failed = "GIFs are temporarily unavailable.", default = "DarkChat could not complete the request.", + }, + }, messages = { name = "Messages", newMessage = "New message from {sender}", compose = "New Message", search = "Search", to = "To:", message = "Message", send = "Send", details = "Details", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index 18cc5f2..1423294 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -45,6 +45,7 @@ server_scripts { 'source/server/calls.lua', 'source/server/media.lua', 'source/server/messages.lua', + 'source/server/darkchat.lua', 'source/server/notes.lua', 'source/server/mail.lua', 'source/server/marketplace.lua', diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 40a8e41..1ab77d3 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -76,6 +76,20 @@ local server_callbacks = { "messages:media", "messages:delete", "messages:gifs", + "darkchat:bootstrap", + "darkchat:update-profile", + "darkchat:start", + "darkchat:thread", + "darkchat:send", + "darkchat:media", + "darkchat:react", + "darkchat:message-action", + "darkchat:update-conversation", + "darkchat:add-contact", + "darkchat:remove-contact", + "darkchat:block", + "darkchat:report", + "darkchat:clear", "gallery:list", "media:config", } @@ -380,6 +394,25 @@ RegisterNetEvent("sky_phone:messages:new", function(data) SendNUIMessage({ type = "messages:new", data = data }) end) +RegisterNetEvent("sky_phone:darkchat:changed", function(data) + SendNUIMessage({ type = "darkchat:changed", data = data }) +end) + +RegisterNetEvent("sky_phone:darkchat:new", function(data) + local darkchat_locale = get_locale().Nui.Apps.darkchat + data.title = darkchat_locale.name + if data.notificationMode == "private" then + data.sender = nil + data.text = darkchat_locale.privateNotification + elseif data.notificationMode == "hidden" then + data.sender = nil + data.text = "" + else + data.text = darkchat_locale.newMessage:gsub("{sender}", tostring(data.sender)) + end + SendNUIMessage({ type = "darkchat:new", data = data }) +end) + RegisterNetEvent("sky_phone:call:incoming", function(data) notification_focus = true SetNuiFocus(true, true) diff --git a/sky_phone/source/server/darkchat.lua b/sky_phone/source/server/darkchat.lua new file mode 100644 index 0000000..87368c0 --- /dev/null +++ b/sky_phone/source/server/darkchat.lua @@ -0,0 +1,848 @@ +Bridge.Database.AfterMigration("sky_phone", function() + +local allowed_voice_mimes = { + ["audio/webm"] = true, + ["audio/webm;codecs=opus"] = true, +} + +local report_reasons = { + spam = true, + harassment = true, + threats = true, + illegal = true, + other = true, +} + +local notification_modes = { + full = true, + private = true, + hidden = true, +} + +local function allowed_gif_url(value) + if type(value) ~= "string" or #value == 0 or #value > Config.Media.UrlMaxLength then + return false + end + local host = value:lower():match("^https://([^/:?#]+)") + if not host then + return false + end + for _, allowed_host in ipairs(Config.Media.AllowedGifHosts) do + local suffix = "." .. allowed_host + if host == allowed_host or host:sub(-#suffix) == suffix then + return true + end + end + return false +end + +local reaction_values = { + ["❤️"] = true, + ["👍"] = true, + ["👎"] = true, + ["😂"] = true, + ["‼️"] = true, + ["❓"] = true, +} + +local function trim(value) + if type(value) ~= "string" then + return nil + end + return value:match("^%s*(.-)%s*$") +end + +local function uuid() + local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {}) + if not rows[1] or type(rows[1].id) ~= "string" then + error("[sky_phone] Database did not generate a DarkChat UUID.") + end + return rows[1].id +end + +local function affected_rows(result) + if type(result) == "number" then + return result + end + return type(result) == "table" and tonumber(result.affectedRows) or 0 +end + +local function profile_payload(row) + return { + id = tonumber(row.id), + darkId = row.dark_id, + inviteCode = row.invite_code, + alias = row.alias, + avatarSeed = tonumber(row.avatar_seed), + notificationMode = row.notification_mode, + activityVisible = row.activity_visible == 1 or row.activity_visible == true, + createdAt = row.created_at, + } +end + +local function load_profile(account_id) + local rows = Bridge.Database.Query([[ + SELECT `id`, `dark_id`, `invite_code`, `alias`, `avatar_seed`, `notification_mode`, + `activity_visible`, `created_at` + FROM `sky_phone_darkchat_profiles` + WHERE `account_id` = ? LIMIT 1 + ]], { account_id }) + return rows[1] +end + +local function create_profile(account_id) + for _ = 1, 10 do + local entropy = uuid():gsub("%-", ""):upper() + local dark_id = ("dark:%s-%s"):format(entropy:sub(1, 4), entropy:sub(5, 8)) + local invite_code = ("DC-%s-%s"):format(entropy:sub(9, 12), entropy:sub(13, 16)) + local seed = tonumber(entropy:sub(17, 23), 16) or 1 + local result = Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_darkchat_profiles` + (`account_id`, `dark_id`, `invite_code`, `alias`, `avatar_seed`) + VALUES (?, ?, ?, ?, ?) + ]], { account_id, dark_id, invite_code, "Shadow " .. entropy:sub(1, 4), seed }) + if affected_rows(result) > 0 then + return load_profile(account_id) + end + local existing = load_profile(account_id) + if existing then + return existing + end + end + error("[sky_phone] Could not generate a unique DarkChat profile after 10 attempts.") +end + +local function require_profile(source) + local account, error_response = SkyPhone.RequireAccount(source) + if not account then + return nil, nil, error_response + end + local profile = load_profile(account.id) or create_profile(account.id) + return profile, account +end + +local function load_membership(profile_id, conversation_id) + local rows = Bridge.Database.Query([[ + SELECT mine.`conversation_id`, mine.`notifications_enabled`, mine.`read_receipts`, mine.`cleared_at`, + conversation.`disappearing_seconds`, conversation.`created_at` AS `conversation_created_at`, + peer.`id` AS `peer_id`, peer.`account_id` AS `peer_account_id`, peer.`dark_id` AS `peer_dark_id`, + peer.`alias` AS `peer_alias`, peer.`avatar_seed` AS `peer_avatar_seed`, + peer.`activity_visible` AS `peer_activity_visible`, peer_member.`last_read_at` AS `peer_last_read_at`, + contact.`id` AS `contact_id`, contact.`alias_override`, block_mine.`id` AS `blocked_by_me`, + block_peer.`id` AS `blocked_by_peer` + FROM `sky_phone_darkchat_members` mine + JOIN `sky_phone_darkchat_conversations` conversation ON conversation.`id` = mine.`conversation_id` + JOIN `sky_phone_darkchat_members` peer_member + ON peer_member.`conversation_id` = mine.`conversation_id` AND peer_member.`profile_id` <> mine.`profile_id` + JOIN `sky_phone_darkchat_profiles` peer ON peer.`id` = peer_member.`profile_id` + LEFT JOIN `sky_phone_darkchat_contacts` contact + ON contact.`profile_id` = mine.`profile_id` AND contact.`contact_profile_id` = peer.`id` + LEFT JOIN `sky_phone_darkchat_blocks` block_mine + ON block_mine.`blocker_profile_id` = mine.`profile_id` AND block_mine.`blocked_profile_id` = peer.`id` + LEFT JOIN `sky_phone_darkchat_blocks` block_peer + ON block_peer.`blocker_profile_id` = peer.`id` AND block_peer.`blocked_profile_id` = mine.`profile_id` + WHERE mine.`profile_id` = ? AND mine.`conversation_id` = ? + LIMIT 1 + ]], { profile_id, conversation_id }) + return rows[1] +end + +local function peer_payload(row) + return { + id = tonumber(row.peer_id), + darkId = row.peer_dark_id, + alias = row.alias_override or row.peer_alias, + originalAlias = row.peer_alias, + avatarSeed = tonumber(row.peer_avatar_seed), + activityVisible = row.peer_activity_visible == 1 or row.peer_activity_visible == true, + isContact = row.contact_id ~= nil, + blocked = row.blocked_by_me ~= nil, + } +end + +local function decode_array(value, context) + if value == nil then + return {} + end + local decoded = json.decode(value) + if type(decoded) ~= "table" then + error(("[sky_phone] Invalid DarkChat JSON in %s."):format(context)) + end + return decoded +end + +local function message_payload(row, profile_id) + local deleted_for = decode_array(row.deleted_for_profiles, "deleted_for_profiles") + for _, deleted_profile_id in ipairs(deleted_for) do + if tonumber(deleted_profile_id) == tonumber(profile_id) then + return nil + end + end + local deleted_everywhere = row.deleted_for_everyone == 1 or row.deleted_for_everyone == true + local reactions = decode_array(row.reactions, "reactions") + local waveform = row.media_waveform and decode_array(row.media_waveform, "media_waveform") or nil + return { + id = row.id, + conversationId = row.conversation_id, + direction = tonumber(row.sender_profile_id) == tonumber(profile_id) and "sent" or "received", + senderProfileId = tonumber(row.sender_profile_id), + messageType = deleted_everywhere and "system" or row.message_type, + body = deleted_everywhere and "message_deleted" or row.body, + mediaMime = deleted_everywhere and nil or row.media_mime, + mediaPayload = not deleted_everywhere and row.message_type ~= "voice" and row.media_payload or nil, + mediaDurationMs = deleted_everywhere and nil or tonumber(row.media_duration_ms), + mediaWaveform = deleted_everywhere and nil or waveform, + replyToId = row.reply_to_id, + replyBody = row.reply_body, + reactions = reactions, + expiresAt = row.expires_at, + createdAt = row.created_at, + readAt = row.peer_last_read_at and row.peer_last_read_at >= row.created_at and row.peer_last_read_at or nil, + deletedForEveryone = deleted_everywhere, + } +end + +local function list_contacts(profile_id) + local rows = Bridge.Database.Query([[ + SELECT target.`id`, target.`dark_id`, target.`alias`, target.`avatar_seed`, contact.`alias_override`, + contact.`created_at` + FROM `sky_phone_darkchat_contacts` contact + JOIN `sky_phone_darkchat_profiles` target ON target.`id` = contact.`contact_profile_id` + WHERE contact.`profile_id` = ? + ORDER BY COALESCE(contact.`alias_override`, target.`alias`) ASC + ]], { profile_id }) + local contacts = {} + for index, row in ipairs(rows) do + contacts[index] = { + id = tonumber(row.id), + darkId = row.dark_id, + alias = row.alias_override or row.alias, + originalAlias = row.alias, + avatarSeed = tonumber(row.avatar_seed), + createdAt = row.created_at, + } + end + return contacts +end + +local function list_conversations(profile_id) + local rows = Bridge.Database.Query([[ + SELECT conversation.`id`, conversation.`disappearing_seconds`, conversation.`updated_at`, + peer.`id` AS `peer_id`, peer.`dark_id` AS `peer_dark_id`, peer.`alias` AS `peer_alias`, + peer.`avatar_seed` AS `peer_avatar_seed`, peer.`activity_visible`, contact.`alias_override`, + block_mine.`id` AS `blocked_by_me`, latest.`body` AS `last_body`, latest.`message_type` AS `last_type`, + latest.`created_at` AS `last_at`, latest.`deleted_for_everyone`, + (SELECT COUNT(*) FROM `sky_phone_darkchat_messages` unread + WHERE unread.`conversation_id` = conversation.`id` + AND unread.`sender_profile_id` <> mine.`profile_id` + AND unread.`created_at` > COALESCE(mine.`last_read_at`, '1970-01-01') + AND (unread.`expires_at` IS NULL OR unread.`expires_at` > CURRENT_TIMESTAMP)) AS `unread` + FROM `sky_phone_darkchat_members` mine + JOIN `sky_phone_darkchat_conversations` conversation ON conversation.`id` = mine.`conversation_id` + JOIN `sky_phone_darkchat_members` peer_member + ON peer_member.`conversation_id` = conversation.`id` AND peer_member.`profile_id` <> mine.`profile_id` + JOIN `sky_phone_darkchat_profiles` peer ON peer.`id` = peer_member.`profile_id` + LEFT JOIN `sky_phone_darkchat_contacts` contact + ON contact.`profile_id` = mine.`profile_id` AND contact.`contact_profile_id` = peer.`id` + LEFT JOIN `sky_phone_darkchat_blocks` block_mine + ON block_mine.`blocker_profile_id` = mine.`profile_id` AND block_mine.`blocked_profile_id` = peer.`id` + LEFT JOIN `sky_phone_darkchat_messages` latest ON latest.`id` = ( + SELECT message.`id` FROM `sky_phone_darkchat_messages` message + WHERE message.`conversation_id` = conversation.`id` + AND message.`created_at` >= COALESCE(mine.`cleared_at`, '1970-01-01') + AND (message.`expires_at` IS NULL OR message.`expires_at` > CURRENT_TIMESTAMP) + ORDER BY message.`created_at` DESC, message.`id` DESC LIMIT 1 + ) + WHERE mine.`profile_id` = ? + ORDER BY COALESCE(latest.`created_at`, conversation.`updated_at`) DESC + ]], { profile_id }) + local conversations = {} + for index, row in ipairs(rows) do + conversations[index] = { + id = row.id, + peer = { + id = tonumber(row.peer_id), + darkId = row.peer_dark_id, + alias = row.alias_override or row.peer_alias, + avatarSeed = tonumber(row.peer_avatar_seed), + activityVisible = row.activity_visible == 1 or row.activity_visible == true, + }, + disappearingSeconds = tonumber(row.disappearing_seconds), + blocked = row.blocked_by_me ~= nil, + lastMessage = row.deleted_for_everyone == 1 and "message_deleted" or row.last_body or "", + lastMessageType = row.deleted_for_everyone == 1 and "system" or row.last_type or "system", + lastMessageAt = row.last_at or row.updated_at, + unread = tonumber(row.unread) or 0, + } + end + return conversations +end + +local function notify_profile(profile_id, event_name, data) + local rows = Bridge.Database.Query([[ + SELECT `account_id`, `notification_mode` FROM `sky_phone_darkchat_profiles` + WHERE `id` = ? LIMIT 1 + ]], { profile_id }) + local target = rows[1] + if not target then + return + end + if event_name == "sky_phone:darkchat:new" and data.conversationId then + local members = Bridge.Database.Query([[ + SELECT `notifications_enabled` FROM `sky_phone_darkchat_members` + WHERE `profile_id` = ? AND `conversation_id` = ? LIMIT 1 + ]], { profile_id, data.conversationId }) + if not members[1] or members[1].notifications_enabled == 0 then + return + end + end + data.notificationMode = target.notification_mode + SkyPhone.NotifyAccountDevices(target.account_id, event_name, data) +end + +local function valid_voice(data) + if type(data.mediaPayload) ~= "string" or #data.mediaPayload == 0 + or #data.mediaPayload > Config.DarkChat.VoiceMaxBase64Length + or data.mediaPayload:find("[^A-Za-z0-9+/=]") then + return nil + end + if type(data.mediaMime) ~= "string" or not allowed_voice_mimes[data.mediaMime] then + return nil + end + local duration = tonumber(data.mediaDurationMs) + if not duration or duration < 300 or duration > Config.DarkChat.VoiceMaxDurationMs then + return nil + end + if type(data.mediaWaveform) ~= "table" or #data.mediaWaveform < 8 + or #data.mediaWaveform > Config.DarkChat.VoiceWaveformSamples then + return nil + end + local waveform = {} + for index, value in ipairs(data.mediaWaveform) do + local sample = tonumber(value) + if not sample or sample < 0 or sample > 1 then + return nil + end + waveform[index] = math.floor(sample * 1000 + 0.5) / 1000 + end + return { + duration = math.floor(duration), + mime = data.mediaMime, + payload = data.mediaPayload, + waveform = json.encode(waveform), + } +end + +Bridge.Callbacks.Register("sky_phone:darkchat:bootstrap", function(source) + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + return { + success = true, + data = { + profile = profile_payload(profile), + contacts = list_contacts(profile.id), + conversations = list_conversations(profile.id), + }, + } +end) + +Bridge.Callbacks.Register("sky_phone:darkchat:update-profile", function(source, data) + if not SkyPhone.AllowOperation(source, "darkchat_profile", 10, 60) then + return { success = false, error = "rate_limited" } + end + local profile, account, error_response = require_profile(source) + if not profile then + return error_response + end + local alias = trim(data and data.alias) + local mode = data and data.notificationMode + if not alias or alias == "" or #alias > Config.DarkChat.AliasMaxLength + or not notification_modes[mode] or type(data.activityVisible) ~= "boolean" then + return { success = false, error = "invalid_profile" } + end + Bridge.Database.Query([[ + UPDATE `sky_phone_darkchat_profiles` + SET `alias` = ?, `notification_mode` = ?, `activity_visible` = ? + WHERE `id` = ? + ]], { alias, mode, data.activityVisible and 1 or 0, profile.id }) + return { success = true, data = profile_payload(load_profile(account.id)) } +end) + +Bridge.Callbacks.Register("sky_phone:darkchat:start", function(source, data) + if not SkyPhone.AllowOperation(source, "darkchat_start", 20, 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local identifier = trim(data and data.identifier) + if not identifier or #identifier > 32 then + return { success = false, error = "invalid_dark_id" } + end + local targets = Bridge.Database.Query([[ + SELECT `id` FROM `sky_phone_darkchat_profiles` + WHERE UPPER(`dark_id`) = UPPER(?) OR UPPER(`invite_code`) = UPPER(?) + LIMIT 1 + ]], { identifier, identifier }) + local target = targets[1] + if not target then + return { success = false, error = "profile_not_found" } + end + if tonumber(target.id) == tonumber(profile.id) then + return { success = false, error = "self_chat" } + end + local existing = Bridge.Database.Query([[ + SELECT mine.`conversation_id` + FROM `sky_phone_darkchat_members` mine + JOIN `sky_phone_darkchat_members` peer ON peer.`conversation_id` = mine.`conversation_id` + WHERE mine.`profile_id` = ? AND peer.`profile_id` = ? + LIMIT 1 + ]], { profile.id, target.id }) + local conversation_id = existing[1] and existing[1].conversation_id + if not conversation_id then + conversation_id = uuid() + local created = Bridge.Database.Transaction({ + { + query = "INSERT INTO `sky_phone_darkchat_conversations` (`id`) VALUES (?)", + params = { conversation_id }, + }, + { + query = "INSERT INTO `sky_phone_darkchat_members` (`conversation_id`, `profile_id`) VALUES (?, ?)", + params = { conversation_id, profile.id }, + }, + { + query = "INSERT INTO `sky_phone_darkchat_members` (`conversation_id`, `profile_id`) VALUES (?, ?)", + params = { conversation_id, target.id }, + }, + }) + if not created then + error("[sky_phone] Failed to create a DarkChat conversation transaction.") + end + end + return { success = true, data = { conversationId = conversation_id } } +end) + +Bridge.Callbacks.Register("sky_phone:darkchat:thread", function(source, data) + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local conversation_id = data and data.conversationId + if type(conversation_id) ~= "string" or #conversation_id ~= 36 then + return { success = false, error = "invalid_conversation" } + end + local membership = load_membership(profile.id, conversation_id) + if not membership then + return { success = false, error = "conversation_not_found" } + end + Bridge.Database.Query([[ + UPDATE `sky_phone_darkchat_members` SET `last_read_at` = CURRENT_TIMESTAMP + WHERE `conversation_id` = ? AND `profile_id` = ? + ]], { conversation_id, profile.id }) + if tonumber(membership.disappearing_seconds) == -1 then + Bridge.Database.Query([[ + UPDATE `sky_phone_darkchat_messages` + SET `expires_at` = DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 1 SECOND) + WHERE `conversation_id` = ? AND `sender_profile_id` <> ? AND `expires_at` IS NULL + ]], { conversation_id, profile.id }) + end + local rows = Bridge.Database.Query([[ + SELECT message.*, reply.`body` AS `reply_body`, peer_member.`last_read_at` AS `peer_last_read_at` + FROM `sky_phone_darkchat_messages` message + JOIN `sky_phone_darkchat_members` mine + ON mine.`conversation_id` = message.`conversation_id` AND mine.`profile_id` = ? + JOIN `sky_phone_darkchat_members` peer_member + ON peer_member.`conversation_id` = message.`conversation_id` AND peer_member.`profile_id` <> ? + LEFT JOIN `sky_phone_darkchat_messages` reply ON reply.`id` = message.`reply_to_id` + WHERE message.`conversation_id` = ? + AND message.`created_at` >= COALESCE(mine.`cleared_at`, '1970-01-01') + AND (message.`expires_at` IS NULL OR message.`expires_at` > CURRENT_TIMESTAMP) + ORDER BY message.`created_at` DESC, message.`id` DESC LIMIT ? + ]], { profile.id, profile.id, conversation_id, Config.DarkChat.ThreadPageSize }) + local messages = {} + for index = #rows, 1, -1 do + local formatted = message_payload(rows[index], profile.id) + if formatted then + messages[#messages + 1] = formatted + end + end + return { + success = true, + data = { + conversation = { + id = conversation_id, + peer = peer_payload(membership), + disappearingSeconds = tonumber(membership.disappearing_seconds), + notificationsEnabled = membership.notifications_enabled == 1 or membership.notifications_enabled == true, + readReceipts = membership.read_receipts == 1 or membership.read_receipts == true, + blockedByPeer = membership.blocked_by_peer ~= nil, + createdAt = membership.conversation_created_at, + }, + messages = messages, + }, + } +end) + +Bridge.Callbacks.Register("sky_phone:darkchat:send", function(source, data) + if not SkyPhone.AllowOperation(source, "darkchat_send", Config.DarkChat.SendsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local conversation_id = data and data.conversationId + local membership = type(conversation_id) == "string" and load_membership(profile.id, conversation_id) or nil + if not membership then + return { success = false, error = "conversation_not_found" } + end + if membership.blocked_by_me or membership.blocked_by_peer then + return { success = false, error = "blocked" } + end + local message_type = data.messageType + local body = trim(data.body) or "" + local media_payload + local media_mime + local media_duration + local media_waveform + if message_type == "text" or message_type == "emoji" then + if body == "" or #body > Config.DarkChat.BodyMaxLength then + return { success = false, error = "invalid_message" } + end + elseif message_type == "gif" then + if not allowed_gif_url(data.mediaPayload) then + return { success = false, error = "invalid_gif" } + end + media_payload = data.mediaPayload + media_mime = "image/gif" + elseif message_type == "voice" then + local voice = valid_voice(data) + if not voice then + return { success = false, error = "invalid_voice" } + end + media_payload = voice.payload + media_mime = voice.mime + media_duration = voice.duration + media_waveform = voice.waveform + else + return { success = false, error = "invalid_message" } + end + local reply_to = data.replyToId + if reply_to ~= nil then + local reply = Bridge.Database.Query([[ + SELECT `id` FROM `sky_phone_darkchat_messages` + WHERE `id` = ? AND `conversation_id` = ? LIMIT 1 + ]], { reply_to, conversation_id }) + if not reply[1] then + return { success = false, error = "invalid_reply" } + end + end + local timer = tonumber(membership.disappearing_seconds) or 0 + local id = uuid() + Bridge.Database.Query([[ + INSERT INTO `sky_phone_darkchat_messages` + (`id`, `conversation_id`, `sender_profile_id`, `message_type`, `body`, `media_payload`, + `media_mime`, `media_duration_ms`, `media_waveform`, `reply_to_id`, `expires_at`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + IF(? > 0, DATE_ADD(CURRENT_TIMESTAMP, INTERVAL ? SECOND), NULL)) + ]], { + id, conversation_id, profile.id, message_type, body, media_payload, media_mime, + media_duration, media_waveform, reply_to, timer, timer, + }) + Bridge.Database.Query( + "UPDATE `sky_phone_darkchat_conversations` SET `updated_at` = CURRENT_TIMESTAMP WHERE `id` = ?", + { conversation_id } + ) + local rows = Bridge.Database.Query([[ + SELECT message.*, reply.`body` AS `reply_body`, NULL AS `peer_last_read_at` + FROM `sky_phone_darkchat_messages` message + LEFT JOIN `sky_phone_darkchat_messages` reply ON reply.`id` = message.`reply_to_id` + WHERE message.`id` = ? LIMIT 1 + ]], { id }) + local message = message_payload(rows[1], profile.id) + TriggerClientEvent("sky_phone:darkchat:changed", source, { conversationId = conversation_id }) + notify_profile(tonumber(membership.peer_id), "sky_phone:darkchat:new", { + conversationId = conversation_id, + sender = profile.alias, + messageType = message_type, + preview = (message_type == "text" or message_type == "emoji") and body or message_type, + }) + return { success = true, data = message } +end) + +Bridge.Callbacks.Register("sky_phone:darkchat:media", function(source, data) + if not SkyPhone.AllowOperation(source, "darkchat_media", Config.DarkChat.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local rows = Bridge.Database.Query([[ + SELECT message.`media_payload`, message.`media_mime` + FROM `sky_phone_darkchat_messages` message + JOIN `sky_phone_darkchat_members` member ON member.`conversation_id` = message.`conversation_id` + WHERE message.`id` = ? AND member.`profile_id` = ? AND message.`message_type` = 'voice' + AND (message.`expires_at` IS NULL OR message.`expires_at` > CURRENT_TIMESTAMP) + LIMIT 1 + ]], { data and data.messageId, profile.id }) + if not rows[1] or type(rows[1].media_payload) ~= "string" then + return { success = false, error = "message_not_found" } + end + return { success = true, data = { payload = rows[1].media_payload, mime = rows[1].media_mime } } +end) + +Bridge.Callbacks.Register("sky_phone:darkchat:react", function(source, data) + if not SkyPhone.AllowOperation(source, "darkchat_react", Config.DarkChat.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + if not reaction_values[data and data.reaction] then + return { success = false, error = "invalid_reaction" } + end + local rows = Bridge.Database.Query([[ + SELECT message.`id`, message.`conversation_id`, message.`reactions` + FROM `sky_phone_darkchat_messages` message + JOIN `sky_phone_darkchat_members` member ON member.`conversation_id` = message.`conversation_id` + WHERE message.`id` = ? AND member.`profile_id` = ? LIMIT 1 + ]], { data.messageId, profile.id }) + local message = rows[1] + if not message then + return { success = false, error = "message_not_found" } + end + local reactions = decode_array(message.reactions, "reactions") + local key = tostring(profile.id) + if reactions[key] == data.reaction then + reactions[key] = nil + else + reactions[key] = data.reaction + end + Bridge.Database.Query("UPDATE `sky_phone_darkchat_messages` SET `reactions` = ? WHERE `id` = ?", { + json.encode(reactions), message.id, + }) + TriggerClientEvent("sky_phone:darkchat:changed", source, { conversationId = message.conversation_id }) + local membership = load_membership(profile.id, message.conversation_id) + notify_profile(tonumber(membership.peer_id), "sky_phone:darkchat:changed", { + conversationId = message.conversation_id, + }) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:darkchat:message-action", function(source, data) + if not SkyPhone.AllowOperation(source, "darkchat_message_action", Config.DarkChat.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local rows = Bridge.Database.Query([[ + SELECT message.* FROM `sky_phone_darkchat_messages` message + JOIN `sky_phone_darkchat_members` member ON member.`conversation_id` = message.`conversation_id` + WHERE message.`id` = ? AND member.`profile_id` = ? LIMIT 1 + ]], { data and data.messageId, profile.id }) + local message = rows[1] + if not message then + return { success = false, error = "message_not_found" } + end + if data.action == "delete_me" then + local deleted_for = decode_array(message.deleted_for_profiles, "deleted_for_profiles") + local already_deleted = false + for _, value in ipairs(deleted_for) do + already_deleted = already_deleted or tonumber(value) == tonumber(profile.id) + end + if not already_deleted then + deleted_for[#deleted_for + 1] = tonumber(profile.id) + end + Bridge.Database.Query("UPDATE `sky_phone_darkchat_messages` SET `deleted_for_profiles` = ? WHERE `id` = ?", { + json.encode(deleted_for), message.id, + }) + elseif data.action == "delete_all" then + if tonumber(message.sender_profile_id) ~= tonumber(profile.id) then + return { success = false, error = "not_message_owner" } + end + Bridge.Database.Query([[ + UPDATE `sky_phone_darkchat_messages` + SET `deleted_for_everyone` = 1, `body` = '', `media_payload` = NULL, + `media_mime` = NULL, `media_duration_ms` = NULL, `media_waveform` = NULL + WHERE `id` = ? + ]], { message.id }) + else + return { success = false, error = "invalid_action" } + end + local membership = load_membership(profile.id, message.conversation_id) + TriggerClientEvent("sky_phone:darkchat:changed", source, { conversationId = message.conversation_id }) + notify_profile(tonumber(membership.peer_id), "sky_phone:darkchat:changed", { + conversationId = message.conversation_id, + }) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:darkchat:update-conversation", function(source, data) + if not SkyPhone.AllowOperation(source, "darkchat_conversation_settings", Config.DarkChat.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local membership = load_membership(profile.id, data and data.conversationId) + if not membership then + return { success = false, error = "conversation_not_found" } + end + if type(data.notificationsEnabled) ~= "boolean" or type(data.readReceipts) ~= "boolean" then + return { success = false, error = "invalid_settings" } + end + local timer = tonumber(data.disappearingSeconds) + if not timer or not Config.DarkChat.AllowedDisappearTimers[timer] then + return { success = false, error = "invalid_timer" } + end + Bridge.Database.Transaction({ + { + query = "UPDATE `sky_phone_darkchat_members` SET `notifications_enabled` = ?, `read_receipts` = ? WHERE `conversation_id` = ? AND `profile_id` = ?", + params = { data.notificationsEnabled and 1 or 0, data.readReceipts and 1 or 0, data.conversationId, profile.id }, + }, + { + query = "UPDATE `sky_phone_darkchat_conversations` SET `disappearing_seconds` = ? WHERE `id` = ?", + params = { timer, data.conversationId }, + }, + { + query = "INSERT INTO `sky_phone_darkchat_messages` (`id`, `conversation_id`, `message_type`, `body`) VALUES (?, ?, 'system', ?)", + params = { uuid(), data.conversationId, "timer_changed:" .. tostring(timer) }, + }, + }) + TriggerClientEvent("sky_phone:darkchat:changed", source, { conversationId = data.conversationId }) + notify_profile(tonumber(membership.peer_id), "sky_phone:darkchat:changed", { + conversationId = data.conversationId, + }) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:darkchat:add-contact", function(source, data) + if not SkyPhone.AllowOperation(source, "darkchat_contact", Config.DarkChat.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local membership = load_membership(profile.id, data and data.conversationId) + local alias = trim(data and data.alias) + if not membership or not alias or alias == "" or #alias > Config.DarkChat.AliasMaxLength then + return { success = false, error = "invalid_contact" } + end + Bridge.Database.Query([[ + INSERT INTO `sky_phone_darkchat_contacts` (`profile_id`, `contact_profile_id`, `alias_override`) + VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `alias_override` = VALUES(`alias_override`) + ]], { profile.id, membership.peer_id, alias }) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:darkchat:remove-contact", function(source, data) + if not SkyPhone.AllowOperation(source, "darkchat_contact", Config.DarkChat.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local membership = load_membership(profile.id, data and data.conversationId) + if not membership then + return { success = false, error = "conversation_not_found" } + end + Bridge.Database.Query([[ + DELETE FROM `sky_phone_darkchat_contacts` WHERE `profile_id` = ? AND `contact_profile_id` = ? + ]], { profile.id, membership.peer_id }) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:darkchat:block", function(source, data) + if not SkyPhone.AllowOperation(source, "darkchat_block", Config.DarkChat.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local membership = load_membership(profile.id, data and data.conversationId) + if not membership or type(data.blocked) ~= "boolean" then + return { success = false, error = "invalid_request" } + end + if data.blocked then + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_darkchat_blocks` (`blocker_profile_id`, `blocked_profile_id`) + VALUES (?, ?) + ]], { profile.id, membership.peer_id }) + else + Bridge.Database.Query([[ + DELETE FROM `sky_phone_darkchat_blocks` WHERE `blocker_profile_id` = ? AND `blocked_profile_id` = ? + ]], { profile.id, membership.peer_id }) + end + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:darkchat:report", function(source, data) + if not SkyPhone.AllowOperation(source, "darkchat_report", Config.DarkChat.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local membership = load_membership(profile.id, data and data.conversationId) + local reason = data and data.reason + local details = trim(data and data.details) or "" + if not membership or not report_reasons[reason] or #details > 500 then + return { success = false, error = "invalid_report" } + end + local count = Bridge.Database.Query([[ + SELECT COUNT(*) AS `count` FROM `sky_phone_darkchat_reports` + WHERE `reporter_profile_id` = ? AND `created_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 DAY) + ]], { profile.id }) + if tonumber(count[1] and count[1].count) >= Config.DarkChat.ReportsPerDay then + return { success = false, error = "rate_limited" } + end + Bridge.Database.Query([[ + INSERT INTO `sky_phone_darkchat_reports` + (`id`, `reporter_profile_id`, `reported_profile_id`, `conversation_id`, `message_id`, `reason`, `details`) + VALUES (?, ?, ?, ?, ?, ?, ?) + ]], { uuid(), profile.id, membership.peer_id, data.conversationId, data.messageId, reason, details }) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:darkchat:clear", function(source, data) + if not SkyPhone.AllowOperation(source, "darkchat_clear", Config.DarkChat.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local membership = load_membership(profile.id, data and data.conversationId) + if not membership then + return { success = false, error = "conversation_not_found" } + end + Bridge.Database.Query([[ + UPDATE `sky_phone_darkchat_members` SET `cleared_at` = CURRENT_TIMESTAMP + WHERE `conversation_id` = ? AND `profile_id` = ? + ]], { data.conversationId, profile.id }) + return { success = true } +end) + +CreateThread(function() + while true do + Wait(Config.DarkChat.CleanupIntervalSeconds * 1000) + Bridge.Database.Query([[ + DELETE FROM `sky_phone_darkchat_messages` + WHERE `expires_at` IS NOT NULL AND `expires_at` <= CURRENT_TIMESTAMP + ]], {}) + end +end) + +end) diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index b1ef08b..b2d437a 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -657,6 +657,161 @@ local schema = { }, tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", }, + { + name = "sky_phone_darkchat_profiles", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "account_id", type = "BIGINT UNSIGNED NOT NULL" }, + { name = "dark_id", type = "CHAR(14) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "invite_code", type = "CHAR(11) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "alias", type = "VARCHAR(32) NOT NULL" }, + { name = "avatar_seed", type = "INT UNSIGNED NOT NULL" }, + { name = "notification_mode", type = "ENUM('full', 'private', 'hidden') NOT NULL DEFAULT 'private'" }, + { name = "activity_visible", type = "TINYINT(1) NOT NULL DEFAULT 0" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_darkchat_profile_account", columns = "(`account_id`)" }, + { name = "uniq_sky_phone_darkchat_profile_dark_id", columns = "(`dark_id`)" }, + { name = "uniq_sky_phone_darkchat_profile_invite", columns = "(`invite_code`)" }, + }, + foreignKeys = { + { column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_darkchat_contacts", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "profile_id", type = "BIGINT UNSIGNED NOT NULL" }, + { name = "contact_profile_id", type = "BIGINT UNSIGNED NOT NULL" }, + { name = "alias_override", type = "VARCHAR(32) NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_darkchat_contact", columns = "(`profile_id`, `contact_profile_id`)" }, + }, + foreignKeys = { + { column = "profile_id", references = "`sky_phone_darkchat_profiles` (`id`) ON DELETE CASCADE" }, + { column = "contact_profile_id", references = "`sky_phone_darkchat_profiles` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_darkchat_conversations", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "disappearing_seconds", type = "INT NOT NULL DEFAULT 0" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_darkchat_conversation_updated", columns = "(`updated_at`)" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_darkchat_members", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "conversation_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "profile_id", type = "BIGINT UNSIGNED NOT NULL" }, + { name = "notifications_enabled", type = "TINYINT(1) NOT NULL DEFAULT 1" }, + { name = "read_receipts", type = "TINYINT(1) NOT NULL DEFAULT 1" }, + { name = "last_read_at", type = "DATETIME NULL" }, + { name = "cleared_at", type = "DATETIME NULL" }, + { name = "joined_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_darkchat_member", columns = "(`conversation_id`, `profile_id`)" }, + }, + indexes = { + { name = "idx_sky_phone_darkchat_member_profile", columns = "(`profile_id`, `conversation_id`)" }, + }, + foreignKeys = { + { column = "conversation_id", references = "`sky_phone_darkchat_conversations` (`id`) ON DELETE CASCADE" }, + { column = "profile_id", references = "`sky_phone_darkchat_profiles` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_darkchat_messages", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "conversation_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "sender_profile_id", type = "BIGINT UNSIGNED NULL" }, + { name = "message_type", type = "ENUM('text', 'emoji', 'gif', 'voice', 'system') NOT NULL DEFAULT 'text'" }, + { name = "body", type = "TEXT NOT NULL" }, + { name = "media_payload", type = "LONGTEXT NULL" }, + { name = "media_mime", type = "VARCHAR(80) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "media_duration_ms", type = "INT UNSIGNED NULL" }, + { name = "media_waveform", type = "JSON NULL" }, + { name = "reply_to_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "reactions", type = "JSON NULL" }, + { name = "deleted_for_profiles", type = "JSON NULL" }, + { name = "deleted_for_everyone", type = "TINYINT(1) NOT NULL DEFAULT 0" }, + { name = "expires_at", type = "DATETIME NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_darkchat_message_thread", columns = "(`conversation_id`, `created_at`, `id`)" }, + { name = "idx_sky_phone_darkchat_message_expiry", columns = "(`expires_at`)" }, + }, + foreignKeys = { + { column = "conversation_id", references = "`sky_phone_darkchat_conversations` (`id`) ON DELETE CASCADE" }, + { column = "sender_profile_id", references = "`sky_phone_darkchat_profiles` (`id`) ON DELETE SET NULL" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_darkchat_blocks", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "blocker_profile_id", type = "BIGINT UNSIGNED NOT NULL" }, + { name = "blocked_profile_id", type = "BIGINT UNSIGNED NOT NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_darkchat_block", columns = "(`blocker_profile_id`, `blocked_profile_id`)" }, + }, + foreignKeys = { + { column = "blocker_profile_id", references = "`sky_phone_darkchat_profiles` (`id`) ON DELETE CASCADE" }, + { column = "blocked_profile_id", references = "`sky_phone_darkchat_profiles` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_darkchat_reports", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "reporter_profile_id", type = "BIGINT UNSIGNED NOT NULL" }, + { name = "reported_profile_id", type = "BIGINT UNSIGNED NOT NULL" }, + { name = "conversation_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "message_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "reason", type = "ENUM('spam', 'harassment', 'threats', 'illegal', 'other') NOT NULL" }, + { name = "details", type = "VARCHAR(500) NOT NULL DEFAULT ''" }, + { name = "status", type = "ENUM('open', 'reviewed', 'dismissed') NOT NULL DEFAULT 'open'" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_darkchat_report_target", columns = "(`reported_profile_id`, `created_at`)" }, + }, + foreignKeys = { + { column = "reporter_profile_id", references = "`sky_phone_darkchat_profiles` (`id`) ON DELETE CASCADE" }, + { column = "reported_profile_id", references = "`sky_phone_darkchat_profiles` (`id`) ON DELETE CASCADE" }, + { column = "conversation_id", references = "`sky_phone_darkchat_conversations` (`id`) ON DELETE SET NULL" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, } Bridge.Database.Migrate("sky_phone", schema)