-- Non-destructive LB Phone importer. Every target write is idempotent and every -- completed domain receives its own marker, so later versions can add domains -- without replaying already migrated data. LB's source tables are read-only. Bridge.Database.AfterMigration("sky_phone", function() local migration_config = Config.Migrations and Config.Migrations.LbPhone or {} local source_name = "lb-phone" local marker_prefix = "lb-phone:" local running = false -- Increment a domain version whenever an idempotent repair must run for -- installations that already recorded the previous importer as complete. local domain_versions = { picstagram = 2, flipTok = 2, feather = 2, } local source_prefix = migration_config.SourcePrefix or "phone_" if type(source_prefix) ~= "string" or not source_prefix:match("^[%w_]+$") then error("[sky_phone] LB Phone migration SourcePrefix must contain only letters, numbers, and underscores.") end local resolved_source_tables = {} local function source_table(suffix) if resolved_source_tables[suffix] then return resolved_source_tables[suffix] end local table_name = source_prefix .. suffix local rescued_name = table_name .. "_lb" local rescued = Bridge.Database.Query([[ SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND TABLE_TYPE = 'BASE TABLE' LIMIT 1 ]], { rescued_name }) resolved_source_tables[suffix] = rescued[1] and rescued_name or table_name return resolved_source_tables[suffix] end local function affected_rows(result) if type(result) == "number" then return result end if type(result) == "table" then return tonumber(result.affectedRows) or tonumber(result.affected_rows) or 0 end return 0 end local function table_exists(table_name) local rows = Bridge.Database.Query([[ SELECT COUNT(*) AS `count` FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND TABLE_TYPE = 'BASE TABLE' ]], { table_name }) return rows[1] and tonumber(rows[1].count) > 0 or false end local function table_has_column(table_name, column_name) local rows = Bridge.Database.Query([[ SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1 ]], { table_name, column_name }) return rows[1] ~= nil end local function count_rows(table_name) if not table_exists(table_name) then return 0 end local rows = Bridge.Database.Query(("SELECT COUNT(*) AS `count` FROM `%s`"):format(table_name), {}) return rows[1] and tonumber(rows[1].count) or 0 end local function clamp_text(value, maximum) if value == nil then return "" end return tostring(value):sub(1, maximum) end local function normalize_number(value) return SkyPhoneSimNumber.Normalize(value, Config.Sim.NumberLength, Config.Sim.NumberPrefix) end local function deterministic_hex(namespace, value) local parts = {} for index = 1, 4 do local hash = joaat(("%s:%s:%s"):format(namespace, tostring(value), index)) if hash < 0 then hash = hash + 4294967296 end parts[index] = ("%08x"):format(hash) end return table.concat(parts) end local function deterministic_uuid(namespace, value) local value_hash = deterministic_hex(namespace, value) return ("%s-%s-%s-%s-%s"):format( value_hash:sub(1, 8), value_hash:sub(9, 12), value_hash:sub(13, 16), value_hash:sub(17, 20), value_hash:sub(21, 32) ) end local function deterministic_imei(value) return SkyPhoneImei.FromEntropy(deterministic_hex("lb-phone-imei", value)) end local function synthetic_email(namespace, value) local domain = tostring(Config.Mail.Domain or "ifruit.com"):lower() local suffix = "@" .. domain local local_maximum = math.max(3, 64 - #suffix) local local_part = (namespace .. "_" .. deterministic_hex(namespace, value)):sub(1, local_maximum) return local_part .. suffix end local function decode_json(value) if type(value) == "table" then return value end if type(value) ~= "string" or value == "" then return {} end local success, decoded = pcall(json.decode, value) if success and type(decoded) == "table" then return decoded end return {} end local function insert_many(prefix, width, rows, suffix) if #rows == 0 then return 0 end local inserted = 0 for first = 1, #rows, 250 do local last = math.min(first + 249, #rows) local groups = {} local parameters = {} for row_index = first, last do local cells = {} local row = rows[row_index] for column = 1, width do if row[column] == nil then cells[column] = "NULL" else cells[column] = "?" parameters[#parameters + 1] = row[column] end end groups[#groups + 1] = "(" .. table.concat(cells, ",") .. ")" end local query = prefix .. " " .. table.concat(groups, ",") if suffix then query = query .. " " .. suffix end inserted = inserted + affected_rows(Bridge.Database.Query(query, parameters)) end return inserted end local function migration_name(domain) local version = domain_versions[domain] return marker_prefix .. domain .. (version and (":v" .. version) or "") end local function migration_done(domain) local rows = Bridge.Database.Query([[ SELECT 1 FROM `sky_phone_migrations` WHERE `name` = ? LIMIT 1 ]], { migration_name(domain) }) return rows[1] ~= nil end local function record_migration(domain, stats) Bridge.Database.Query([[ INSERT IGNORE INTO `sky_phone_migrations` (`name`, `source`, `stats`) VALUES (?, ?, ?) ]], { migration_name(domain), source_name, json.encode(stats or {}) }) end local function load_roster() local rows local success, result = pcall(function() if Bridge.Framework.Name == "esx" then return Bridge.Database.Query("SELECT `identifier` AS `character_id`, `identifier` AS `license` FROM `users`", {}) end return Bridge.Database.Query("SELECT `citizenid` AS `character_id`, `license` FROM `players`", {}) end) if success then rows = result else Bridge.Debug( "error", "[sky_phone] LB Phone migration could not load the framework character roster: %s", tostring(result), { always = true } ) rows = {} end local direct = {} local licenses = {} for index = 1, #rows do local character_id = tostring(rows[index].character_id or "") local license = tostring(rows[index].license or "") if character_id ~= "" and #character_id <= 80 then direct[character_id] = character_id local embedded_license = character_id:match("(license[%w]*:[%w]+)") if license == "" and embedded_license then license = embedded_license end if license ~= "" then licenses[license] = licenses[license] or {} licenses[license][#licenses[license] + 1] = character_id end end end return direct, licenses end local function resolve_owner(owner_id, direct, licenses) owner_id = tostring(owner_id or "") local mode = migration_config.IdentifierMode or "auto" if owner_id == "" or #owner_id > 100 then return nil, "unresolved" end if mode ~= "license" and direct[owner_id] then return direct[owner_id] end if mode == "identifier" then return nil, "unresolved" end local matches = licenses[owner_id] if not matches then return nil, "unresolved" end if #matches ~= 1 then return nil, "ambiguous" end return matches[1] end local function load_context() local context = { number_map = {}, owner_by_number = {}, owner_by_phone_id = {}, sim_by_number = {}, } local numbers = Bridge.Database.Query([[ SELECT `source_number`, `phone_number`, `sim_id`, `owned` FROM `sky_phone_migration_numbers` WHERE `source` = ? ]], { source_name }) for index = 1, #numbers do local row = numbers[index] local mapped = { phone_number = row.phone_number, sim_id = row.sim_id, owned = tonumber(row.owned) == 1, } context.number_map[tostring(row.source_number)] = mapped context.number_map[tostring(row.phone_number)] = mapped end local owners = Bridge.Database.Query([[ SELECT `source_phone_id`, `source_phone_number`, `source_owner_id`, `owner_identifier`, `device_imei`, `sim_id`, `account_id` FROM `sky_phone_migration_owners` WHERE `source` = ? ]], { source_name }) for index = 1, #owners do local row = owners[index] context.owner_by_number[tostring(row.source_phone_number)] = row context.owner_by_phone_id[tostring(row.source_phone_id)] = row end local sims = Bridge.Database.Query("SELECT `id`, `phone_number`, `owner_identifier`, `sim_type` FROM `sky_phone_sims`", {}) for index = 1, #sims do context.sim_by_number[tostring(sims[index].phone_number)] = sims[index] end return context end local function ensure_number_maps(context, values, dry_run) local unique = {} local sim_rows = {} local mapping_rows = {} local invalid = 0 for index = 1, #values do local source_number = tostring(values[index] or "") if source_number ~= "" and not unique[source_number] and not context.number_map[source_number] then unique[source_number] = true local normalized = normalize_number(source_number) if not normalized then invalid = invalid + 1 else local sim = context.sim_by_number[normalized] if not sim then local sim_id = deterministic_uuid("lb-phone-external-sim", normalized) sim = { id = sim_id, phone_number = normalized, owner_identifier = nil, } context.sim_by_number[normalized] = sim sim_rows[#sim_rows + 1] = { sim_id, normalized, "anonymous", 1 } end mapping_rows[#mapping_rows + 1] = { source_name, source_number, normalized, sim.id, 0, } context.number_map[source_number] = { phone_number = normalized, sim_id = sim.id, owned = false, } end end end if not dry_run then insert_many([[ INSERT IGNORE INTO `sky_phone_sims` (`id`, `phone_number`, `sim_type`, `is_virtual`) VALUES ]], 4, sim_rows) insert_many([[ INSERT IGNORE INTO `sky_phone_migration_numbers` (`source`, `source_number`, `phone_number`, `sim_id`, `owned`) VALUES ]], 5, mapping_rows) end return #mapping_rows, invalid end local function run_devices(dry_run) local phones_table = source_table("phones") if not table_exists(phones_table) then return nil, "source_missing" end local source_phones = Bridge.Database.Query(([[ SELECT phone.*, DATE_FORMAT(phone.`last_seen`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` phone ]]):format(phones_table), {}) local direct, licenses = load_roster() local resolved = {} local owners = {} local stats = { source = #source_phones, resolved = 0, unresolved = 0, ambiguous = 0, invalid_numbers = 0, imported = 0, conflicts = 0, } for index = 1, #source_phones do local phone = source_phones[index] local owner_identifier, reason = resolve_owner(phone.owner_id, direct, licenses) local normalized = normalize_number(phone.phone_number) if not owner_identifier then stats[reason] = (stats[reason] or 0) + 1 elseif not normalized then stats.invalid_numbers = stats.invalid_numbers + 1 else phone.source_phone_id = tostring(phone.id) phone.source_number = tostring(phone.phone_number) phone.normalized_number = normalized phone.owner_identifier = owner_identifier resolved[#resolved + 1] = phone owners[owner_identifier] = owners[owner_identifier] or {} owners[owner_identifier][#owners[owner_identifier] + 1] = phone stats.resolved = stats.resolved + 1 end end if dry_run then stats.imported = #resolved return stats end local preferred_numbers = {} local last_table = source_table("last_phone") if table_exists(last_table) then local last_rows = Bridge.Database.Query(("SELECT `id`, `phone_number` FROM `%s`"):format(last_table), {}) for index = 1, #last_rows do preferred_numbers[tostring(last_rows[index].id)] = tostring(last_rows[index].phone_number) end end local character_rows = Bridge.Database.Query([[ SELECT c.`owner_identifier`, c.`device_imei`, d.`account_id`, d.`sim_id`, s.`phone_number` FROM `sky_phone_character_devices` c JOIN `sky_phone_devices` d ON d.`imei` = c.`device_imei` LEFT JOIN `sky_phone_sims` s ON s.`id` = d.`sim_id` ]], {}) local character_devices = {} for index = 1, #character_rows do character_devices[tostring(character_rows[index].owner_identifier)] = character_rows[index] end local existing_mappings = Bridge.Database.Query([[ SELECT * FROM `sky_phone_migration_owners` WHERE `source` = ? ]], { source_name }) local mapping_by_phone_id = {} local migrated_account_by_owner = {} for index = 1, #existing_mappings do local row = existing_mappings[index] mapping_by_phone_id[tostring(row.source_phone_id)] = row if row.account_id then migrated_account_by_owner[tostring(row.owner_identifier)] = tonumber(row.account_id) end end local account_rows = {} local account_email_by_owner = {} for owner_identifier in pairs(owners) do local character = character_devices[owner_identifier] if not (character and character.account_id) and not migrated_account_by_owner[owner_identifier] then local email = synthetic_email("lb", owner_identifier) account_email_by_owner[owner_identifier] = email account_rows[#account_rows + 1] = { email, deterministic_hex("lb-account-password", owner_identifier) } end end insert_many("INSERT IGNORE INTO `sky_phone_accounts` (`email`, `password`) VALUES", 2, account_rows) local accounts = Bridge.Database.Query("SELECT `id`, `email` FROM `sky_phone_accounts`", {}) local account_by_email = {} for index = 1, #accounts do account_by_email[tostring(accounts[index].email):lower()] = tonumber(accounts[index].id) end local account_by_owner = {} for owner_identifier in pairs(owners) do local character = character_devices[owner_identifier] account_by_owner[owner_identifier] = character and tonumber(character.account_id) or migrated_account_by_owner[owner_identifier] or account_by_email[account_email_by_owner[owner_identifier]] if character and not character.account_id and account_by_owner[owner_identifier] then Bridge.Database.Query([[ UPDATE `sky_phone_devices` SET `account_id` = ? WHERE `imei` = ? AND `account_id` IS NULL ]], { account_by_owner[owner_identifier], character.device_imei }) end end local sim_rows = Bridge.Database.Query([[ SELECT s.`id`, s.`phone_number`, s.`owner_identifier`, d.`imei` AS `device_imei` FROM `sky_phone_sims` s LEFT JOIN `sky_phone_devices` d ON d.`sim_id` = s.`id` ]], {}) local sim_by_number = {} for index = 1, #sim_rows do sim_by_number[tostring(sim_rows[index].phone_number)] = sim_rows[index] end local preferred_by_owner = {} for owner_identifier, owner_phones in pairs(owners) do local wanted = preferred_numbers[tostring(owner_phones[1].owner_id)] or preferred_numbers[owner_identifier] preferred_by_owner[owner_identifier] = owner_phones[1] if wanted then for index = 1, #owner_phones do if owner_phones[index].source_number == wanted then preferred_by_owner[owner_identifier] = owner_phones[index] break end end end end local new_sims = {} for index = 1, #resolved do local phone = resolved[index] local character = character_devices[phone.owner_identifier] local preferred_has_sim = preferred_by_owner[phone.owner_identifier] == phone and character and character.sim_id if not preferred_has_sim and not sim_by_number[phone.normalized_number] then local sim_id = deterministic_uuid("lb-phone-sim", phone.normalized_number) sim_by_number[phone.normalized_number] = { id = sim_id, phone_number = phone.normalized_number, owner_identifier = phone.owner_identifier, } new_sims[#new_sims + 1] = { sim_id, phone.normalized_number, "registered", Config.Sim.Enabled == false and 1 or 0, phone.owner_identifier, phone.migrated_at, } end end insert_many([[ INSERT IGNORE INTO `sky_phone_sims` (`id`, `phone_number`, `sim_type`, `is_virtual`, `owner_identifier`, `registered_at`) VALUES ]], 6, new_sims) local new_devices = {} local pending = {} for index = 1, #resolved do local phone = resolved[index] local existing = mapping_by_phone_id[phone.source_phone_id] local character = character_devices[phone.owner_identifier] local preferred = preferred_by_owner[phone.owner_identifier] == phone local sim = sim_by_number[phone.normalized_number] local device_imei local mapped_sim = sim if existing then device_imei = existing.device_imei mapped_sim = { id = existing.sim_id, phone_number = phone.normalized_number } elseif preferred and character then device_imei = character.device_imei if character.sim_id then mapped_sim = { id = character.sim_id, phone_number = character.phone_number } else Bridge.Database.Query([[ UPDATE `sky_phone_devices` SET `sim_id` = ?, `account_id` = COALESCE(`account_id`, ?) WHERE `imei` = ? AND `sim_id` IS NULL ]], { sim.id, account_by_owner[phone.owner_identifier], device_imei }) end elseif sim.owner_identifier and sim.owner_identifier ~= phone.owner_identifier then stats.conflicts = stats.conflicts + 1 elseif sim.device_imei then device_imei = sim.device_imei else device_imei = deterministic_imei(phone.source_phone_id) new_devices[#new_devices + 1] = { device_imei, account_by_owner[phone.owner_identifier], sim.id, clamp_text(phone.name and phone.name ~= "" and phone.name or Config.Phone.DeviceName, 64), phone.migrated_at, } end if device_imei and mapped_sim and mapped_sim.id then pending[#pending + 1] = { phone = phone, preferred = preferred, device_imei = device_imei, sim_id = mapped_sim.id, phone_number = mapped_sim.phone_number or phone.normalized_number, account_id = account_by_owner[phone.owner_identifier], } end end insert_many([[ INSERT IGNORE INTO `sky_phone_devices` (`imei`, `account_id`, `sim_id`, `device_name`, `created_at`) VALUES ]], 5, new_devices) local owner_rows = {} local number_rows = {} local character_mappings = {} for index = 1, #pending do local entry = pending[index] local phone = entry.phone owner_rows[#owner_rows + 1] = { source_name, phone.source_phone_id, tostring(phone.owner_id), phone.owner_identifier, phone.source_number, entry.device_imei, entry.sim_id, entry.account_id, } number_rows[#number_rows + 1] = { source_name, phone.source_number, entry.phone_number, entry.sim_id, 1, } if entry.preferred and not character_devices[phone.owner_identifier] then character_mappings[#character_mappings + 1] = { phone.owner_identifier, entry.device_imei } end end insert_many([[ INSERT IGNORE INTO `sky_phone_migration_owners` (`source`, `source_phone_id`, `source_owner_id`, `owner_identifier`, `source_phone_number`, `device_imei`, `sim_id`, `account_id`) VALUES ]], 8, owner_rows) insert_many([[ INSERT IGNORE INTO `sky_phone_migration_numbers` (`source`, `source_number`, `phone_number`, `sim_id`, `owned`) VALUES ]], 5, number_rows) insert_many([[ INSERT IGNORE INTO `sky_phone_character_devices` (`owner_identifier`, `device_imei`) VALUES ]], 2, character_mappings) stats.imported = #pending return stats end local function percentage(value, fallback) local number = tonumber(value) if not number then return fallback end if number <= 1 then number = number * 100 end return math.max(0, math.min(100, math.floor(number + 0.5))) end local function valid_media_url(value) if type(value) ~= "string" then return nil end value = value:match("^%s*(.-)%s*$") if #value > 2000000 then return nil end if value:match("^https?://") or value:match("^nui://") then return value end return nil end local function first_attachment(value) local attachments = decode_json(value) local attachment = attachments[1] if type(attachment) == "string" then return valid_media_url(attachment), "image" end if type(attachment) ~= "table" then return nil, nil end local url = valid_media_url(attachment.url or attachment.src or attachment.link or attachment.attachment) local media_type = tostring(attachment.type or attachment.mediaType or "image"):lower() if media_type:find("video", 1, true) then media_type = "video" elseif media_type:find("gif", 1, true) then media_type = "gif" else media_type = "image" end return url, media_type end local function load_media_ids(pattern) local rows = Bridge.Database.Query([[ SELECT `id`, `source_id` FROM `sky_phone_media` WHERE `origin` = 'website_import' AND `source_id` LIKE ? ]], { pattern }) local media = {} for index = 1, #rows do media[tostring(rows[index].source_id)] = tonumber(rows[index].id) end return media end local function run_settings(dry_run) local table_name = source_table("phones") if not table_exists(table_name) then return { source = 0, imported = 0, skipped = 0 } end local rows = Bridge.Database.Query(([[ SELECT p.`id`, p.`pin`, p.`settings`, p.`is_setup`, owner.`device_imei` FROM `%s` p JOIN `sky_phone_migration_owners` owner ON owner.`source` = ? AND owner.`source_phone_id` = p.`id` ]]):format(table_name), { source_name }) local data_rows = {} local security_rows = {} local skipped = 0 for index = 1, #rows do local row = rows[index] local source_settings = decode_json(row.settings) local display = type(source_settings.display) == "table" and source_settings.display or {} local sound = type(source_settings.sound) == "table" and source_settings.sound or {} local wallpaper = type(source_settings.wallpaper) == "table" and source_settings.wallpaper or {} local theme = tostring(display.theme or "automatic"):lower() if theme ~= "light" and theme ~= "dark" then theme = "automatic" end local wallpaper_url = valid_media_url(wallpaper.background) local setup_completed = row.is_setup == true or tonumber(row.is_setup) == 1 local payload = { version = 1, settings = { appearanceMode = theme, notificationVolume = percentage(sound.volume, 70), phoneScale = percentage(display.size, 100), ringtoneVolume = percentage(sound.callVolume, 80), screenBrightness = percentage(display.brightness, 100), setupCompleted = setup_completed, setupStep = setup_completed and 9 or 0, wallpaper = wallpaper_url and "custom" or "midnight", wallpaperHistory = wallpaper_url and { { imageUrl = wallpaper_url, wallpaper = "custom" } } or { { wallpaper = "midnight" } }, wallpaperImageUrl = wallpaper_url, }, } data_rows[#data_rows + 1] = { row.device_imei, "settings", json.encode(payload) } local pin = type(row.pin) == "string" and row.pin or "" if pin:match("^%d%d%d%d$") then security_rows[#security_rows + 1] = { row.device_imei, pin, deterministic_hex("lb-phone-passcode-salt", row.id), #pin, } elseif pin ~= "" then skipped = skipped + 1 end end if not dry_run then insert_many([[ INSERT IGNORE INTO `sky_phone_device_data` (`device_imei`, `namespace`, `payload`) VALUES ]], 3, data_rows) local pepper = tostring(Config.Server.PasscodePepper or "") for index = 1, #security_rows do local row = security_rows[index] Bridge.Database.Query([[ INSERT IGNORE INTO `sky_phone_device_security` (`device_imei`, `passcode_hash`, `passcode_salt`, `passcode_length`) VALUES (?, UNHEX(SHA2(CONCAT(?, ?, ?), 256)), ?, ?) ]], { row[1], pepper, row[3], row[2], row[3], row[4] }) end end return { source = #rows, imported = #data_rows, passcodes = #security_rows, skipped = skipped } end local function run_alarms(dry_run) local table_name = source_table("clock_alarms") if not table_exists(table_name) then return { source = 0, imported = 0 } end local rows = Bridge.Database.Query(([[ SELECT alarm.*, owner.`device_imei` FROM `%s` alarm JOIN `sky_phone_migration_owners` owner ON owner.`source` = ? AND owner.`source_phone_number` = alarm.`phone_number` ORDER BY alarm.`phone_number`, alarm.`id` ]]):format(table_name), { source_name }) local alarms_by_device = {} for index = 1, #rows do local row = rows[index] alarms_by_device[row.device_imei] = alarms_by_device[row.device_imei] or {} alarms_by_device[row.device_imei][#alarms_by_device[row.device_imei] + 1] = { id = "lb-alarm-" .. tostring(row.id), enabled = row.enabled == true or tonumber(row.enabled) == 1, note = clamp_text(row.label, 80), sound = "radar", time = ("%02d:%02d"):format( math.max(0, math.min(23, tonumber(row.hours) or 0)), math.max(0, math.min(59, tonumber(row.minutes) or 0)) ), weekdays = {}, } end local data_rows = {} for imei, alarms in pairs(alarms_by_device) do data_rows[#data_rows + 1] = { imei, "alarms", json.encode(alarms) } end if not dry_run then insert_many([[ INSERT IGNORE INTO `sky_phone_device_data` (`device_imei`, `namespace`, `payload`) VALUES ]], 3, data_rows) end return { source = #rows, imported = #rows, devices = #data_rows } end local function run_contacts(dry_run) local table_name = source_table("phone_contacts") if not table_exists(table_name) then return { source = 0, imported = 0, skipped = 0 } end local rows = Bridge.Database.Query(([[ SELECT contact.*, owner.`account_id`, owner.`device_imei` FROM `%s` contact JOIN `sky_phone_migration_owners` owner ON owner.`source` = ? AND owner.`source_phone_number` = contact.`phone_number` ]]):format(table_name), { source_name }) local context = load_context() local contact_numbers = {} for index = 1, #rows do contact_numbers[#contact_numbers + 1] = rows[index].contact_phone_number end local _, invalid = ensure_number_maps(context, contact_numbers, dry_run) local avatar_rows = {} for index = 1, #rows do local row = rows[index] local avatar = valid_media_url(row.profile_image) if avatar then local key = deterministic_uuid("lb-phone-contact-avatar", tostring(row.phone_number) .. ":" .. tostring(row.contact_phone_number)) avatar_rows[#avatar_rows + 1] = { row.account_id, avatar, "lb-contact-avatar:" .. key, "photo", "website_import", "lb-contact:" .. key, } end end if not dry_run then insert_many([[ INSERT IGNORE INTO `sky_phone_media` (`account_id`, `url`, `remote_id`, `media_type`, `origin`, `source_id`, `verified_at`) VALUES ]], 7, (function() local with_verification = {} for index = 1, #avatar_rows do with_verification[index] = { avatar_rows[index][1], avatar_rows[index][2], avatar_rows[index][3], avatar_rows[index][4], avatar_rows[index][5], avatar_rows[index][6], os.date("!%Y-%m-%d %H:%M:%S"), } end return with_verification end)()) end local avatar_ids = dry_run and {} or load_media_ids("lb-contact:%") local contact_rows = {} local skipped = invalid for index = 1, #rows do local row = rows[index] local target_number = context.number_map[tostring(row.contact_phone_number)] if target_number then local key = deterministic_uuid("lb-phone-contact-avatar", tostring(row.phone_number) .. ":" .. tostring(row.contact_phone_number)) local contact_id = deterministic_uuid("lb-phone-contact", tostring(row.phone_number) .. ":" .. tostring(row.contact_phone_number)) local name = (clamp_text(row.firstname, 50) .. " " .. clamp_text(row.lastname, 50)):match("^%s*(.-)%s*$") if name == "" then name = target_number.phone_number end contact_rows[#contact_rows + 1] = { contact_id, contact_id, row.account_id, clamp_text(name, 80), clamp_text(row.address, 500), clamp_text(row.email, 64) ~= "" and clamp_text(row.email, 64) or nil, target_number.phone_number, avatar_ids["lb-contact:" .. key], (row.favourite == true or tonumber(row.favourite) == 1) and 1 or 0, } else skipped = skipped + 1 end end if not dry_run then insert_many([[ INSERT IGNORE INTO `sky_phone_contacts` (`id`, `contact_id`, `account_id`, `name`, `notes`, `email`, `phone_number`, `avatar_media_id`, `favorite`) VALUES ]], 9, contact_rows) end return { source = #rows, imported = #contact_rows, avatars = #avatar_rows, skipped = skipped } end local function run_blocked(dry_run) local table_name = source_table("phone_blocked_numbers") if not table_exists(table_name) then return { source = 0, imported = 0, skipped = 0 } end local rows = Bridge.Database.Query(([[ SELECT blocked.* FROM `%s` blocked JOIN `sky_phone_migration_owners` owner ON owner.`source` = ? AND owner.`source_phone_number` = blocked.`phone_number` ]]):format(table_name), { source_name }) local context = load_context() local values = {} for index = 1, #rows do values[index] = rows[index].blocked_number end ensure_number_maps(context, values, dry_run) local inserts = {} local skipped = 0 for index = 1, #rows do local blocker = context.number_map[tostring(rows[index].phone_number)] local blocked = context.number_map[tostring(rows[index].blocked_number)] if blocker and blocked and blocker.sim_id ~= blocked.sim_id then inserts[#inserts + 1] = { blocker.sim_id, blocked.sim_id } else skipped = skipped + 1 end end if not dry_run then insert_many([[ INSERT IGNORE INTO `sky_phone_call_blocks` (`blocker_sim_id`, `blocked_sim_id`) VALUES ]], 2, inserts) end return { source = #rows, imported = #inserts, skipped = skipped } end local function run_calls(dry_run) local table_name = source_table("phone_calls") if not table_exists(table_name) then return { source = 0, imported = 0, skipped = 0 } end local rows = Bridge.Database.Query(([[ SELECT call_row.*, DATE_FORMAT(call_row.`timestamp`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` call_row WHERE EXISTS ( SELECT 1 FROM `sky_phone_migration_owners` owner WHERE owner.`source` = ? AND (owner.`source_phone_number` = call_row.`caller` OR owner.`source_phone_number` = call_row.`callee`) ) ]]):format(table_name), { source_name }) local context = load_context() local values = {} for index = 1, #rows do values[#values + 1] = rows[index].caller values[#values + 1] = rows[index].callee end ensure_number_maps(context, values, dry_run) local calls = {} local entries = {} local skipped = 0 for index = 1, #rows do local row = rows[index] local caller = context.number_map[tostring(row.caller)] local callee = context.number_map[tostring(row.callee)] if caller and callee then local call_id = deterministic_uuid("lb-phone-call", row.id) local answered = row.answered == true or tonumber(row.answered) == 1 local status = answered and "ended" or "missed" calls[#calls + 1] = { call_id, caller.sim_id, callee.sim_id, caller.phone_number, callee.phone_number, status, row.migrated_at, answered and row.migrated_at or nil, row.migrated_at, math.max(0, tonumber(row.duration) or 0), } local caller_owner = context.owner_by_number[tostring(row.caller)] local callee_owner = context.owner_by_number[tostring(row.callee)] if caller_owner then entries[#entries + 1] = { call_id, caller_owner.account_id, nil, "outgoing", status, callee.phone_number, row.migrated_at, } end if callee_owner then entries[#entries + 1] = { call_id, callee_owner.account_id, nil, "incoming", status, caller.phone_number, row.migrated_at, } end else skipped = skipped + 1 end end if not dry_run then insert_many([[ INSERT IGNORE INTO `sky_phone_calls` (`id`, `caller_sim_id`, `callee_sim_id`, `caller_number`, `callee_number`, `status`, `started_at`, `answered_at`, `ended_at`, `duration_seconds`) VALUES ]], 10, calls) local existing = Bridge.Database.Query([[ SELECT `call_id`, COALESCE(`account_id`, 0) AS `account_id`, COALESCE(`device_imei`, '') AS `device_imei`, `direction` FROM `sky_phone_call_entries` WHERE `call_id` LIKE '________-____-____-____-____________' ]], {}) local seen = {} for index = 1, #existing do seen[("%s:%s:%s:%s"):format( existing[index].call_id, tostring(existing[index].account_id), tostring(existing[index].device_imei), existing[index].direction )] = true end local unique_entries = {} for index = 1, #entries do local row = entries[index] local key = ("%s:%s::%s"):format(row[1], tostring(row[2] or 0), row[4]) if not seen[key] then seen[key] = true unique_entries[#unique_entries + 1] = row end end insert_many([[ INSERT INTO `sky_phone_call_entries` (`call_id`, `account_id`, `device_imei`, `direction`, `status`, `other_number`, `created_at`) VALUES ]], 7, unique_entries) end return { source = #rows, imported = #calls, entries = #entries, skipped = skipped } end local function run_messages(dry_run) local channels_table = source_table("message_channels") local members_table = source_table("message_members") local messages_table = source_table("message_messages") if not table_exists(channels_table) or not table_exists(members_table) or not table_exists(messages_table) then return { source = 0, imported = 0, skipped = 0 } end local rows = Bridge.Database.Query(([[ SELECT message_row.*, first_member.`phone_number` AS `first_number`, DATE_FORMAT(message_row.`timestamp`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at`, second_member.`phone_number` AS `second_number` FROM `%s` message_row JOIN `%s` channel_row ON channel_row.`id` = message_row.`channel_id` JOIN `%s` first_member ON first_member.`channel_id` = message_row.`channel_id` JOIN `%s` second_member ON second_member.`channel_id` = message_row.`channel_id` AND first_member.`phone_number` < second_member.`phone_number` WHERE channel_row.`is_group` = 0 AND (SELECT COUNT(*) FROM `%s` member_count WHERE member_count.`channel_id` = message_row.`channel_id`) = 2 AND message_row.`sender` IN (first_member.`phone_number`, second_member.`phone_number`) AND EXISTS ( SELECT 1 FROM `sky_phone_migration_owners` owner WHERE owner.`source` = ? AND owner.`source_phone_number` IN ( first_member.`phone_number`, second_member.`phone_number` ) ) ORDER BY message_row.`channel_id`, message_row.`timestamp`, message_row.`id` ]]):format(messages_table, channels_table, members_table, members_table, members_table), { source_name }) local context = load_context() local values = {} for index = 1, #rows do values[#values + 1] = rows[index].first_number values[#values + 1] = rows[index].second_number end ensure_number_maps(context, values, dry_run) local messages = {} local skipped = 0 local replies = 0 for index = 1, #rows do local row = rows[index] local sender_number = tostring(row.sender) local recipient_number = sender_number == tostring(row.first_number) and tostring(row.second_number) or tostring(row.first_number) local sender = context.number_map[sender_number] local recipient = context.number_map[recipient_number] if sender and recipient then local media_payload, message_type = first_attachment(row.attachments) messages[#messages + 1] = { deterministic_uuid("lb-phone-message", row.id), sender.sim_id, recipient.sim_id, sender.phone_number, recipient.phone_number, media_payload and message_type or "text", clamp_text(row.content, Config.Messages.BodyMaxLength), media_payload, row.migrated_at, row.migrated_at, } if row.reply_to then replies = replies + 1 end else skipped = skipped + 1 end end if not dry_run then insert_many([[ INSERT IGNORE INTO `sky_phone_sms_messages` (`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`, `message_type`, `body`, `media_payload`, `read_at`, `created_at`) VALUES ]], 10, messages) end local groups = tonumber((Bridge.Database.Query( ("SELECT COUNT(*) AS `count` FROM `%s` WHERE `is_group` = 1"):format(channels_table), {} )[1] or {}).count) or 0 local reactions = count_rows(source_table("message_reactions")) return { source = #rows, imported = #messages, skipped = skipped, unsupported_group_channels = groups, unsupported_reactions = reactions, unsupported_replies = replies, } end local function run_photos(dry_run) local table_name = source_table("photos") if not table_exists(table_name) then return { source = 0, imported = 0, skipped = 0 } end if not table_has_column(table_name, "phone_number") or not table_has_column(table_name, "link") then return { source = count_rows(table_name), imported = 0, skipped = count_rows(table_name), unsupported_schema = table_name, } end local rows = Bridge.Database.Query(([[ SELECT photo.*, owner.`account_id`, DATE_FORMAT(photo.`timestamp`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` photo JOIN `sky_phone_migration_owners` owner ON owner.`source` = ? AND owner.`source_phone_number` = photo.`phone_number` ]]):format(table_name), { source_name }) local media_rows = {} local skipped = 0 for index = 1, #rows do local row = rows[index] local url = valid_media_url(row.link) if url then local key = deterministic_uuid("lb-phone-photo", row.id) media_rows[#media_rows + 1] = { row.account_id, url, "lb-photo:" .. key, (row.is_video == true or tonumber(row.is_video) == 1) and "video" or "photo", "website_import", "lb-photo:" .. key, row.migrated_at, (row.is_favourite == true or tonumber(row.is_favourite) == 1) and 1 or 0, row.migrated_at, } else skipped = skipped + 1 end end if not dry_run then insert_many([[ INSERT IGNORE INTO `sky_phone_media` (`account_id`, `url`, `remote_id`, `media_type`, `origin`, `source_id`, `verified_at`, `favorite`, `created_at`) VALUES ]], 9, media_rows) end return { source = #rows, imported = #media_rows, skipped = skipped, unsupported_albums = count_rows(source_table("photo_albums")), } end local function run_notes(dry_run) local table_name = source_table("notes") if not table_exists(table_name) then return { source = 0, imported = 0 } end if not table_has_column(table_name, "phone_number") or not table_has_column(table_name, "content") then return { source = count_rows(table_name), imported = 0, skipped = count_rows(table_name), unsupported_schema = table_name, } end local rows = Bridge.Database.Query(([[ SELECT note.*, owner.`account_id`, DATE_FORMAT(note.`timestamp`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` note JOIN `sky_phone_migration_owners` owner ON owner.`source` = ? AND owner.`source_phone_number` = note.`phone_number` ]]):format(table_name), { source_name }) local notes = {} for index = 1, #rows do notes[#notes + 1] = { "lb-phone-note:" .. tostring(rows[index].id), rows[index].account_id, clamp_text(rows[index].title, 120), tostring(rows[index].content or ""), rows[index].migrated_at, rows[index].migrated_at, } end if not dry_run then insert_many([[ INSERT IGNORE INTO `sky_phone_notes` (`id`, `account_id`, `title`, `body`, `created_at`, `updated_at`) VALUES ]], 6, notes) end return { source = #rows, imported = #notes } end local function run_wallet(dry_run) local table_name = source_table("wallet_transactions") if not table_exists(table_name) then return { source = 0, imported = 0, skipped = 0 } end local rows = Bridge.Database.Query(([[ SELECT transaction_row.*, owner.`owner_identifier`, DATE_FORMAT(transaction_row.`timestamp`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` transaction_row JOIN `sky_phone_migration_owners` owner ON owner.`source` = ? AND owner.`source_phone_number` = transaction_row.`phone_number` ]]):format(table_name), { source_name }) local transactions = {} local skipped = 0 for index = 1, #rows do local row = rows[index] local amount = tonumber(row.amount) or 0 if amount ~= 0 then transactions[#transactions + 1] = { row.owner_identifier, amount > 0 and "deposit" or "withdrawal", math.abs(amount), clamp_text(row.company, 160), "lb-phone-wallet:" .. tostring(row.id), row.migrated_at, } else skipped = skipped + 1 end end if not dry_run then for index = 1, #transactions do local row = transactions[index] Bridge.Database.Query([[ INSERT INTO `sky_phone_bank_transactions` (`owner_identifier`, `kind`, `amount`, `label`, `reference`, `created_at`) SELECT ?, ?, ?, ?, ?, ? WHERE NOT EXISTS ( SELECT 1 FROM `sky_phone_bank_transactions` WHERE `owner_identifier` = ? AND `reference` = ? ) ]], { row[1], row[2], row[3], row[4], row[5], row[6], row[1], row[5] }) end end return { source = #rows, imported = #transactions, skipped = skipped } end local function run_voice_memos(dry_run) local table_name = source_table("voice_memos_recordings") if not table_exists(table_name) then return { source = 0, imported = 0, skipped = 0 } end local rows = Bridge.Database.Query(([[ SELECT memo.*, owner.`account_id`, DATE_FORMAT(memo.`created_at`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` memo JOIN `sky_phone_migration_owners` owner ON owner.`source` = ? AND owner.`source_phone_number` = memo.`phone_number` ]]):format(table_name), { source_name }) local media_rows = {} local valid_rows = {} local skipped = 0 for index = 1, #rows do local row = rows[index] local url = valid_media_url(row.file_url) if url then local key = deterministic_uuid("lb-phone-voice-memo", row.id) local source_id = "lb-memo:" .. key media_rows[#media_rows + 1] = { row.account_id, url, source_id, "audio", "website_import", source_id, row.migrated_at, row.migrated_at, } valid_rows[#valid_rows + 1] = { row = row, key = key, source_id = source_id } else skipped = skipped + 1 end end if not dry_run then insert_many([[ INSERT IGNORE INTO `sky_phone_media` (`account_id`, `url`, `remote_id`, `media_type`, `origin`, `source_id`, `verified_at`, `created_at`) VALUES ]], 8, media_rows) local media_ids = load_media_ids("lb-memo:%") local memo_rows = {} local waveform = json.encode({ 0.24, 0.42, 0.31, 0.56, 0.38, 0.48, 0.27, 0.36 }) for index = 1, #valid_rows do local entry = valid_rows[index] local media_id = media_ids[entry.source_id] if media_id then memo_rows[#memo_rows + 1] = { entry.key, media_id, clamp_text(entry.row.file_name, 120), math.max(0, math.floor((tonumber(entry.row.file_length) or 0) * 1000)), 0, waveform, entry.row.migrated_at, entry.row.migrated_at, } end end insert_many([[ INSERT IGNORE INTO `sky_phone_voice_memos` (`id`, `media_id`, `title`, `duration_ms`, `size_bytes`, `waveform`, `created_at`, `updated_at`) VALUES ]], 8, memo_rows) end return { source = #rows, imported = #valid_rows, skipped = skipped } end local function normalize_picstagram_handle(value) local handle = tostring(value or ""):lower():gsub("^@", ""):match("^%s*(.-)%s*$") if #handle < 3 or #handle > 24 or not handle:match("^[a-z0-9][a-z0-9._]*[a-z0-9]$") or handle:find("..", 1, true) then return nil end return handle end local function extract_media(value, maximum) local decoded = decode_json(value) local result = {} for index = 1, math.min(#decoded, maximum) do local entry = decoded[index] local url local media_type = "photo" if type(entry) == "string" then url = valid_media_url(entry) elseif type(entry) == "table" then url = valid_media_url(entry.url or entry.src or entry.link) local raw_type = tostring(entry.type or entry.mediaType or "photo"):lower() if raw_type:find("video", 1, true) then media_type = "video" end end if url then result[#result + 1] = { url = url, media_type = media_type } end end return result end local function social_login_table_available(table_name) return table_exists(table_name) and table_has_column(table_name, "phone_number") and table_has_column(table_name, "app") and table_has_column(table_name, "username") and table_has_column(table_name, "active") end local function prepare_social_profile_accounts(entries, options) local profile_table = tostring(options.profile_table or "") if not profile_table:match("^[%w_]+$") then error("[sky_phone] Invalid social migration profile table.") end local preferred = {} local logged_table = source_table("logged_in_accounts") if social_login_table_available(logged_table) then local app_placeholders = {} local parameters = { source_name } for index = 1, #options.apps do app_placeholders[index] = "?" parameters[#parameters + 1] = tostring(options.apps[index]):lower() end local logged = Bridge.Database.Query(([[ SELECT LOWER(login.`username`) AS `username`, owner.`account_id` FROM `%s` login JOIN `sky_phone_migration_owners` owner ON owner.`source` = ? AND owner.`source_phone_number` = login.`phone_number` WHERE login.`active` = 1 AND LOWER(login.`app`) IN (%s) ORDER BY owner.`account_id`, login.`phone_number`, login.`username` ]]):format(logged_table, table.concat(app_placeholders, ",")), parameters) for index = 1, #logged do local account_id = tonumber(logged[index].account_id) if account_id then preferred[account_id] = preferred[account_id] or tostring(logged[index].username or ""):lower() end end end for index = 1, #entries do local owner_account_id = tonumber(entries[index].source.owner_account_id) if owner_account_id then preferred[owner_account_id] = preferred[owner_account_id] or tostring(entries[index].source.username or ""):lower() end end local cloud_rows = {} for index = 1, #entries do local entry = entries[index] entry.email = synthetic_email(options.synthetic_namespace, entry.handle) cloud_rows[#cloud_rows + 1] = { entry.email, deterministic_hex(options.account_namespace, entry.handle), } end insert_many("INSERT IGNORE INTO `sky_phone_accounts` (`email`, `password`) VALUES", 2, cloud_rows) local target_accounts = Bridge.Database.Query("SELECT `id`, `email` FROM `sky_phone_accounts`", {}) local account_by_email = {} local email_by_account = {} for index = 1, #target_accounts do local account_id = tonumber(target_accounts[index].id) local email = tostring(target_accounts[index].email or ""):lower() account_by_email[email] = account_id email_by_account[account_id] = email end for index = 1, #entries do entries[index].synthetic_account_id = account_by_email[entries[index].email] end local target_profiles = Bridge.Database.Query(([[ SELECT profile.`id`, profile.`account_id`, profile.`handle`, account.`email` AS `account_email` FROM `%s` profile JOIN `sky_phone_accounts` account ON account.`id` = profile.`account_id` ]]):format(profile_table), {}) local existing_by_handle = {} local occupied_accounts = {} for index = 1, #target_profiles do local profile = target_profiles[index] profile.account_id = tonumber(profile.account_id) profile.account_email = tostring(profile.account_email or ""):lower() existing_by_handle[tostring(profile.handle or ""):lower()] = profile occupied_accounts[profile.account_id] = profile end local entry_by_handle = {} local preferred_entry_by_owner = {} for index = 1, #entries do local entry = entries[index] entry_by_handle[entry.handle] = entry local owner_account_id = tonumber(entry.source.owner_account_id) if owner_account_id and preferred[owner_account_id] == tostring(entry.source.username or ""):lower() then preferred_entry_by_owner[owner_account_id] = entry end end local function is_imported_profile(profile, entry) if not profile or not entry then return false end if entry.profile_id and tostring(profile.id) == tostring(entry.profile_id) then return true end if profile.account_email == entry.email then return true end return tonumber(profile.account_id) == tonumber(entry.source.owner_account_id) end local repaired = 0 local function move_profile(profile, entry, target_account_id) local previous_account_id = tonumber(profile.account_id) target_account_id = tonumber(target_account_id) if not previous_account_id or not target_account_id or previous_account_id == target_account_id then return true end if occupied_accounts[target_account_id] then return false end local moved = affected_rows(Bridge.Database.Query(([[ UPDATE `%s` SET `account_id` = ? WHERE `id` = ? AND `account_id` = ? ]]):format(profile_table), { target_account_id, profile.id, previous_account_id })) if moved < 1 then return false end Bridge.Database.Query([[ UPDATE `sky_phone_media` SET `account_id` = ? WHERE `account_id` = ? AND `origin` = 'website_import' AND `source_id` LIKE ? ]], { target_account_id, previous_account_id, options.media_pattern }) occupied_accounts[previous_account_id] = nil occupied_accounts[target_account_id] = profile profile.account_id = target_account_id profile.account_email = email_by_account[target_account_id] or "" repaired = repaired + 1 return true end -- If the active LB profile changed since a previous import, first move the -- old imported profile back to its deterministic cloud account. for owner_account_id, preferred_entry in pairs(preferred_entry_by_owner) do local occupant = occupied_accounts[owner_account_id] if occupant and tostring(occupant.handle or ""):lower() ~= preferred_entry.handle then local occupant_entry = entry_by_handle[tostring(occupant.handle or ""):lower()] if occupant_entry and tonumber(occupant_entry.source.owner_account_id) == owner_account_id and is_imported_profile(occupant, occupant_entry) then move_profile(occupant, occupant_entry, occupant_entry.synthetic_account_id) end end end local owner_assigned = {} for owner_account_id, preferred_entry in pairs(preferred_entry_by_owner) do local existing = existing_by_handle[preferred_entry.handle] local occupant = occupied_accounts[owner_account_id] if not occupant or occupant == existing then if not existing or tonumber(existing.account_id) == owner_account_id or (is_imported_profile(existing, preferred_entry) and move_profile(existing, preferred_entry, owner_account_id)) then owner_assigned[preferred_entry] = true end end end local accepted = {} local skipped = 0 for index = 1, #entries do local entry = entries[index] local desired_account_id = owner_assigned[entry] and tonumber(entry.source.owner_account_id) or entry.synthetic_account_id local existing = existing_by_handle[entry.handle] if not desired_account_id then skipped = skipped + 1 elseif existing then if tonumber(existing.account_id) == desired_account_id and is_imported_profile(existing, entry) then entry.account_id = desired_account_id accepted[#accepted + 1] = entry else skipped = skipped + 1 end elseif occupied_accounts[desired_account_id] then skipped = skipped + 1 else entry.account_id = desired_account_id accepted[#accepted + 1] = entry occupied_accounts[desired_account_id] = { pending = true, handle = entry.handle } end end return accepted, skipped, repaired end local function run_picstagram(dry_run) local accounts_table = source_table("instagram_accounts") if not table_exists(accounts_table) then return { source = 0, profiles = 0, skipped = 0 } end local source_accounts = Bridge.Database.Query(([[ SELECT account.*, owner.`account_id` AS `owner_account_id`, owner.`device_imei`, DATE_FORMAT(account.`date_joined`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` account JOIN `sky_phone_migration_owners` owner ON owner.`source` = ? AND owner.`source_phone_number` = account.`phone_number` ORDER BY account.`date_joined`, account.`phone_number`, account.`username` ]]):format(accounts_table), { source_name }) local entries = {} local skipped = 0 for index = 1, #source_accounts do local row = source_accounts[index] local handle = normalize_picstagram_handle(row.username) if handle then local profile_id = deterministic_uuid("lb-phone-picstagram-profile", handle) entries[#entries + 1] = { source = row, handle = handle, profile_id = profile_id, } else skipped = skipped + 1 end end if dry_run then return { source = #source_accounts, profiles = #entries, skipped = skipped, posts = count_rows(source_table("instagram_posts")), comments = count_rows(source_table("instagram_comments")), follows = count_rows(source_table("instagram_follows")), stories = count_rows(source_table("instagram_stories")), } end local accepted, assignment_skipped, ownership_repaired = prepare_social_profile_accounts(entries, { profile_table = "sky_phone_picstagram_profiles", synthetic_namespace = "ig", account_namespace = "lb-phone-picstagram-account", media_pattern = "lb-ig-%", apps = { "instagram", "instapic", "picstagram" }, }) entries = accepted skipped = skipped + assignment_skipped local avatar_rows = {} for index = 1, #entries do local entry = entries[index] local avatar = valid_media_url(entry.source.profile_image) if entry.account_id and avatar then entry.avatar_source = "lb-ig-avatar:" .. entry.profile_id avatar_rows[#avatar_rows + 1] = { entry.account_id, avatar, entry.avatar_source, "photo", "website_import", entry.avatar_source, entry.source.migrated_at, entry.source.migrated_at, } end end insert_many([[ INSERT IGNORE INTO `sky_phone_media` (`account_id`, `url`, `remote_id`, `media_type`, `origin`, `source_id`, `verified_at`, `created_at`) VALUES ]], 8, avatar_rows) local avatar_ids = load_media_ids("lb-ig-avatar:%") local profile_rows = {} local profile_by_username = {} for index = 1, #entries do local entry = entries[index] if entry.account_id then entry.avatar_media_id = entry.avatar_source and avatar_ids[entry.avatar_source] or nil profile_rows[#profile_rows + 1] = { entry.profile_id, entry.account_id, entry.handle, clamp_text(entry.source.display_name ~= "" and entry.source.display_name or entry.handle, 40), clamp_text(entry.source.bio, Config.Picstagram.BioMaxLength), entry.avatar_media_id, (entry.source.private == true or tonumber(entry.source.private) == 1) and 1 or 0, (entry.source.verified == true or tonumber(entry.source.verified) == 1) and 1 or 0, entry.source.migrated_at, } profile_by_username[tostring(entry.source.username):lower()] = entry end end insert_many([[ INSERT IGNORE INTO `sky_phone_picstagram_profiles` (`id`, `account_id`, `handle`, `display_name`, `bio`, `avatar_media_id`, `private`, `verified`, `created_at`) VALUES ]], 9, profile_rows) local password_pepper = tostring(Config.Server.PicstagramPasswordPepper or "") for index = 1, #entries do local entry = entries[index] if entry.account_id then local salt = deterministic_hex("lb-phone-picstagram-salt", entry.handle) Bridge.Database.Query([[ INSERT IGNORE INTO `sky_phone_picstagram_credentials` (`profile_id`, `password_hash`, `password_salt`) VALUES (?, UNHEX(SHA2(CONCAT(?, ?, ?), 256)), ?) ]], { entry.profile_id, password_pepper, salt, tostring(entry.source.password or ""), salt }) end end local session_candidates = {} local logged_table = source_table("logged_in_accounts") if social_login_table_available(logged_table) then local logged = Bridge.Database.Query(([[ SELECT login.`phone_number`, login.`username`, owner.`device_imei` FROM `%s` login JOIN `sky_phone_migration_owners` owner ON owner.`source` = ? AND owner.`source_phone_number` = login.`phone_number` WHERE login.`active` = 1 AND LOWER(login.`app`) IN ('instagram', 'instapic', 'picstagram') ]]):format(logged_table), { source_name }) for index = 1, #logged do local profile = profile_by_username[tostring(logged[index].username):lower()] if profile then session_candidates[tostring(logged[index].device_imei)] = profile.profile_id end end end local profiles_by_device = {} for index = 1, #entries do local entry = entries[index] local imei = tostring(entry.source.device_imei) profiles_by_device[imei] = profiles_by_device[imei] or {} profiles_by_device[imei][#profiles_by_device[imei] + 1] = entry.profile_id end for imei, profiles in pairs(profiles_by_device) do if not session_candidates[imei] and #profiles == 1 then session_candidates[imei] = profiles[1] end end local session_rows = {} for imei, profile_id in pairs(session_candidates) do session_rows[#session_rows + 1] = { imei, profile_id } end insert_many([[ INSERT INTO `sky_phone_picstagram_sessions` (`device_imei`, `profile_id`) VALUES ]], 2, session_rows, [[ ON DUPLICATE KEY UPDATE `profile_id` = VALUES(`profile_id`), `updated_at` = CURRENT_TIMESTAMP ]]) local posts_table = source_table("instagram_posts") local post_entries = {} local post_media_rows = {} if table_exists(posts_table) then local posts = Bridge.Database.Query(([[ SELECT post.*, DATE_FORMAT(post.`timestamp`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` post ]]):format(posts_table), {}) for index = 1, #posts do local row = posts[index] local profile = profile_by_username[tostring(row.username):lower()] local media = extract_media(row.media, Config.Picstagram.MaxPostMedia) if profile and #media > 0 then local post_id = deterministic_uuid("lb-phone-picstagram-post", row.id) post_entries[tostring(row.id)] = { id = post_id, profile = profile } for position = 1, #media do local source_id = "lb-ig-post:" .. deterministic_uuid( "lb-phone-picstagram-post-media", tostring(row.id) .. ":" .. position ) media[position].source_id = source_id post_media_rows[#post_media_rows + 1] = { profile.account_id, media[position].url, source_id, media[position].media_type, "website_import", source_id, row.migrated_at, row.migrated_at, } end post_entries[tostring(row.id)].media = media post_entries[tostring(row.id)].row = row end end end insert_many([[ INSERT IGNORE INTO `sky_phone_media` (`account_id`, `url`, `remote_id`, `media_type`, `origin`, `source_id`, `verified_at`, `created_at`) VALUES ]], 8, post_media_rows) local media_ids = load_media_ids("lb-ig-post:%") local post_rows = {} for _, entry in pairs(post_entries) do post_rows[#post_rows + 1] = { entry.id, entry.profile.profile_id, clamp_text(entry.row.caption, Config.Picstagram.CaptionMaxLength), clamp_text(entry.row.location, Config.Picstagram.LocationMaxLength), entry.row.migrated_at, entry.row.migrated_at, } end insert_many([[ INSERT IGNORE INTO `sky_phone_picstagram_posts` (`id`, `profile_id`, `caption`, `location`, `created_at`, `updated_at`) VALUES ]], 6, post_rows) local post_media_links = {} for _, entry in pairs(post_entries) do for position = 1, #entry.media do local media_id = media_ids[entry.media[position].source_id] if media_id then post_media_links[#post_media_links + 1] = { entry.id, media_id, position - 1 } end end end insert_many([[ INSERT IGNORE INTO `sky_phone_picstagram_post_media` (`post_id`, `media_id`, `position`) VALUES ]], 3, post_media_links) local comment_entries = {} local comments_table = source_table("instagram_comments") if table_exists(comments_table) then local rows = Bridge.Database.Query(([[ SELECT comment.*, DATE_FORMAT(comment.`timestamp`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` comment ]]):format(comments_table), {}) for index = 1, #rows do local post = post_entries[tostring(rows[index].post_id)] local profile = profile_by_username[tostring(rows[index].username):lower()] if post and profile then local comment_id = deterministic_uuid("lb-phone-picstagram-comment", rows[index].id) comment_entries[tostring(rows[index].id)] = comment_id Bridge.Database.Query([[ INSERT IGNORE INTO `sky_phone_picstagram_comments` (`id`, `post_id`, `profile_id`, `body`, `created_at`) VALUES (?, ?, ?, ?, ?) ]], { comment_id, post.id, profile.profile_id, clamp_text(rows[index].comment, Config.Picstagram.CommentMaxLength), rows[index].migrated_at, }) end end end local likes_table = source_table("instagram_likes") if table_exists(likes_table) then local likes = Bridge.Database.Query(("SELECT * FROM `%s`"):format(likes_table), {}) local post_likes = {} local comment_likes = {} for index = 1, #likes do local profile = profile_by_username[tostring(likes[index].username):lower()] if profile and (likes[index].is_comment == true or tonumber(likes[index].is_comment) == 1) then local comment_id = comment_entries[tostring(likes[index].id)] if comment_id then comment_likes[#comment_likes + 1] = { comment_id, profile.profile_id } end elseif profile then local post = post_entries[tostring(likes[index].id)] if post then post_likes[#post_likes + 1] = { post.id, profile.profile_id, "like" } end end end insert_many([[ INSERT IGNORE INTO `sky_phone_picstagram_reactions` (`post_id`, `profile_id`, `kind`) VALUES ]], 3, post_likes) insert_many([[ INSERT IGNORE INTO `sky_phone_picstagram_comment_reactions` (`comment_id`, `profile_id`) VALUES ]], 2, comment_likes) end local follow_rows = {} local follows_table = source_table("instagram_follows") if table_exists(follows_table) then local follows = Bridge.Database.Query(("SELECT * FROM `%s`"):format(follows_table), {}) for index = 1, #follows do local follower = profile_by_username[tostring(follows[index].follower):lower()] local following = profile_by_username[tostring(follows[index].followed):lower()] if follower and following and follower.profile_id ~= following.profile_id then follow_rows[#follow_rows + 1] = { follower.profile_id, following.profile_id, "accepted", follower.source.migrated_at, } end end end local requests_table = source_table("instagram_follow_requests") local activity_rows = {} if table_exists(requests_table) then local requests = Bridge.Database.Query(([[ SELECT request.*, DATE_FORMAT(request.`timestamp`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` request ]]):format(requests_table), {}) for index = 1, #requests do local requester = profile_by_username[tostring(requests[index].requester):lower()] local requestee = profile_by_username[tostring(requests[index].requestee):lower()] if requester and requestee and requester.profile_id ~= requestee.profile_id then follow_rows[#follow_rows + 1] = { requester.profile_id, requestee.profile_id, "pending", requests[index].migrated_at, } activity_rows[#activity_rows + 1] = { deterministic_uuid( "lb-phone-picstagram-follow-request", tostring(requests[index].requester) .. ":" .. tostring(requests[index].requestee) ), requestee.profile_id, requester.profile_id, "follow_request", requests[index].migrated_at, } end end end insert_many([[ INSERT IGNORE INTO `sky_phone_picstagram_follows` (`follower_id`, `following_id`, `status`, `created_at`) VALUES ]], 4, follow_rows) insert_many([[ INSERT IGNORE INTO `sky_phone_picstagram_activities` (`id`, `recipient_id`, `actor_id`, `kind`, `created_at`) VALUES ]], 5, activity_rows) local story_entries = {} local story_media_rows = {} local stories_table = source_table("instagram_stories") if table_exists(stories_table) then local stories = Bridge.Database.Query(([[ SELECT story.*, DATE_FORMAT(story.`timestamp`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` story ]]):format(stories_table), {}) for index = 1, #stories do local profile = profile_by_username[tostring(stories[index].username):lower()] local url = valid_media_url(stories[index].image) if profile and url then local story_id = deterministic_uuid("lb-phone-picstagram-story", stories[index].id) local source_id = "lb-ig-story:" .. story_id story_entries[tostring(stories[index].id)] = { id = story_id, profile = profile, source_id = source_id, timestamp = stories[index].migrated_at, } story_media_rows[#story_media_rows + 1] = { profile.account_id, url, source_id, "photo", "website_import", source_id, stories[index].migrated_at, stories[index].migrated_at, } end end end insert_many([[ INSERT IGNORE INTO `sky_phone_media` (`account_id`, `url`, `remote_id`, `media_type`, `origin`, `source_id`, `verified_at`, `created_at`) VALUES ]], 8, story_media_rows) local story_media_ids = load_media_ids("lb-ig-story:%") for _, story in pairs(story_entries) do local media_id = story_media_ids[story.source_id] if media_id then Bridge.Database.Query([[ INSERT IGNORE INTO `sky_phone_picstagram_stories` (`id`, `profile_id`, `media_id`, `expires_at`, `created_at`) VALUES (?, ?, ?, DATE_ADD(?, INTERVAL ? SECOND), ?) ]], { story.id, story.profile.profile_id, media_id, story.timestamp, Config.Picstagram.StoryLifetimeSeconds, story.timestamp, }) end end local views_table = source_table("instagram_stories_views") local view_rows = {} if table_exists(views_table) then local views = Bridge.Database.Query(([[ SELECT story_view.*, DATE_FORMAT(story_view.`timestamp`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` story_view ]]):format(views_table), {}) for index = 1, #views do local story = story_entries[tostring(views[index].story_id)] local viewer = profile_by_username[tostring(views[index].viewer):lower()] if story and viewer then view_rows[#view_rows + 1] = { story.id, viewer.profile_id, views[index].migrated_at } end end end insert_many([[ INSERT IGNORE INTO `sky_phone_picstagram_story_views` (`story_id`, `profile_id`, `created_at`) VALUES ]], 3, view_rows) return { source = #source_accounts, profiles = #profile_rows, sessions = #session_rows, posts = #post_rows, post_media = #post_media_links, comments = count_rows(comments_table), follows = #follow_rows, stories = count_rows(stories_table), story_views = #view_rows, ownership_repaired = ownership_repaired, skipped = skipped, unsupported_messages = count_rows(source_table("instagram_messages")), unsupported_notifications = count_rows(source_table("instagram_notifications")), } end local function valid_legacy_email(value) if type(value) ~= "string" then return nil end local address = value:lower():match("^%s*(.-)%s*$") if #address < 3 or #address > 64 or address:find("[^%w%._%+%-@]") then return nil end if not address:match("^[^@]+@[^@]+%.[^@]+$") then return nil end return address end local function normalize_social_handle(value, maximum, allow_dot) local handle = tostring(value or ""):lower():gsub("^@", ""):match("^%s*(.-)%s*$") local pattern = allow_dot and "^[a-z0-9][a-z0-9._]*[a-z0-9]$" or "^[a-z0-9][a-z0-9_]*[a-z0-9]$" if #handle < 3 or #handle > maximum or not handle:match(pattern) then return nil end if allow_dot and handle:find("..", 1, true) then return nil end return handle end local function run_mail(dry_run) local accounts_table = source_table("mail_accounts") local messages_table = source_table("mail_messages") local logged_table = source_table("logged_in_accounts") if not table_exists(accounts_table) or not table_exists(messages_table) or not table_exists(logged_table) then return { source = 0, mailboxes = 0, messages = 0, entries = 0, skipped = 0 } end if not table_has_column(accounts_table, "address") or not table_has_column(messages_table, "recipient") or not table_has_column(messages_table, "sender") then return { source = count_rows(messages_table), mailboxes = 0, messages = 0, entries = 0, skipped = count_rows(messages_table), unsupported_schema = accounts_table, } end local links = Bridge.Database.Query(([[ SELECT login.`username` AS `address`, owner.`account_id`, owner.`device_imei` FROM `%s` login JOIN `%s` mailbox ON LOWER(mailbox.`address`) = LOWER(login.`username`) JOIN `sky_phone_migration_owners` owner ON owner.`source` = ? AND owner.`source_phone_number` = login.`phone_number` WHERE LOWER(login.`app`) IN ('mail', 'email') ]]):format(logged_table, accounts_table), { source_name }) local mailbox_accounts = {} local linked_accounts = {} for index = 1, #links do local address = tostring(links[index].address or ""):lower() local account_id = tonumber(links[index].account_id) if address ~= "" and account_id then mailbox_accounts[address] = mailbox_accounts[address] or {} if not mailbox_accounts[address][account_id] then mailbox_accounts[address][account_id] = true linked_accounts[#linked_accounts + 1] = { address = address, account_id = account_id } end end end local source_messages = Bridge.Database.Query(([[ SELECT message.*, DATE_FORMAT(message.`timestamp`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` message ]]):format(messages_table), {}) local relevant = {} local sender_addresses = {} local skipped = 0 local unsupported_payloads = 0 for index = 1, #source_messages do local row = source_messages[index] local sender = tostring(row.sender or ""):lower() local recipient = tostring(row.recipient or ""):lower() if mailbox_accounts[sender] or mailbox_accounts[recipient] then row.sender_address = sender row.recipient_address = recipient relevant[#relevant + 1] = row sender_addresses[sender] = true if next(decode_json(row.attachments)) or next(decode_json(row.actions)) then unsupported_payloads = unsupported_payloads + 1 end else skipped = skipped + 1 end end if dry_run then return { source = #source_messages, mailboxes = #linked_accounts, messages = #relevant, entries = 0, skipped = skipped, unsupported_payloads = unsupported_payloads, } end local account_rows = {} local source_email = {} for address in pairs(sender_addresses) do local email = valid_legacy_email(address) or synthetic_email("mail", address) source_email[address] = email account_rows[#account_rows + 1] = { email, deterministic_hex("lb-phone-mail-account", address), } end insert_many("INSERT IGNORE INTO `sky_phone_accounts` (`email`, `password`) VALUES", 2, account_rows) local target_accounts = Bridge.Database.Query("SELECT `id`, `email` FROM `sky_phone_accounts`", {}) local account_by_email = {} for index = 1, #target_accounts do account_by_email[tostring(target_accounts[index].email):lower()] = tonumber(target_accounts[index].id) end local deleted = {} local deleted_table = source_table("mail_deleted") if table_exists(deleted_table) then local deleted_rows = Bridge.Database.Query(("SELECT * FROM `%s`"):format(deleted_table), {}) for index = 1, #deleted_rows do local key = tostring(deleted_rows[index].message_id) .. ":" .. tostring(deleted_rows[index].address):lower() deleted[key] = true end end local message_rows = {} local entry_rows = {} for index = 1, #relevant do local row = relevant[index] local sender_account_id = account_by_email[source_email[row.sender_address]] if sender_account_id then local message_id = deterministic_uuid("lb-phone-mail-message", row.id) message_rows[#message_rows + 1] = { message_id, sender_account_id, json.encode({ row.recipient_address }), clamp_text(row.subject, 120), tostring(row.content or ""), row.migrated_at, } local sent_accounts = mailbox_accounts[row.sender_address] or {} for account_id in pairs(sent_accounts) do local trash_key = tostring(row.id) .. ":" .. row.sender_address entry_rows[#entry_rows + 1] = { message_id, account_id, "sent", nil, nil, deleted[trash_key] and row.migrated_at or nil, } end local recipient_accounts = mailbox_accounts[row.recipient_address] or {} for account_id in pairs(recipient_accounts) do local trash_key = tostring(row.id) .. ":" .. row.recipient_address entry_rows[#entry_rows + 1] = { message_id, account_id, "inbox", nil, (row.read == true or tonumber(row.read) == 1) and row.migrated_at or nil, deleted[trash_key] and row.migrated_at or nil, } end end end insert_many([[ INSERT IGNORE INTO `sky_phone_mail_messages` (`id`, `sender_account_id`, `recipients`, `subject`, `body`, `created_at`) VALUES ]], 6, message_rows) insert_many([[ INSERT IGNORE INTO `sky_phone_mail_entries` (`message_id`, `account_id`, `folder`, `mailbox_id`, `read_at`, `trashed_at`) VALUES ]], 6, entry_rows) return { source = #source_messages, mailboxes = #linked_accounts, messages = #message_rows, entries = #entry_rows, skipped = skipped, unsupported_payloads = unsupported_payloads, } end local function run_map_markers(dry_run) local table_name = source_table("maps_locations") if not table_exists(table_name) then return { source = 0, imported = 0, skipped = 0 } end local rows = Bridge.Database.Query(([[ SELECT location.*, owner.`device_imei` FROM `%s` location JOIN `sky_phone_migration_owners` owner ON owner.`source` = ? AND owner.`source_phone_number` = location.`phone_number` ]]):format(table_name), { source_name }) local valid = {} local skipped = 0 for index = 1, #rows do local x = tonumber(rows[index].x_pos) local y = tonumber(rows[index].y_pos) local label = clamp_text(rows[index].name, 40):match("^%s*(.-)%s*$") if x and y and label ~= "" and math.abs(x) <= 10000 and math.abs(y) <= 10000 then valid[#valid + 1] = { deterministic_uuid("lb-phone-map-marker", rows[index].id), rows[index].device_imei, label, "blue", x, y, 0, } else skipped = skipped + 1 end end if not dry_run then insert_many([[ INSERT IGNORE INTO `sky_phone_map_markers` (`id`, `device_imei`, `label`, `color`, `position_x`, `position_y`, `position_z`) VALUES ]], 7, valid) end return { source = #rows, imported = #valid, skipped = skipped } end local function run_dark_chat(dry_run) local accounts_table = source_table("darkchat_accounts") local channels_table = source_table("darkchat_channels") local members_table = source_table("darkchat_members") local messages_table = source_table("darkchat_messages") if not table_exists(accounts_table) or not table_exists(channels_table) or not table_exists(members_table) or not table_exists(messages_table) then return { source = 0, profiles = 0, conversations = 0, messages = 0, skipped = 0 } end local source_accounts = Bridge.Database.Query(([[ SELECT account.*, owner.`account_id` AS `owner_account_id`, owner.`device_imei` FROM `%s` account JOIN `sky_phone_migration_owners` owner ON owner.`source` = ? AND owner.`source_phone_number` = account.`phone_number` ORDER BY account.`phone_number`, account.`username` ]]):format(accounts_table), { source_name }) local preferred = {} local logged_table = source_table("logged_in_accounts") if table_exists(logged_table) then local logged = Bridge.Database.Query(([[ SELECT login.`username`, owner.`account_id` FROM `%s` login JOIN `sky_phone_migration_owners` owner ON owner.`source` = ? AND owner.`source_phone_number` = login.`phone_number` WHERE login.`active` = 1 AND LOWER(login.`app`) IN ('darkchat', 'dark_chat') ]]):format(logged_table), { source_name }) for index = 1, #logged do local account_id = tonumber(logged[index].account_id) if account_id then preferred[account_id] = tostring(logged[index].username):lower() end end end for index = 1, #source_accounts do local account_id = tonumber(source_accounts[index].owner_account_id) if account_id then preferred[account_id] = preferred[account_id] or tostring(source_accounts[index].username):lower() end end if dry_run then return { source = #source_accounts, profiles = #source_accounts, conversations = count_rows(channels_table), messages = count_rows(messages_table), skipped = 0, } end local existing_owner_profiles = Bridge.Database.Query( "SELECT `account_id`, `dark_id` FROM `sky_phone_darkchat_profiles`", {} ) local occupied_accounts = {} local existing_by_dark_id = {} for index = 1, #existing_owner_profiles do occupied_accounts[tonumber(existing_owner_profiles[index].account_id)] = true existing_by_dark_id[tostring(existing_owner_profiles[index].dark_id)] = existing_owner_profiles[index] end local cloud_accounts = {} for index = 1, #source_accounts do local row = source_accounts[index] local owner_account_id = tonumber(row.owner_account_id) local entropy = deterministic_hex("lb-phone-darkchat-profile", row.username):upper() row.dark_id = ("dark:%s-%s"):format(entropy:sub(1, 4), entropy:sub(5, 8)) row.profile_entropy = entropy local existing = existing_by_dark_id[row.dark_id] local use_owner = owner_account_id and preferred[owner_account_id] == tostring(row.username):lower() and not occupied_accounts[owner_account_id] if existing then row.target_account_id = tonumber(existing.account_id) elseif use_owner then row.target_account_id = owner_account_id occupied_accounts[owner_account_id] = true else row.target_email = synthetic_email("dark", row.username) cloud_accounts[#cloud_accounts + 1] = { row.target_email, deterministic_hex("lb-phone-darkchat-account", row.username), } end end insert_many("INSERT IGNORE INTO `sky_phone_accounts` (`email`, `password`) VALUES", 2, cloud_accounts) local all_accounts = Bridge.Database.Query("SELECT `id`, `email` FROM `sky_phone_accounts`", {}) local account_by_email = {} for index = 1, #all_accounts do account_by_email[tostring(all_accounts[index].email):lower()] = tonumber(all_accounts[index].id) end local profile_rows = {} for index = 1, #source_accounts do local row = source_accounts[index] row.target_account_id = row.target_account_id or account_by_email[row.target_email] if row.target_account_id then local entropy = row.profile_entropy row.invite_code = ("DC-%s-%s"):format(entropy:sub(9, 12), entropy:sub(13, 16)) profile_rows[#profile_rows + 1] = { row.target_account_id, row.dark_id, row.invite_code, clamp_text(row.username, 32), tonumber(entropy:sub(17, 23), 16) or 1, } end end insert_many([[ INSERT IGNORE INTO `sky_phone_darkchat_profiles` (`account_id`, `dark_id`, `invite_code`, `alias`, `avatar_seed`) VALUES ]], 5, profile_rows) local target_profiles = Bridge.Database.Query( "SELECT `id`, `account_id`, `dark_id` FROM `sky_phone_darkchat_profiles`", {} ) local profile_by_account = {} for index = 1, #target_profiles do profile_by_account[tonumber(target_profiles[index].account_id)] = tonumber(target_profiles[index].id) end local profile_by_username = {} for index = 1, #source_accounts do local row = source_accounts[index] local profile_id = profile_by_account[row.target_account_id] if profile_id then profile_by_username[tostring(row.username):lower()] = profile_id end end local channels = Bridge.Database.Query(([[ SELECT channel.`name`, channel.`password`, COUNT(DISTINCT member.`username`) AS `member_count`, COALESCE( DATE_FORMAT(MIN(message.`timestamp`), '%%Y-%%m-%%d %%H:%%i:%%s'), DATE_FORMAT(CURRENT_TIMESTAMP, '%%Y-%%m-%%d %%H:%%i:%%s') ) AS `first_at`, COALESCE( DATE_FORMAT(MAX(message.`timestamp`), '%%Y-%%m-%%d %%H:%%i:%%s'), DATE_FORMAT(CURRENT_TIMESTAMP, '%%Y-%%m-%%d %%H:%%i:%%s') ) AS `last_at` FROM `%s` channel LEFT JOIN `%s` member ON member.`channel_name` = channel.`name` LEFT JOIN `%s` message ON message.`channel` = channel.`name` GROUP BY channel.`name`, channel.`password` ]]):format(channels_table, members_table, messages_table), {}) local member_rows = Bridge.Database.Query(("SELECT * FROM `%s`"):format(members_table), {}) local members_by_channel = {} for index = 1, #member_rows do local name = tostring(member_rows[index].channel_name) members_by_channel[name] = members_by_channel[name] or {} members_by_channel[name][#members_by_channel[name] + 1] = tostring(member_rows[index].username) end local conversation_by_channel = {} local conversation_rows = {} local target_member_rows = {} local contact_rows = {} local unsupported_groups = 0 local private_channels = 0 for index = 1, #channels do local channel = channels[index] local usernames = members_by_channel[tostring(channel.name)] or {} local first_profile local second_profile if tonumber(channel.member_count) == 2 and #usernames == 2 then first_profile = profile_by_username[usernames[1]:lower()] second_profile = profile_by_username[usernames[2]:lower()] end if first_profile and second_profile and first_profile ~= second_profile then local conversation_id = deterministic_uuid("lb-phone-darkchat-conversation", channel.name) conversation_by_channel[tostring(channel.name)] = conversation_id local first_at = channel.first_at or channel.last_at local last_at = channel.last_at or first_at conversation_rows[#conversation_rows + 1] = { conversation_id, 0, first_at, last_at, } target_member_rows[#target_member_rows + 1] = { conversation_id, first_profile, first_at } target_member_rows[#target_member_rows + 1] = { conversation_id, second_profile, first_at } contact_rows[#contact_rows + 1] = { first_profile, second_profile } contact_rows[#contact_rows + 1] = { second_profile, first_profile } if channel.password and tostring(channel.password) ~= "" then private_channels = private_channels + 1 end else unsupported_groups = unsupported_groups + 1 end end insert_many([[ INSERT IGNORE INTO `sky_phone_darkchat_conversations` (`id`, `disappearing_seconds`, `created_at`, `updated_at`) VALUES ]], 4, conversation_rows) insert_many([[ INSERT IGNORE INTO `sky_phone_darkchat_members` (`conversation_id`, `profile_id`, `joined_at`) VALUES ]], 3, target_member_rows) insert_many([[ INSERT IGNORE INTO `sky_phone_darkchat_contacts` (`profile_id`, `contact_profile_id`) VALUES ]], 2, contact_rows) local messages = Bridge.Database.Query(([[ SELECT message.*, DATE_FORMAT(message.`timestamp`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` message ]]):format(messages_table), {}) local message_rows = {} local skipped_messages = 0 for index = 1, #messages do local conversation_id = conversation_by_channel[tostring(messages[index].channel)] local profile_id = profile_by_username[tostring(messages[index].sender):lower()] if conversation_id and profile_id then message_rows[#message_rows + 1] = { deterministic_uuid("lb-phone-darkchat-message", messages[index].id), conversation_id, profile_id, "text", tostring(messages[index].content or ""), messages[index].migrated_at, } else skipped_messages = skipped_messages + 1 end end insert_many([[ INSERT IGNORE INTO `sky_phone_darkchat_messages` (`id`, `conversation_id`, `sender_profile_id`, `message_type`, `body`, `created_at`) VALUES ]], 6, message_rows) return { source = #source_accounts, profiles = #profile_rows, conversations = #conversation_rows, messages = #message_rows, skipped = skipped_messages, unsupported_groups = unsupported_groups, unsupported_private_passwords = private_channels, } end local function run_flip_tok(dry_run) local accounts_table = source_table("tiktok_accounts") if not table_exists(accounts_table) then return { source = 0, profiles = 0, videos = 0, comments = 0, skipped = 0 } end local source_accounts = Bridge.Database.Query(([[ SELECT account.*, owner.`device_imei`, owner.`account_id` AS `owner_account_id`, DATE_FORMAT(account.`date_joined`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` account JOIN `sky_phone_migration_owners` owner ON owner.`source` = ? AND owner.`source_phone_number` = account.`phone_number` ORDER BY account.`date_joined`, account.`phone_number`, account.`username` ]]):format(accounts_table), { source_name }) local entries = {} local skipped = 0 for index = 1, #source_accounts do local row = source_accounts[index] local handle = normalize_social_handle(row.username, 24, true) if handle then entries[#entries + 1] = { source = row, handle = handle, } else skipped = skipped + 1 end end if dry_run then return { source = #source_accounts, profiles = #entries, videos = count_rows(source_table("tiktok_videos")), comments = count_rows(source_table("tiktok_comments")), follows = count_rows(source_table("tiktok_follows")), skipped = skipped, } end local accepted, assignment_skipped, ownership_repaired = prepare_social_profile_accounts(entries, { profile_table = "sky_phone_fliptok_profiles", synthetic_namespace = "flip", account_namespace = "lb-phone-fliptok-account", media_pattern = "lb-flip-%", apps = { "tiktok", "fliptok" }, }) skipped = skipped + assignment_skipped local avatar_rows = {} for index = 1, #accepted do local entry = accepted[index] local avatar = valid_media_url(entry.source.avatar) if avatar then entry.avatar_source = "lb-flip-avatar:" .. deterministic_uuid( "lb-phone-fliptok-avatar", entry.handle ) avatar_rows[#avatar_rows + 1] = { entry.account_id, avatar, entry.avatar_source, "photo", "website_import", entry.avatar_source, entry.source.migrated_at, entry.source.migrated_at, } end end insert_many([[ INSERT IGNORE INTO `sky_phone_media` (`account_id`, `url`, `remote_id`, `media_type`, `origin`, `source_id`, `verified_at`, `created_at`) VALUES ]], 8, avatar_rows) local avatar_ids = load_media_ids("lb-flip-avatar:%") local profile_rows = {} for index = 1, #accepted do local entry = accepted[index] profile_rows[#profile_rows + 1] = { entry.account_id, entry.handle, clamp_text(entry.source.name ~= "" and entry.source.name or entry.handle, 40), clamp_text(entry.source.bio, Config.FlipTok.BioMaxLength), entry.avatar_source and avatar_ids[entry.avatar_source] or nil, (entry.source.verified == true or tonumber(entry.source.verified) == 1) and 1 or 0, entry.source.migrated_at, } end insert_many([[ INSERT IGNORE INTO `sky_phone_fliptok_profiles` (`account_id`, `handle`, `display_name`, `bio`, `avatar_media_id`, `verified`, `created_at`) VALUES ]], 7, profile_rows) local target_profiles = Bridge.Database.Query( "SELECT `id`, `account_id`, `handle` FROM `sky_phone_fliptok_profiles`", {} ) local profile_by_handle = {} for index = 1, #target_profiles do profile_by_handle[tostring(target_profiles[index].handle):lower()] = tonumber(target_profiles[index].id) end local profile_by_username = {} local policy_passwords = 0 local password_pepper = tostring(Config.Server.FlipTokPasswordPepper or "") for index = 1, #accepted do local entry = accepted[index] entry.profile_id = profile_by_handle[entry.handle] if entry.profile_id then profile_by_username[tostring(entry.source.username):lower()] = entry local password = tostring(entry.source.password or "") if #password < Config.FlipTok.PasswordMinLength or #password > Config.FlipTok.PasswordMaxLength then policy_passwords = policy_passwords + 1 end local salt = deterministic_hex("lb-phone-fliptok-salt", entry.handle) Bridge.Database.Query([[ INSERT IGNORE INTO `sky_phone_fliptok_credentials` (`profile_id`, `password_hash`, `password_salt`) VALUES (?, UNHEX(SHA2(CONCAT(?, ?, ?), 256)), ?) ]], { entry.profile_id, password_pepper, salt, password, salt }) end end local session_candidates = {} local logged_table = source_table("logged_in_accounts") if social_login_table_available(logged_table) then local logged = Bridge.Database.Query(([[ SELECT login.`username`, owner.`device_imei` FROM `%s` login JOIN `sky_phone_migration_owners` owner ON owner.`source` = ? AND owner.`source_phone_number` = login.`phone_number` WHERE login.`active` = 1 AND LOWER(login.`app`) IN ('tiktok', 'fliptok') ]]):format(logged_table), { source_name }) for index = 1, #logged do local profile = profile_by_username[tostring(logged[index].username):lower()] if profile then session_candidates[tostring(logged[index].device_imei)] = profile.profile_id end end end local profiles_by_device = {} for index = 1, #accepted do local entry = accepted[index] local imei = tostring(entry.source.device_imei) profiles_by_device[imei] = profiles_by_device[imei] or {} profiles_by_device[imei][#profiles_by_device[imei] + 1] = entry.profile_id end for imei, profiles in pairs(profiles_by_device) do if not session_candidates[imei] and #profiles == 1 then session_candidates[imei] = profiles[1] end end local session_rows = {} for imei, profile_id in pairs(session_candidates) do if profile_id then session_rows[#session_rows + 1] = { imei, profile_id } end end insert_many([[ INSERT INTO `sky_phone_fliptok_sessions` (`device_imei`, `profile_id`) VALUES ]], 2, session_rows, [[ ON DUPLICATE KEY UPDATE `profile_id` = VALUES(`profile_id`), `updated_at` = CURRENT_TIMESTAMP ]]) local videos_table = source_table("tiktok_videos") local video_entries = {} local video_media_rows = {} if table_exists(videos_table) then local videos = Bridge.Database.Query(([[ SELECT video.*, DATE_FORMAT(video.`timestamp`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` video ]]):format(videos_table), {}) for index = 1, #videos do local row = videos[index] local profile = profile_by_username[tostring(row.username):lower()] local url = valid_media_url(row.src) if profile and url then local video_id = deterministic_uuid("lb-phone-fliptok-video", row.id) local source_id = "lb-flip-video:" .. video_id video_entries[tostring(row.id)] = { id = video_id, row = row, profile = profile, source_id = source_id, } video_media_rows[#video_media_rows + 1] = { profile.account_id, url, source_id, "video", "website_import", source_id, row.migrated_at, row.migrated_at, } end end end insert_many([[ INSERT IGNORE INTO `sky_phone_media` (`account_id`, `url`, `remote_id`, `media_type`, `origin`, `source_id`, `verified_at`, `created_at`) VALUES ]], 8, video_media_rows) local video_media_ids = load_media_ids("lb-flip-video:%") local video_rows = {} local video_links = {} for _, entry in pairs(video_entries) do local media_id = video_media_ids[entry.source_id] if media_id then entry.media_id = media_id video_rows[#video_rows + 1] = { entry.id, entry.profile.profile_id, media_id, clamp_text(entry.row.caption, Config.FlipTok.CaptionMaxLength), clamp_text(entry.row.music, 160), math.max(0, math.floor(tonumber(entry.row.views) or 0)), entry.row.migrated_at, entry.row.migrated_at, } video_links[#video_links + 1] = { entry.id, media_id, 0 } end end insert_many([[ INSERT IGNORE INTO `sky_phone_fliptok_videos` (`id`, `profile_id`, `media_id`, `caption`, `custom_music_title`, `view_count`, `created_at`, `updated_at`) VALUES ]], 8, video_rows) insert_many([[ INSERT IGNORE INTO `sky_phone_fliptok_video_media` (`video_id`, `media_id`, `sort_order`) VALUES ]], 3, video_links) local follows = {} local follows_table = source_table("tiktok_follows") if table_exists(follows_table) then local rows = Bridge.Database.Query(("SELECT * FROM `%s`"):format(follows_table), {}) for index = 1, #rows do local follower = profile_by_username[tostring(rows[index].follower):lower()] local following = profile_by_username[tostring(rows[index].followed):lower()] if follower and following and follower.profile_id ~= following.profile_id then follows[#follows + 1] = { follower.profile_id, following.profile_id } end end end insert_many([[ INSERT IGNORE INTO `sky_phone_fliptok_follows` (`follower_id`, `following_id`) VALUES ]], 2, follows) local reactions = {} for _, source_suffix in ipairs({ "tiktok_likes", "tiktok_saves" }) do local table_name = source_table(source_suffix) if table_exists(table_name) then local rows = Bridge.Database.Query(("SELECT * FROM `%s`"):format(table_name), {}) local kind = source_suffix == "tiktok_likes" and "like" or "save" for index = 1, #rows do local profile = profile_by_username[tostring(rows[index].username):lower()] local video = video_entries[tostring(rows[index].video_id)] if profile and video and video.media_id then reactions[#reactions + 1] = { video.id, profile.profile_id, kind } end end end end insert_many([[ INSERT IGNORE INTO `sky_phone_fliptok_reactions` (`video_id`, `profile_id`, `kind`) VALUES ]], 3, reactions) local comments_table = source_table("tiktok_comments") local comment_entries = {} local comments = {} if table_exists(comments_table) then comments = Bridge.Database.Query(([[ SELECT comment.*, DATE_FORMAT(comment.`timestamp`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` comment ]]):format(comments_table), {}) local comment_rows = {} for index = 1, #comments do local row = comments[index] local profile = profile_by_username[tostring(row.username):lower()] local video = video_entries[tostring(row.video_id)] if profile and video and video.media_id then local comment_id = deterministic_uuid("lb-phone-fliptok-comment", row.id) comment_entries[tostring(row.id)] = comment_id comment_rows[#comment_rows + 1] = { comment_id, video.id, profile.profile_id, clamp_text(row.comment, Config.FlipTok.CommentMaxLength), row.migrated_at, } end end insert_many([[ INSERT IGNORE INTO `sky_phone_fliptok_comments` (`id`, `video_id`, `profile_id`, `body`, `created_at`) VALUES ]], 5, comment_rows) for index = 1, #comments do local comment_id = comment_entries[tostring(comments[index].id)] local parent_id = comment_entries[tostring(comments[index].reply_to)] if comment_id and parent_id and comment_id ~= parent_id then Bridge.Database.Query([[ UPDATE `sky_phone_fliptok_comments` SET `parent_id` = ? WHERE `id` = ? AND `parent_id` IS NULL ]], { parent_id, comment_id }) end end end local comment_reactions = {} local comment_likes_table = source_table("tiktok_comments_likes") if table_exists(comment_likes_table) then local rows = Bridge.Database.Query(("SELECT * FROM `%s`"):format(comment_likes_table), {}) for index = 1, #rows do local profile = profile_by_username[tostring(rows[index].username):lower()] local comment_id = comment_entries[tostring(rows[index].comment_id)] if profile and comment_id then comment_reactions[#comment_reactions + 1] = { comment_id, profile.profile_id } end end end insert_many([[ INSERT IGNORE INTO `sky_phone_fliptok_comment_reactions` (`comment_id`, `profile_id`) VALUES ]], 2, comment_reactions) local notification_rows = {} local notifications_table = source_table("tiktok_notifications") if table_exists(notifications_table) then local rows = Bridge.Database.Query(([[ SELECT notification.*, DATE_FORMAT(notification.`timestamp`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` notification ]]):format(notifications_table), {}) for index = 1, #rows do local recipient = profile_by_username[tostring(rows[index].username):lower()] local actor = profile_by_username[tostring(rows[index].from):lower()] local video = video_entries[tostring(rows[index].video_id)] local source_kind = tostring(rows[index].type):lower() local kind = source_kind == "follow" and "follow" or source_kind == "like" and "like" or (source_kind == "comment" or source_kind == "reply" or source_kind == "like_comment") and "comment" or nil if recipient and actor and kind and recipient.profile_id ~= actor.profile_id and (kind == "follow" or (video and video.media_id)) then notification_rows[#notification_rows + 1] = { deterministic_uuid("lb-phone-fliptok-notification", rows[index].id), recipient.profile_id, actor.profile_id, video and video.id or nil, kind, rows[index].migrated_at, } end end end insert_many([[ INSERT IGNORE INTO `sky_phone_fliptok_notifications` (`id`, `recipient_id`, `actor_id`, `video_id`, `kind`, `created_at`) VALUES ]], 6, notification_rows) return { source = #source_accounts, profiles = #accepted, sessions = #session_rows, videos = #video_rows, comments = #comments, follows = #follows, reactions = #reactions, notifications = #notification_rows, ownership_repaired = ownership_repaired, skipped = skipped, password_policy_mismatches = policy_passwords, unsupported_messages = count_rows(source_table("tiktok_messages")), unsupported_pins = count_rows(source_table("tiktok_pinned_videos")), } end local function run_feather(dry_run) local accounts_table = source_table("twitter_accounts") if not table_exists(accounts_table) then return { source = 0, profiles = 0, posts = 0, skipped = 0 } end local source_accounts = Bridge.Database.Query(([[ SELECT account.*, owner.`device_imei`, owner.`account_id` AS `owner_account_id`, DATE_FORMAT(account.`date_joined`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` account JOIN `sky_phone_migration_owners` owner ON owner.`source` = ? AND owner.`source_phone_number` = account.`phone_number` ORDER BY account.`date_joined`, account.`phone_number`, account.`username` ]]):format(accounts_table), { source_name }) local entries = {} local skipped = 0 for index = 1, #source_accounts do local row = source_accounts[index] local handle = normalize_social_handle(row.username, Config.Feather.HandleMaxLength, false) if handle then local entry = { source = row, handle = handle } entries[#entries + 1] = entry else skipped = skipped + 1 end end if dry_run then return { source = #source_accounts, profiles = #entries, posts = count_rows(source_table("twitter_tweets")), replies = count_rows(source_table("twitter_tweets")), likes = count_rows(source_table("twitter_likes")), retweets = count_rows(source_table("twitter_retweets")), follows = count_rows(source_table("twitter_follows")), skipped = skipped, } end local accepted, assignment_skipped, ownership_repaired = prepare_social_profile_accounts(entries, { profile_table = "sky_phone_feather_profiles", synthetic_namespace = "feather", account_namespace = "lb-phone-feather-account", media_pattern = "lb-feather-%", apps = { "twitter", "feather" }, }) skipped = skipped + assignment_skipped local avatar_rows = {} for index = 1, #accepted do local entry = accepted[index] local avatar = valid_media_url(entry.source.profile_image) if avatar then entry.avatar_source = "lb-feather-avatar:" .. deterministic_uuid( "lb-phone-feather-avatar", entry.handle ) avatar_rows[#avatar_rows + 1] = { entry.account_id, avatar, entry.avatar_source, "photo", "website_import", entry.avatar_source, entry.source.migrated_at, entry.source.migrated_at, } end end insert_many([[ INSERT IGNORE INTO `sky_phone_media` (`account_id`, `url`, `remote_id`, `media_type`, `origin`, `source_id`, `verified_at`, `created_at`) VALUES ]], 8, avatar_rows) local avatar_ids = load_media_ids("lb-feather-avatar:%") local profile_rows = {} for index = 1, #accepted do local entry = accepted[index] profile_rows[#profile_rows + 1] = { entry.account_id, entry.handle, clamp_text(entry.source.display_name ~= "" and entry.source.display_name or entry.handle, Config.Feather.DisplayNameMaxLength), clamp_text(entry.source.bio, Config.Feather.BioMaxLength), entry.avatar_source and avatar_ids[entry.avatar_source] or nil, (entry.source.verified == true or tonumber(entry.source.verified) == 1) and 1 or 0, entry.source.migrated_at, } end insert_many([[ INSERT IGNORE INTO `sky_phone_feather_profiles` (`account_id`, `handle`, `display_name`, `bio`, `avatar_media_id`, `verified`, `created_at`) VALUES ]], 7, profile_rows) local target_profiles = Bridge.Database.Query( "SELECT `id`, `account_id`, `handle` FROM `sky_phone_feather_profiles`", {} ) local profile_by_handle = {} for index = 1, #target_profiles do profile_by_handle[tostring(target_profiles[index].handle):lower()] = tonumber(target_profiles[index].id) end local profile_by_username = {} for index = 1, #accepted do accepted[index].profile_id = profile_by_handle[accepted[index].handle] if accepted[index].profile_id then profile_by_username[tostring(accepted[index].source.username):lower()] = accepted[index] end end local tweets_table = source_table("twitter_tweets") local post_entries = {} local post_media_rows = {} if table_exists(tweets_table) then local tweets = Bridge.Database.Query(([[ SELECT tweet.*, DATE_FORMAT(tweet.`timestamp`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` tweet ]]):format(tweets_table), {}) for index = 1, #tweets do local row = tweets[index] local profile = profile_by_username[tostring(row.username):lower()] local media = extract_media(row.attachments, 4) local body = clamp_text(row.content, 360) if profile and (body ~= "" or #media > 0) then local post_id = deterministic_uuid("lb-phone-feather-post", row.id) local entry = { id = post_id, row = row, profile = profile, media = media, } post_entries[tostring(row.id)] = entry for position = 1, #media do local source_id = "lb-feather-post:" .. deterministic_uuid( "lb-phone-feather-post-media", tostring(row.id) .. ":" .. position ) media[position].source_id = source_id post_media_rows[#post_media_rows + 1] = { profile.account_id, media[position].url, source_id, media[position].media_type, "website_import", source_id, row.migrated_at, row.migrated_at, } end end end end insert_many([[ INSERT IGNORE INTO `sky_phone_media` (`account_id`, `url`, `remote_id`, `media_type`, `origin`, `source_id`, `verified_at`, `created_at`) VALUES ]], 8, post_media_rows) local post_media_ids = load_media_ids("lb-feather-post:%") local post_rows = {} local post_media_links = {} for _, entry in pairs(post_entries) do post_rows[#post_rows + 1] = { entry.id, entry.profile.profile_id, clamp_text(entry.row.content, 360), entry.row.migrated_at, } for position = 1, #entry.media do local media_id = post_media_ids[entry.media[position].source_id] if media_id then post_media_links[#post_media_links + 1] = { entry.id, media_id, position - 1 } end end end insert_many([[ INSERT IGNORE INTO `sky_phone_feather_posts` (`id`, `profile_id`, `body`, `created_at`) VALUES ]], 4, post_rows) insert_many([[ INSERT IGNORE INTO `sky_phone_feather_post_media` (`post_id`, `media_id`, `sort_order`) VALUES ]], 3, post_media_links) for _, entry in pairs(post_entries) do local parent = post_entries[tostring(entry.row.reply_to)] if parent and parent.id ~= entry.id then Bridge.Database.Query([[ UPDATE `sky_phone_feather_posts` SET `reply_to_id` = ? WHERE `id` = ? AND `reply_to_id` IS NULL ]], { parent.id, entry.id }) end end local hashtag_rows = {} for _, entry in pairs(post_entries) do local seen = {} for tag in tostring(entry.row.content or ""):gmatch("#([%w_]+)") do local normalized = tag:lower() if #normalized <= Config.Feather.HashtagMaxLength and not seen[normalized] then seen[normalized] = true hashtag_rows[#hashtag_rows + 1] = { entry.id, normalized, entry.row.migrated_at } end end end insert_many([[ INSERT IGNORE INTO `sky_phone_feather_hashtags` (`post_id`, `tag`, `created_at`) VALUES ]], 3, hashtag_rows) local retweet_rows = {} local retweets_table = source_table("twitter_retweets") if table_exists(retweets_table) then local rows = Bridge.Database.Query(([[ SELECT retweet.*, DATE_FORMAT(retweet.`timestamp`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` retweet ]]):format(retweets_table), {}) for index = 1, #rows do local profile = profile_by_username[tostring(rows[index].username):lower()] local quoted = post_entries[tostring(rows[index].tweet_id)] if profile and quoted then retweet_rows[#retweet_rows + 1] = { deterministic_uuid( "lb-phone-feather-retweet", tostring(rows[index].tweet_id) .. ":" .. tostring(rows[index].username) ), profile.profile_id, "", quoted.id, rows[index].migrated_at, } end end end insert_many([[ INSERT IGNORE INTO `sky_phone_feather_posts` (`id`, `profile_id`, `body`, `quote_id`, `created_at`) VALUES ]], 5, retweet_rows) local reaction_rows = {} local likes_table = source_table("twitter_likes") if table_exists(likes_table) then local rows = Bridge.Database.Query(([[ SELECT reaction.*, DATE_FORMAT(reaction.`timestamp`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` reaction ]]):format(likes_table), {}) for index = 1, #rows do local profile = profile_by_username[tostring(rows[index].username):lower()] local post = post_entries[tostring(rows[index].tweet_id)] if profile and post then reaction_rows[#reaction_rows + 1] = { post.id, profile.profile_id, "like", rows[index].migrated_at, } end end end insert_many([[ INSERT IGNORE INTO `sky_phone_feather_reactions` (`post_id`, `profile_id`, `kind`, `created_at`) VALUES ]], 4, reaction_rows) local follow_rows = {} local follows_table = source_table("twitter_follows") if table_exists(follows_table) then local rows = Bridge.Database.Query(("SELECT * FROM `%s`"):format(follows_table), {}) for index = 1, #rows do local follower = profile_by_username[tostring(rows[index].follower):lower()] local following = profile_by_username[tostring(rows[index].followed):lower()] if follower and following and follower.profile_id ~= following.profile_id then follow_rows[#follow_rows + 1] = { follower.profile_id, following.profile_id } end end end insert_many([[ INSERT IGNORE INTO `sky_phone_feather_follows` (`follower_id`, `following_id`) VALUES ]], 2, follow_rows) local notification_rows = {} local notifications_table = source_table("twitter_notifications") if table_exists(notifications_table) then local rows = Bridge.Database.Query(([[ SELECT notification.*, DATE_FORMAT(notification.`timestamp`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` notification ]]):format(notifications_table), {}) for index = 1, #rows do local recipient = profile_by_username[tostring(rows[index].username):lower()] local actor = profile_by_username[tostring(rows[index].from):lower()] local post = post_entries[tostring(rows[index].tweet_id)] local source_kind = tostring(rows[index].type):lower() local kind = source_kind == "like" and "like" or source_kind == "reply" and "reply" or source_kind == "retweet" and "quote" or source_kind == "follow" and "follow" or nil if recipient and actor and kind and recipient.profile_id ~= actor.profile_id and (kind == "follow" or post) then notification_rows[#notification_rows + 1] = { deterministic_uuid("lb-phone-feather-notification", rows[index].id), recipient.profile_id, actor.profile_id, post and post.id or nil, kind, rows[index].migrated_at, } end end end insert_many([[ INSERT IGNORE INTO `sky_phone_feather_notifications` (`id`, `recipient_id`, `actor_id`, `post_id`, `kind`, `created_at`) VALUES ]], 6, notification_rows) local private_profiles = 0 for index = 1, #accepted do if accepted[index].source.private == true or tonumber(accepted[index].source.private) == 1 then private_profiles = private_profiles + 1 end end return { source = #source_accounts, profiles = #accepted, posts = #post_rows, post_media = #post_media_links, retweets = #retweet_rows, likes = #reaction_rows, follows = #follow_rows, notifications = #notification_rows, ownership_repaired = ownership_repaired, skipped = skipped, unsupported_private_profiles = private_profiles, unsupported_follow_requests = count_rows(source_table("twitter_follow_requests")), unsupported_messages = count_rows(source_table("twitter_messages")), unsupported_promotions = count_rows(source_table("twitter_promoted")), } end local function run_flare(dry_run) local accounts_table = source_table("tinder_accounts") if not table_exists(accounts_table) then return { source = 0, profiles = 0, profile_photos = 0, swipes = 0, matches = 0, skipped = 0 } end local source_accounts = Bridge.Database.Query(([[ SELECT account.*, owner.`account_id` AS `owner_account_id`, TIMESTAMPDIFF(YEAR, account.`dob`, CURDATE()) AS `age`, DATE_FORMAT(account.`last_seen`, '%%Y-%%m-%%d %%H:%%i:%%s') AS `migrated_at` FROM `%s` account JOIN `sky_phone_migration_owners` owner ON owner.`source` = ? AND owner.`source_phone_number` = account.`phone_number` ORDER BY account.`phone_number` ]]):format(accounts_table), { source_name }) local entries = {} local photo_count = 0 local skipped = 0 for index = 1, #source_accounts do local row = source_accounts[index] local account_id = tonumber(row.owner_account_id) local name = clamp_text(row.name, 32):match("^%s*(.-)%s*$") if #name < 2 then name = "LB User" end local age = math.max(18, math.min(99, math.floor(tonumber(row.age) or 18))) local interested_men = row.interested_men == true or tonumber(row.interested_men) == 1 local interested_women = row.interested_women == true or tonumber(row.interested_women) == 1 local interested_in = interested_men and not interested_women and "man" or interested_women and not interested_men and "woman" or "everyone" if account_id then local photos = extract_media(row.photos, 6) local photo_entries = {} for position = 1, #photos do if photos[position].media_type == "photo" and photos[position].url:match("^https://") then photo_entries[#photo_entries + 1] = { url = photos[position].url, source_id = "lb-flare-photo:" .. deterministic_uuid( "lb-phone-flare-photo", tostring(row.phone_number) .. ":" .. tostring(position) ), } end end photo_count = photo_count + #photo_entries entries[#entries + 1] = { source = row, account_id = account_id, name = name, age = age, bio = clamp_text(row.bio, 300), gender = (row.is_male == true or tonumber(row.is_male) == 1) and "man" or "woman", interested_in = interested_in, discoverable = (row.active == true or tonumber(row.active) == 1) and 1 or 0, photos = photo_entries, } else skipped = skipped + 1 end end local swipes_table = source_table("tinder_swipes") local source_swipes = {} if table_exists(swipes_table) then source_swipes = Bridge.Database.Query(([[ SELECT swipe.*, swiper_owner.`account_id` AS `swiper_account_id`, target_owner.`account_id` AS `target_account_id` FROM `%s` swipe JOIN `sky_phone_migration_owners` swiper_owner ON swiper_owner.`source` = ? AND swiper_owner.`source_phone_number` = swipe.`swiper` JOIN `sky_phone_migration_owners` target_owner ON target_owner.`source` = ? AND target_owner.`source_phone_number` = swipe.`swipee` ]]):format(swipes_table), { source_name, source_name }) end if dry_run then return { source = #source_accounts, profiles = #entries, profile_photos = photo_count, swipes = #source_swipes, skipped = skipped, } end local existing_profiles = Bridge.Database.Query( "SELECT `account_id` FROM `sky_phone_flare_profiles`", {} ) local existing_profile_accounts = {} for index = 1, #existing_profiles do existing_profile_accounts[tonumber(existing_profiles[index].account_id)] = true end for index = 1, #entries do entries[index].import_profile = not existing_profile_accounts[entries[index].account_id] end local media_rows = {} for index = 1, #entries do local entry = entries[index] if entry.import_profile then for position = 1, #entry.photos do local photo = entry.photos[position] media_rows[#media_rows + 1] = { entry.account_id, photo.url, photo.source_id, "photo", "website_import", photo.source_id, entry.source.migrated_at, entry.source.migrated_at, } end end end insert_many([[ INSERT IGNORE INTO `sky_phone_media` (`account_id`, `url`, `remote_id`, `media_type`, `origin`, `source_id`, `verified_at`, `created_at`) VALUES ]], 8, media_rows) local profile_rows = {} for index = 1, #entries do local entry = entries[index] if entry.import_profile then profile_rows[#profile_rows + 1] = { entry.account_id, entry.name, entry.age, entry.bio, entry.gender, entry.interested_in, 18, 99, 0, json.encode({}), "longTerm", entry.discoverable, entry.source.migrated_at, entry.source.migrated_at, } end end local inserted_profiles = insert_many([[ INSERT IGNORE INTO `sky_phone_flare_profiles` (`account_id`, `name`, `age`, `bio`, `gender`, `interested_in`, `min_age`, `max_age`, `avatar`, `interests`, `looking_for`, `discoverable`, `created_at`, `updated_at`) VALUES ]], 14, profile_rows) local target_profiles = Bridge.Database.Query( "SELECT `id`, `account_id` FROM `sky_phone_flare_profiles`", {} ) local profile_by_account = {} for index = 1, #target_profiles do profile_by_account[tonumber(target_profiles[index].account_id)] = tonumber(target_profiles[index].id) end local media_ids = load_media_ids("lb-flare-photo:%") local photo_rows = {} for index = 1, #entries do local entry = entries[index] local profile_id = profile_by_account[entry.account_id] if profile_id and entry.import_profile then for position = 1, #entry.photos do local media_id = media_ids[entry.photos[position].source_id] if media_id then photo_rows[#photo_rows + 1] = { profile_id, media_id, position } end end end end insert_many([[ INSERT IGNORE INTO `sky_phone_flare_profile_photos` (`profile_id`, `media_id`, `sort_order`) VALUES ]], 3, photo_rows) local swipe_rows = {} local liked_pairs = {} for index = 1, #source_swipes do local row = source_swipes[index] local swiper_account_id = tonumber(row.swiper_account_id) local target_account_id = tonumber(row.target_account_id) if swiper_account_id and target_account_id and swiper_account_id ~= target_account_id and profile_by_account[swiper_account_id] and profile_by_account[target_account_id] then local liked = row.liked == true or tonumber(row.liked) == 1 swipe_rows[#swipe_rows + 1] = { swiper_account_id, target_account_id, liked and "like" or "pass", } if liked then liked_pairs[tostring(swiper_account_id) .. ":" .. tostring(target_account_id)] = true end else skipped = skipped + 1 end end insert_many([[ INSERT IGNORE INTO `sky_phone_flare_swipes` (`swiper_account_id`, `target_account_id`, `choice`) VALUES ]], 3, swipe_rows) local match_rows = {} local matched_pairs = {} for key in pairs(liked_pairs) do local first, second = key:match("^(%d+):(%d+)$") local first_id = tonumber(first) local second_id = tonumber(second) if first_id and second_id and liked_pairs[tostring(second_id) .. ":" .. tostring(first_id)] then local account_a_id = math.min(first_id, second_id) local account_b_id = math.max(first_id, second_id) local pair_key = tostring(account_a_id) .. ":" .. tostring(account_b_id) if not matched_pairs[pair_key] then matched_pairs[pair_key] = true match_rows[#match_rows + 1] = { deterministic_uuid("lb-phone-flare-match", pair_key), account_a_id, account_b_id, } end end end insert_many([[ INSERT IGNORE INTO `sky_phone_flare_matches` (`id`, `account_a_id`, `account_b_id`) VALUES ]], 3, match_rows) return { source = #source_accounts, profiles = inserted_profiles, profile_photos = #photo_rows, swipes = #swipe_rows, matches = #match_rows, skipped = skipped, } end local domain_order = { "devices", "settings", "alarms", "contacts", "blocked", "calls", "messages", "photos", "notes", "wallet", "voiceMemos", "picstagram", "mail", "mapMarkers", "darkChat", "flipTok", "feather", "flare", } local domain_runners = { devices = run_devices, settings = run_settings, alarms = run_alarms, contacts = run_contacts, blocked = run_blocked, calls = run_calls, messages = run_messages, photos = run_photos, notes = run_notes, wallet = run_wallet, voiceMemos = run_voice_memos, picstagram = run_picstagram, mail = run_mail, mapMarkers = run_map_markers, darkChat = run_dark_chat, flipTok = run_flip_tok, feather = run_feather, flare = run_flare, } local preview_tables = { settings = { "phones" }, alarms = { "clock_alarms" }, contacts = { "phone_contacts" }, blocked = { "phone_blocked_numbers" }, calls = { "phone_calls" }, messages = { "message_messages", "message_reactions" }, photos = { "photos", "photo_albums" }, notes = { "notes" }, wallet = { "wallet_transactions" }, voiceMemos = { "voice_memos_recordings" }, picstagram = { "instagram_accounts", "instagram_posts", "instagram_comments", "instagram_likes", "instagram_follows", "instagram_follow_requests", "instagram_stories", "instagram_stories_views", }, mail = { "mail_accounts", "mail_messages", "mail_deleted" }, mapMarkers = { "maps_locations" }, darkChat = { "darkchat_accounts", "darkchat_channels", "darkchat_members", "darkchat_messages" }, flipTok = { "tiktok_accounts", "tiktok_videos", "tiktok_likes", "tiktok_saves", "tiktok_follows", "tiktok_comments", "tiktok_comments_likes", "tiktok_notifications", }, feather = { "twitter_accounts", "twitter_tweets", "twitter_likes", "twitter_retweets", "twitter_follows", "twitter_notifications", }, flare = { "tinder_accounts", "tinder_swipes" }, } local function preview_domain(domain) local tables = preview_tables[domain] or {} local counts = { preview = true, source = 0, tables = {} } for index = 1, #tables do local rows = count_rows(source_table(tables[index])) counts.tables[tables[index]] = rows counts.source = counts.source + rows end return counts end local domain_labels = { devices = "Phones", settings = "Settings", alarms = "Alarms", contacts = "Contacts", blocked = "Blocked numbers", calls = "Calls", messages = "Messages", photos = "Photos", notes = "Notes", wallet = "Wallet", voiceMemos = "Voice memos", picstagram = "Picstagram", mail = "Mail", mapMarkers = "Map markers", darkChat = "DarkChat", flipTok = "FlipTok", feather = "Feather", flare = "Flare", } local summary_metrics = { { "imported", "imported" }, { "resolved", "matched" }, { "profiles", "profiles" }, { "ownership_repaired", "ownership repairs" }, { "profile_photos", "profile photos" }, { "swipes", "swipes" }, { "matches", "matches" }, { "sessions", "sessions" }, { "posts", "posts" }, { "post_media", "media" }, { "comments", "comments" }, { "follows", "follows" }, { "stories", "stories" }, { "story_views", "story views" }, { "mailboxes", "mailboxes" }, { "messages", "messages" }, { "entries", "entries" }, { "conversations", "chats" }, { "videos", "videos" }, { "reactions", "reactions" }, { "notifications", "activities" }, { "retweets", "retweets" }, { "likes", "likes" }, { "devices", "devices" }, { "avatars", "avatars" }, { "passcodes", "passcodes" }, } local function unsupported_count(stats) local count = 0 for key, value in pairs(stats) do if tostring(key):sub(1, 12) == "unsupported_" then count = count + math.max(0, tonumber(value) or 0) end end return count end local function format_domain_summary(domain, stats, dry_run) local source = math.max(0, tonumber(stats.source) or 0) local skipped = math.max(0, tonumber(stats.skipped) or 0) local unsupported = unsupported_count(stats) local parts = {} if source > 0 then parts[#parts + 1] = ("%s found"):format(source) end for index = 1, #summary_metrics do local metric = summary_metrics[index] local value = math.max(0, tonumber(stats[metric[1]]) or 0) if value > 0 and not (dry_run and metric[1] == "resolved") then local label = dry_run and metric[1] == "imported" and "ready" or metric[2] parts[#parts + 1] = ("%s %s"):format(value, label) end end if skipped > 0 then parts[#parts + 1] = ("%s skipped"):format(skipped) end if unsupported > 0 then parts[#parts + 1] = ("%s unsupported"):format(unsupported) end if #parts == 0 then return nil end return ("[sky_phone] %-22s %s"):format( (domain_labels[domain] or domain) .. ":", table.concat(parts, " | ") ) end local function format_rollback_summary(stats) local devices = math.max(0, tonumber(stats.devices) or 0) local sims = math.max(0, tonumber(stats.sims) or 0) local accounts = math.max(0, tonumber(stats.accounts) or 0) local mappings = math.max(0, tonumber(stats.ownerMappings) or 0) + math.max(0, tonumber(stats.numberMappings) or 0) + math.max(0, tonumber(stats.characterDevices) or 0) local markers = math.max(0, tonumber(stats.markers) or 0) local ignored = { devices = true, sims = true, accounts = true, ownerMappings = true, numberMappings = true, characterDevices = true, markers = true, } local app_entries = 0 for key, value in pairs(stats) do if not ignored[key] then app_entries = app_entries + math.max(0, tonumber(value) or 0) end end return ("%s phones | %s SIMs | %s accounts | %s app records | %s mappings | %s markers"):format( devices, sims, accounts, app_entries, mappings, markers ) end local function run_migration(options) if running then Bridge.Debug("warn", "[sky_phone] An LB Phone migration is already running.", { always = true }) return end if not table_exists(source_table("phones")) then Bridge.Debug( "info", "[sky_phone] LB Phone migration skipped because source table '%s' does not exist.", source_table("phones"), { always = true } ) return end running = true local dry_run = options and options.dry_run or false local force = options and options.force or false Bridge.Debug( "info", dry_run and "[sky_phone] LB Phone preview started. Source data remains unchanged." or "[sky_phone] LB Phone import started. Source data remains unchanged.", { always = true } ) local domains = migration_config.Domains or {} local failed = 0 local shown = 0 local completed = 0 for index = 1, #domain_order do local domain = domain_order[index] if domains[domain] ~= false then if not force and migration_done(domain) then completed = completed + 1 else local has_owner_mappings = Bridge.Database.Query([[ SELECT 1 FROM `sky_phone_migration_owners` WHERE `source` = ? LIMIT 1 ]], { source_name })[1] ~= nil local runner = dry_run and domain ~= "devices" and not has_owner_mappings and preview_domain or domain_runners[domain] local success, stats, error_code if runner == preview_domain then success, stats, error_code = pcall(runner, domain) else success, stats, error_code = pcall(runner, dry_run) end if success and stats then if not dry_run then record_migration(domain, stats) end local summary = format_domain_summary(domain, stats, dry_run) if summary then shown = shown + 1 Bridge.Debug("info", summary, { always = true }) end else failed = failed + 1 Bridge.Debug( "error", "[sky_phone] LB Phone migration domain '%s' failed: %s", domain, tostring(success and error_code or stats), { always = true } ) if domain == "devices" then break end end end end end running = false Bridge.Debug( failed == 0 and "info" or "error", dry_run and "[sky_phone] Preview complete: %s areas with data | %s already complete | %s errors." or "[sky_phone] Import complete: %s areas with data | %s already complete | %s errors.", tostring(shown), tostring(completed), tostring(failed), { always = true } ) end local function delete_values(table_name, column_name, values) if not table_name:match("^[%w_]+$") or not column_name:match("^[%w_]+$") then error("[sky_phone] Invalid rollback table or column identifier.") end local unique = {} local filtered = {} for index = 1, #values do local value = values[index] if value ~= nil and not unique[tostring(value)] then unique[tostring(value)] = true filtered[#filtered + 1] = value end end local deleted = 0 for first = 1, #filtered, 250 do local last = math.min(first + 249, #filtered) local placeholders = {} local parameters = {} for index = first, last do placeholders[#placeholders + 1] = "?" parameters[#parameters + 1] = filtered[index] end deleted = deleted + affected_rows(Bridge.Database.Query(( "DELETE FROM `%s` WHERE `%s` IN (%s)" ):format(table_name, column_name, table.concat(placeholders, ",")), parameters)) end return deleted end local function rollback_source_rows(suffix, required_columns) local table_name = source_table(suffix) if not table_exists(table_name) then return {} end for index = 1, #(required_columns or {}) do if not table_has_column(table_name, required_columns[index]) then return {} end end return Bridge.Database.Query(("SELECT * FROM `%s`"):format(table_name), {}) end local function rollback_lb_phone() if running then Bridge.Debug("warn", "[sky_phone] An LB Phone migration or rollback is already running.", { always = true }) return end running = true Bridge.Debug( "warn", "[sky_phone] LB Phone rollback started. Source data remains unchanged.", { always = true } ) local success, result = pcall(function() local stats = {} local owners = Bridge.Database.Query([[ SELECT * FROM `sky_phone_migration_owners` WHERE `source` = ? ]], { source_name }) local owner_by_phone_id = {} local owner_by_number = {} for index = 1, #owners do owner_by_phone_id[tostring(owners[index].source_phone_id)] = owners[index] owner_by_number[tostring(owners[index].source_phone_number)] = owners[index] end local contact_ids = {} local contacts = rollback_source_rows("phone_contacts", { "phone_number", "contact_phone_number", }) for index = 1, #contacts do contact_ids[#contact_ids + 1] = deterministic_uuid( "lb-phone-contact", tostring(contacts[index].phone_number) .. ":" .. tostring(contacts[index].contact_phone_number) ) end stats.contacts = delete_values("sky_phone_contacts", "id", contact_ids) local call_ids = {} local calls = rollback_source_rows("phone_calls", { "id" }) for index = 1, #calls do call_ids[#call_ids + 1] = deterministic_uuid("lb-phone-call", calls[index].id) end stats.calls = delete_values("sky_phone_calls", "id", call_ids) local message_ids = {} local messages = rollback_source_rows("message_messages", { "id" }) for index = 1, #messages do message_ids[#message_ids + 1] = deterministic_uuid("lb-phone-message", messages[index].id) end stats.messages = delete_values("sky_phone_sms_messages", "id", message_ids) local map_ids = {} local locations = rollback_source_rows("maps_locations", { "id" }) for index = 1, #locations do map_ids[#map_ids + 1] = deterministic_uuid("lb-phone-map-marker", locations[index].id) end stats.mapMarkers = delete_values("sky_phone_map_markers", "id", map_ids) local mail_ids = {} local mail_messages = rollback_source_rows("mail_messages", { "id" }) for index = 1, #mail_messages do mail_ids[#mail_ids + 1] = deterministic_uuid("lb-phone-mail-message", mail_messages[index].id) end stats.mail = delete_values("sky_phone_mail_messages", "id", mail_ids) local dark_conversations = {} local dark_channels = rollback_source_rows("darkchat_channels", { "name" }) for index = 1, #dark_channels do dark_conversations[#dark_conversations + 1] = deterministic_uuid( "lb-phone-darkchat-conversation", dark_channels[index].name ) end stats.darkChatConversations = delete_values( "sky_phone_darkchat_conversations", "id", dark_conversations ) local dark_ids = {} local dark_accounts = rollback_source_rows("darkchat_accounts", { "username" }) for index = 1, #dark_accounts do local entropy = deterministic_hex( "lb-phone-darkchat-profile", dark_accounts[index].username ):upper() dark_ids[#dark_ids + 1] = ("dark:%s-%s"):format(entropy:sub(1, 4), entropy:sub(5, 8)) end stats.darkChatProfiles = delete_values("sky_phone_darkchat_profiles", "dark_id", dark_ids) local picstagram_profiles = {} local instagram_accounts = rollback_source_rows("instagram_accounts", { "username" }) for index = 1, #instagram_accounts do local handle = normalize_picstagram_handle(instagram_accounts[index].username) if handle then picstagram_profiles[#picstagram_profiles + 1] = deterministic_uuid( "lb-phone-picstagram-profile", handle ) end end stats.picstagram = delete_values("sky_phone_picstagram_profiles", "id", picstagram_profiles) local flare_match_ids = {} local flare_swipe_rows = rollback_source_rows("tinder_swipes", { "swiper", "swipee", "liked" }) local liked_flare_pairs = {} local removed_flare_swipes = 0 for index = 1, #flare_swipe_rows do local swiper_owner = owner_by_number[tostring(flare_swipe_rows[index].swiper)] local target_owner = owner_by_number[tostring(flare_swipe_rows[index].swipee)] local swiper_account_id = swiper_owner and tonumber(swiper_owner.account_id) local target_account_id = target_owner and tonumber(target_owner.account_id) if swiper_account_id and target_account_id and swiper_account_id ~= target_account_id then removed_flare_swipes = removed_flare_swipes + affected_rows(Bridge.Database.Query([[ DELETE FROM `sky_phone_flare_swipes` WHERE `swiper_account_id` = ? AND `target_account_id` = ? ]], { swiper_account_id, target_account_id })) if flare_swipe_rows[index].liked == true or tonumber(flare_swipe_rows[index].liked) == 1 then liked_flare_pairs[tostring(swiper_account_id) .. ":" .. tostring(target_account_id)] = true end end end for key in pairs(liked_flare_pairs) do local first, second = key:match("^(%d+):(%d+)$") local first_id = tonumber(first) local second_id = tonumber(second) if first_id and second_id and liked_flare_pairs[tostring(second_id) .. ":" .. tostring(first_id)] and first_id < second_id then flare_match_ids[#flare_match_ids + 1] = deterministic_uuid( "lb-phone-flare-match", tostring(first_id) .. ":" .. tostring(second_id) ) end end stats.flareMatches = delete_values("sky_phone_flare_matches", "id", flare_match_ids) stats.flareSwipes = removed_flare_swipes local flare_accounts = rollback_source_rows("tinder_accounts", { "phone_number", "last_seen" }) local removed_flare_profiles = 0 for index = 1, #flare_accounts do local owner = owner_by_number[tostring(flare_accounts[index].phone_number)] local account_id = owner and tonumber(owner.account_id) if account_id and flare_accounts[index].last_seen then removed_flare_profiles = removed_flare_profiles + affected_rows(Bridge.Database.Query([[ DELETE FROM `sky_phone_flare_profiles` WHERE `account_id` = ? AND `created_at` = ? AND `updated_at` = ? ]], { account_id, flare_accounts[index].last_seen, flare_accounts[index].last_seen })) end end stats.flareProfiles = removed_flare_profiles local feather_post_ids = {} local tweets = rollback_source_rows("twitter_tweets", { "id" }) for index = 1, #tweets do feather_post_ids[#feather_post_ids + 1] = deterministic_uuid( "lb-phone-feather-post", tweets[index].id ) end local retweets = rollback_source_rows("twitter_retweets", { "tweet_id", "username" }) for index = 1, #retweets do feather_post_ids[#feather_post_ids + 1] = deterministic_uuid( "lb-phone-feather-retweet", tostring(retweets[index].tweet_id) .. ":" .. tostring(retweets[index].username) ) end stats.featherPosts = delete_values("sky_phone_feather_posts", "id", feather_post_ids) local feather_notification_ids = {} local twitter_notifications = rollback_source_rows("twitter_notifications", { "id" }) for index = 1, #twitter_notifications do feather_notification_ids[#feather_notification_ids + 1] = deterministic_uuid( "lb-phone-feather-notification", twitter_notifications[index].id ) end stats.featherNotifications = delete_values( "sky_phone_feather_notifications", "id", feather_notification_ids ) local flip_notification_ids = {} local tiktok_notifications = rollback_source_rows("tiktok_notifications", { "id" }) for index = 1, #tiktok_notifications do flip_notification_ids[#flip_notification_ids + 1] = deterministic_uuid( "lb-phone-fliptok-notification", tiktok_notifications[index].id ) end stats.flipTokNotifications = delete_values( "sky_phone_fliptok_notifications", "id", flip_notification_ids ) stats.notes = affected_rows(Bridge.Database.Query([[ DELETE FROM `sky_phone_notes` WHERE `id` LIKE 'lb-phone-note:%' ]], {})) stats.wallet = affected_rows(Bridge.Database.Query([[ DELETE FROM `sky_phone_bank_transactions` WHERE `reference` LIKE 'lb-phone-wallet:%' ]], {})) stats.media = affected_rows(Bridge.Database.Query([[ DELETE FROM `sky_phone_media` WHERE `origin` = 'website_import' AND `source_id` LIKE 'lb-%' ]], {})) local phones = rollback_source_rows("phones", { "id" }) local created_devices = {} local passcode_rows = 0 for index = 1, #phones do local owner = owner_by_phone_id[tostring(phones[index].id)] if owner then local expected_device = deterministic_imei(phones[index].id) if tostring(owner.device_imei) == expected_device then created_devices[#created_devices + 1] = expected_device end local salt = deterministic_hex("lb-phone-passcode-salt", phones[index].id) passcode_rows = passcode_rows + affected_rows(Bridge.Database.Query([[ DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ? AND `passcode_salt` = ? ]], { owner.device_imei, salt })) end end stats.passcodes = passcode_rows for index = 1, #owners do stats.alarms = (stats.alarms or 0) + affected_rows(Bridge.Database.Query([[ DELETE FROM `sky_phone_device_data` WHERE `device_imei` = ? AND `namespace` = 'alarms' AND `payload` LIKE '%lb-alarm-%' ]], { owners[index].device_imei })) end local migration_numbers = Bridge.Database.Query([[ SELECT * FROM `sky_phone_migration_numbers` WHERE `source` = ? ]], { source_name }) local created_sims = {} for index = 1, #migration_numbers do local row = migration_numbers[index] local normalized = tostring(row.phone_number) local owned_id = deterministic_uuid("lb-phone-sim", normalized) local external_id = deterministic_uuid("lb-phone-external-sim", normalized) if tostring(row.sim_id) == owned_id or tostring(row.sim_id) == external_id then created_sims[#created_sims + 1] = row.sim_id end end stats.ownerMappings = affected_rows(Bridge.Database.Query([[ DELETE FROM `sky_phone_migration_owners` WHERE `source` = ? ]], { source_name })) stats.numberMappings = affected_rows(Bridge.Database.Query([[ DELETE FROM `sky_phone_migration_numbers` WHERE `source` = ? ]], { source_name })) stats.characterDevices = delete_values( "sky_phone_character_devices", "device_imei", created_devices ) stats.devices = delete_values("sky_phone_devices", "imei", created_devices) stats.sims = delete_values("sky_phone_sims", "id", created_sims) local account_candidates = {} for index = 1, #owners do account_candidates[#account_candidates + 1] = { synthetic_email("lb", owners[index].owner_identifier), deterministic_hex("lb-account-password", owners[index].owner_identifier), } end for index = 1, #instagram_accounts do local handle = normalize_picstagram_handle(instagram_accounts[index].username) if handle then account_candidates[#account_candidates + 1] = { synthetic_email("ig", handle), deterministic_hex("lb-phone-picstagram-account", handle), } end end local tiktok_accounts = rollback_source_rows("tiktok_accounts", { "username" }) for index = 1, #tiktok_accounts do local handle = normalize_social_handle(tiktok_accounts[index].username, 24, true) if handle then account_candidates[#account_candidates + 1] = { synthetic_email("flip", handle), deterministic_hex("lb-phone-fliptok-account", handle), } end end local twitter_accounts = rollback_source_rows("twitter_accounts", { "username" }) for index = 1, #twitter_accounts do local handle = normalize_social_handle( twitter_accounts[index].username, Config.Feather.HandleMaxLength, false ) if handle then account_candidates[#account_candidates + 1] = { synthetic_email("feather", handle), deterministic_hex("lb-phone-feather-account", handle), } end end for index = 1, #dark_accounts do account_candidates[#account_candidates + 1] = { synthetic_email("dark", dark_accounts[index].username), deterministic_hex("lb-phone-darkchat-account", dark_accounts[index].username), } end local sender_addresses = {} for index = 1, #mail_messages do sender_addresses[tostring(mail_messages[index].sender or ""):lower()] = true end for address in pairs(sender_addresses) do account_candidates[#account_candidates + 1] = { valid_legacy_email(address) or synthetic_email("mail", address), deterministic_hex("lb-phone-mail-account", address), } end local removed_accounts = 0 for index = 1, #account_candidates do removed_accounts = removed_accounts + affected_rows(Bridge.Database.Query([[ DELETE FROM `sky_phone_accounts` WHERE `email` = ? AND `password` = ? ]], account_candidates[index])) end stats.accounts = removed_accounts stats.markers = affected_rows(Bridge.Database.Query([[ DELETE FROM `sky_phone_migrations` WHERE `source` = ? OR `name` LIKE 'lb-phone:%' ]], { source_name })) return stats end) running = false if not success then Bridge.Debug( "error", "[sky_phone] LB Phone migration rollback failed: %s", tostring(result), { always = true } ) return end Bridge.Debug( "info", "[sky_phone] Rollback complete: %s", format_rollback_summary(result), { always = true } ) end local function announce_lb_phone_source() local success, table_count = pcall(function() local phones_table = source_table("phones") if not table_exists(phones_table) or not table_has_column(phones_table, "id") or not table_has_column(phones_table, "owner_id") or not table_has_column(phones_table, "phone_number") then return 0 end local found = {} for _, tables in pairs(preview_tables) do for index = 1, #tables do local table_name = source_table(tables[index]) if not found[table_name] and table_exists(table_name) then found[table_name] = true end end end local count = 0 for _ in pairs(found) do count = count + 1 end return count end) table_count = success and math.max(0, tonumber(table_count) or 0) or 0 if table_count > 0 then Bridge.Debug( "info", "[sky_phone] LB Phone data detected: %s tables found. Run: skyphone:migrate lb-phone", tostring(table_count), { notice = true } ) end end RegisterCommand("skyphone:migrate", function(command_source, args) if command_source ~= 0 then Bridge.Debug( "warn", "[sky_phone] The skyphone:migrate command is server-console only.", { always = true } ) return end local requested_source = tostring(args[1] or "lb-phone"):lower() local option = tostring(args[2] or ""):lower() if requested_source == "dry" or requested_source == "force" or requested_source == "remove" or requested_source == "rollback" then option = requested_source requested_source = "lb-phone" end if requested_source ~= "lb-phone" and requested_source ~= "lb" then Bridge.Debug( "error", "[sky_phone] Unsupported migration source '%s'. Available source: lb-phone.", requested_source, { always = true } ) return end if option == "remove" or option == "rollback" then rollback_lb_phone() return end if option ~= "" and option ~= "dry" and option ~= "force" then Bridge.Debug( "error", "[sky_phone] Unsupported migration option '%s'. Use dry, force, or remove.", option, { always = true } ) return end run_migration({ dry_run = option == "dry", force = option == "force" }) end, false) announce_lb_phone_source() end)