mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 14:01:35 +00:00
4147 lines
165 KiB
Lua
4147 lines
165 KiB
Lua
-- 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)
|