From 3e35a12efd5cbdd1fa3dfb46d20a856cd9b91069 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Mon, 17 Aug 2026 02:08:31 +0200 Subject: [PATCH] feat: add LB migration and reorganize configuration --- README.md | 732 ++-- frontend/src/utils/gameView.ts | 1 - sky_phone/config/companies.lua | 253 -- sky_phone/config/config.lua | 743 +++- sky_phone/config/locales/de.lua | 433 ++ sky_phone/config/media.lua | 64 - sky_phone/config/music.lua | 19 - sky_phone/config/payphones.lua | 248 -- sky_phone/config/weazel_news.lua | 30 - sky_phone/fxmanifest.lua | 12 +- .../bridge/client/housing/esx_property.lua | 2 +- sky_phone/source/bridge/shared.lua | 5 +- sky_phone/source/client/custom_app_compat.lua | 44 +- sky_phone/source/client/custom_apps.lua | 24 +- sky_phone/source/server/companies.lua | 2 +- sky_phone/source/server/custom_app_compat.lua | 16 +- sky_phone/source/server/custom_apps.lua | 10 +- sky_phone/source/server/db_migrate.lua | 65 + sky_phone/source/server/fliptok.lua | 14 +- .../source/server/lb_phone_migration.lua | 3727 +++++++++++++++++ sky_phone/source/server/media.lua | 5 +- sky_phone/source/server/phone.lua | 60 +- sky_phone/source/server/picstagram.lua | 8 +- sky_phone/sql/install.sql | 42 + 24 files changed, 5603 insertions(+), 956 deletions(-) delete mode 100644 sky_phone/config/companies.lua create mode 100644 sky_phone/config/locales/de.lua delete mode 100644 sky_phone/config/media.lua delete mode 100644 sky_phone/config/music.lua delete mode 100644 sky_phone/config/payphones.lua delete mode 100644 sky_phone/config/weazel_news.lua create mode 100644 sky_phone/source/server/lb_phone_migration.lua diff --git a/README.md b/README.md index f46b137..4439945 100644 --- a/README.md +++ b/README.md @@ -1,261 +1,189 @@ -# sky_phone +# Sky Phone -## Payphones +Sky Phone is a complete, standalone FiveM smartphone resource with a modern iPhone-inspired interface, persistent device data, physical or virtual SIM support, social apps, media, services, games, and framework integrations. -Payphone dialing is validated against server-owned booth positions. Vanilla GTA V positions live -in `sky_phone/config/payphones.lua`; the server compares its own player coordinates with this list -and does not trust coordinates or models sent by the NUI or client. +The production frontend is included. Customers do not need Node.js or pnpm for a normal server installation. -For a custom map or MLO, add each booth to `Config.Payphones.Locations` in that server-only file: +## Highlights -```lua -{ model = "prop_phonebox_01a", coords = { x = 123.45, y = 678.90, z = 21.0 } }, -``` - -The model must also be present in `Config.Payphones.Props`. Coordinates must be finite Lua numbers -inside the supported world bounds. Restart `sky_phone` after changing the list. If payphones are -enabled but the list is empty or contains invalid entries, the server logs a visible warning and -rejects calls that cannot be matched to a valid configured location. - -## Custom Apps - -`sky_phone` erkennt Registrierungen gestarteter Fremd-App-Ressourcen über integrierte -Hersteller-Aliase und übernimmt unterstützte Apps automatisch in Springboard und App Store. In -App-Ressourcen müssen dafür keine Sky-Vorlagen abgelegt werden. Unterstützt werden die -dokumentierten Basisverträge von LB Phone, 17Movement, High Phone, Quasar Smartphone V3 und -YSeries; die Resource- und Cfx-Export-Aliase `lb-phone`, `17mov_Phone`, `high-phone`, -`qs-smartphone` und `yseries` werden direkt von `sky_phone` bereitgestellt. Einrichtung und -ehrliche Kompatibilitätsgrenzen stehen in der -[deutschen Custom-App-Anleitung](docs/custom-apps.md). - -## FlipTok verification - -FlipTok verification is server-authoritative and limited to the framework groups configured in -`Config.FlipTok.AdminGroups`; no command ACE is required. Use -`/fliptokverify <@handle> [on|off]`. Without `on` or `off`, the current blue-check state is toggled. -The command name is configurable through `Config.FlipTok.VerifyCommand`. -Verification command access uses `Config.FlipTok.AdminGroups`. The report moderation overview is -server-authoritative and independently restricted through `Config.FlipTok.ReportAdminGroups`. - -## FlipTok music - -Licensed music can be exposed in the composer through `Config.FlipTok.MusicTracks`. Keep the IDs -stable because published videos store the selected ID; URLs must be directly playable by the NUI. - -```lua -MusicTracks = { - { Id = "night-drive", Title = "Night Drive", Artist = "Sky Radio", Url = "https://cdn.example.com/night-drive.ogg" }, -} -``` - -## Music app - -The standalone `Music` app plays audio only inside the current player's NUI. It never creates a -world sound, voice channel, positional event, or 3D-audio state that another player can hear. - -Server-owned MP3/OGG tracks and their optional artwork live directly in -`sky_phone/config/music`. Define only their stable ID, title, and artist in -`sky_phone/config/music.lua`: - -```lua -Tracks = { - { - Id = "night-drive", - Title = "Night Drive", - Artist = "Sky Records", - }, -} -``` - -The resource searches `config/music` and all of its subdirectories for files whose name matches -the ID. For the example above, place `night-drive.ogg` or `night-drive.mp3` anywhere below that -directory. Optional artwork uses the same name with `.webp`, `.png`, `.jpg`, or `.jpeg`. If both -audio formats exist, OGG wins; artwork priority is WEBP, PNG, JPG, then JPEG. More than one file -with the selected extension and ID is ambiguous and the server reports it in the console. Folder -names may contain spaces, but must not contain path separators or control characters. Do not name -a folder itself with a supported audio or artwork extension. - -No frontend build is needed when tracks change. Restart the resource so FiveM republishes the -files and reloads the track configuration. Keep existing track IDs stable because playlists store -those IDs. Moving matching files between subdirectories does not affect playlists. - -When upgrading from the previous path-based configuration, rename each audio and artwork file to -its existing ID and remove the `File` and `Artwork` fields. Do not change the ID itself, otherwise -existing playlist entries can no longer resolve the track. - -Players can add public YouTube video links to their own library. Metadata is requested through -YouTube's oEmbed endpoint on the server, while playback uses the embedded YouTube player only on -that player's NUI. Personal songs and playlists are stored per linked Sky Cloud account, or per phone -IMEI while signed out, in the `sky_phone_music_*` tables. Limits and rate controls are configured in -`config/music.lua`. - -## FlipTok accounts - -FlipTok profiles use their own username and password login. Registration requires a linked Sky Cloud -account once so an existing creator profile, videos, followers, and verification can be claimed -without data loss. Login sessions are stored per phone IMEI and survive resource or server restarts; -signing out removes only that device session. - -Set a private, stable password pepper in `server.cfg` before players register. Changing it later -invalidates every existing FlipTok password: - -```cfg -set sky_phone_fliptok_password_pepper "replace-with-a-long-random-secret" -``` - -Passwords are stored as salted hashes. The pepper is read server-side from the convar and is never -included in the NUI bundle. - -Standalone FiveM phone built with Vue 3, TypeScript, Pinia, Vue Router, the Sky-owned FiveM UI system, and Tailwind CSS 4. Konsta UI remains only for screens that have not yet completed their migration. The phone opens through the usable item; `/phone` is disabled unless `Config.Phone.DevelopmentCommand` is enabled explicitly. `Config.Phone.AllowMovement` controls whether game input remains active while the mobile phone is open. Phone identity and SIM-card behavior are selected independently through `Config.Phone.Unique` and `Config.Sim.Enabled`. - -### Ingame test data - -On development servers, enable `Config.TestData.Enabled` and run `/phonetestdata` as the player who -owns the phone. The command creates or refreshes idempotent, player-scoped fixtures for contacts, -calls, messages, mail, notes, gallery, banking history, billing, calendar, map markers, music, radio, -EasyShare, CityMarkt, Local Pages, Picstagram, FlipTok, Feather, Flare, DarkChat, CrewLink, SkyRide, -and company requests. It also creates a linked Sky Cloud account and a registered SIM when the selected -phone does not have them yet. Reopen the phone after the command completes. - -Garage and Housing intentionally continue to use the real configured provider data. Apps without -persistent content, such as Calculator, Camera, Clock, Weather, Settings, and Payphone, do not need -database fixtures. Set `Config.TestData.AdminOnly = true` to restrict the command to the configured -framework admin groups, and disable the feature outside development environments. - -A Sky Cloud account is optional. Unlinked devices retain local settings, alarms, media, apps, notes, contacts, and recent calls. Linking from Mail or Settings moves local data into an empty Sky Cloud account; an existing Sky Cloud dataset wins over local contacts and recents. Signing out keeps an editable local snapshot without deleting Sky Cloud data. - -## Phone identity and SIM modes - -Both compatibility switches default to the physical, unique-item behavior: - -```lua -Config.Phone.Unique = true -Config.Sim.Enabled = true -``` - -For backwards compatibility, an omitted switch is also treated as `true`. - -With `Config.Phone.Unique = true`, every phone item is one transferable physical Device. Its unique -15-digit IMEI is stored in item metadata, so its settings, PIN, local app data, linked Sky Cloud -account, and installed SIM travel with that item. The item must be non-stackable. - -With `Config.Phone.Unique = false`, the server assigns each framework character one persistent -virtual Device. Possessing any configured phone item grants access to that Device, but the item does -not own its IMEI or data. Losing a handset therefore removes access only until the character obtains -another one; the replacement opens the same data, PIN, account session, and number. Phone items may -be stackable in this mode, although at least one phone item is still required to use or receive the -phone. The character mapping is server-authoritative and uses the framework character identifier, -never a client-supplied owner value. - -With `Config.Sim.Enabled = true`, a physical registered or anonymous SIM item must be inserted before -the Device has cellular service. SIM items remain unique and non-stackable. With -`Config.Sim.Enabled = false`, SIM inventory items are not required. The first time a Device without -an attached SIM is resolved, the server creates an anonymous virtual SIM with a random unique phone -number using `Config.Sim.NumberPrefix` and `NumberLength`; `NumberGroups` controls its display -format. Physical SIM insertion and ejection are disabled in this mode. An already attached physical -SIM and its number are preserved when SIM requirements are disabled. - -The switches are independent. An automatic number follows a physical handset when unique phones are -enabled and follows the character's persistent virtual Device when unique phones are disabled. -Likewise, a physical SIM inserted while unique phones are disabled belongs to the character's -virtual Device and remains available after replacing the handset. - -### Existing installations and first use - -After changing an existing installation to `Config.Phone.Unique = false`, the first phone item a -character uses establishes the persistent mapping. If that item has a valid legacy IMEI which is not -already mapped to another character, the complete existing Device is adopted. This preserves its -local content, PIN, linked account, and attached SIM. If the legacy IMEI cannot be adopted, the -server creates a fresh virtual Device instead. Historic unique Devices did not record an owner, so -the character carrying a handset on its first use after the change is the character that claims it. - -When `Config.Sim.Enabled = false`, an existing attached physical SIM is kept; only Devices without a -SIM receive a random virtual one. If SIM cards are enabled again, automatically created virtual SIMs -are detached during resource startup so those Devices once again require a physical SIM. Stored -character-to-Device mappings remain available, but changing back to unique phones does not copy a -virtual Device's data onto an arbitrary inventory item. Treat production mode changes as migrations -and restart the resource after updating the configuration. +- Modern Sky UI with light and dark mode support +- Unique physical phones or one persistent virtual phone per character +- Registered, anonymous, physical, and automatic virtual SIM cards +- Calls, Messages, Mail, DarkChat, Radio, EasyShare, and payphones +- Picstagram, FlipTok, Feather, Flare, and CrewLink +- Banking, Billing, Garage, Housing, Companies, CityMarkt, Local Pages, Maps, SkyRide, and Weazel News +- Camera, Photos, Music, Calendar, Clock, Notes, Voice Memos, Weather, and Calculator +- Built-in games and an App Store +- English and German language support +- Automatic database installation and upgrades +- Command-only LB Phone database migration with preview and rollback +- Compatibility adapters for popular frameworks, inventories, voice systems, housing resources, and external phone apps ## Requirements -- ESX Legacy (`es_extended`), Qbox (`qbx_core`), or QBCore (`qb-core`). The bridge selects a running supported framework when `Config.Bridge.Framework` is set to `"auto"`. -- A supported inventory: `ox_inventory`, `qb-inventory`, `lj-inventory`, `qs-inventory`, `codem-inventory`, `core_inventory`, `mf-inventory`, or `smx-inventory`. The bridge auto-detects a running provider and normalizes metadata, slots, counts, item mutations, and usable-item callbacks. `mf-inventory` and `smx-inventory` require ESX. Because SMX stores standard ESX items as stacks, its adapter persists one active Phone/SIM metadata record per player and item type in ESX player metadata. -- An inventory item named `phone`. It must be non-stackable when `Config.Phone.Unique = true` and may be stackable when it is `false`. -- When `Config.Sim.Enabled = true`, two unique, non-stackable inventory items named `sky_phone_sim_registered` and `sky_phone_sim_anonymous`. Their metadata is initialized automatically on first use, so shops and crafting recipes add plain items without supplying a number. These item definitions are not required when SIM cards are disabled. -- `oxmysql` with MySQL/MariaDB. -- `pma-voice` when `Config.Calls.VoiceProvider` is set to `"pma"` (the alias `"pma-voice"` selects the same adapter), or SaltyChat when it is set to `"saltychat"` (alias `"salty"`). SaltyChat additionally enables the in-call speaker control; PMA Voice keeps that control unavailable instead of simulating a local-only state. Set `Config.Speaker.Enabled = false` to disable the SaltyChat phone and radio speaker system globally; the controls then stay unavailable and server callbacks reject attempts to enable them. -- A FiveManage V3 Media API token for Camera photo/video uploads, Voice Memo audio uploads, and Gallery deletion. Set it in the - server-only `sky_phone/config/media.lua`; the token is never sent to NUI because clients receive - temporary presigned upload URLs instead: +### Required -```lua -Config.Media.FiveManage.ApiKey = "replace-with-your-media-token" -``` -- `yaca-voice`, `pma-voice`, or `saltychat` when the Radio app is enabled. `Config.Radio.VoiceProvider = "auto"` selects the first running provider in that order. +- MySQL or MariaDB +- `oxmysql` +- One supported framework: + - ESX Legacy (`es_extended`) + - Qbox (`qbx_core`) + - QBCore (`qb-core`) +- One supported inventory: + - `ox_inventory` + - `qb-inventory` + - `lj-inventory` + - `qs-inventory` + - `codem-inventory` + - `core_inventory` + - `mf-inventory` + - `smx-inventory` -## Messages GIF provider +`mf-inventory` and `smx-inventory` are supported with ESX. -Configure GIF search directly in the server-only `sky_phone/config/media.lua`: +### Voice -```lua -Config.Media.GiphyApiKey = "replace-with-your-giphy-api-key" +Phone calls support: + +- PMA Voice +- SaltyChat + +The Radio app supports: + +- YACA +- PMA Voice +- SaltyChat + +Start the selected voice resource before Sky Phone. + +### Optional services + +- FiveManage V3 Media API for Camera uploads, videos, Voice Memos, and remote Gallery deletion +- GIPHY API for GIF search +- Supported Garage and Housing resources when those apps should use external provider data + +## Quick installation + +1. Copy the resource into your FiveM resources directory. +2. Keep the resource folder name `sky_phone`. +3. Start `oxmysql`, your framework, inventory, and voice resource before Sky Phone. +4. Open `sky_phone/config/config.lua` and review the configuration. +5. Add the required inventory items. +6. Add `ensure sky_phone` to `server.cfg`. +7. Restart the server and watch the console for warnings. + +Example start order: + +```cfg +ensure oxmysql +ensure es_extended +ensure ox_inventory +ensure pma-voice +ensure sky_phone ``` -GIPHY provides trending and searched GIFs through a paginated server-side proxy. Only the server -reads the key. Never expose it to the client or NUI. Photo and video actions in Messages use media -captured by the Camera app. +Replace the example framework, inventory, and voice resources with the providers used by your server. -Database migrations run automatically. Existing `sky_phone_mail_accounts` installations are renamed to `sky_phone_accounts` while preserving account IDs and mail foreign keys. The migration also creates `sky_phone_character_devices` for persistent non-unique phone mappings and marks automatic SIMs through `sky_phone_sims.is_virtual`. Sky Cloud passwords are intentional in-character credentials and remain plaintext `VARCHAR(64)` values; registration screens warn players never to reuse a real password. +Sky Phone creates and upgrades its database tables automatically. A manual SQL import is normally not required. -Camera and Gallery media is stored in `sky_phone_media`. Signed-out captures belong to the current -IMEI; linking a Sky Cloud account moves those rows into the account gallery so every linked phone sees -them. Signing out hides Sky Cloud media without deleting it. Factory reset removes device-local media -and attempts to delete only remote FiveManage files created by `sky_phone`; account-owned media -remains in Sky Cloud. Media imported from a website is removed locally but never deleted at its -source. Register import websites under `Config.Media.Import.Websites` in -`sky_phone/config/media.lua`; built-in adapters support FiveManage files and version-1 JSON -manifests. The Gallery import form accepts direct HTTPS image/video links only when their hostname -matches the selected website's `AllowedMediaHosts`. +## Configuration -For a fresh manual database installation, import `sky_phone/sql/install.sql`. It contains the complete current table, key, index, collation, and foreign-key schema. Runtime migrations remain authoritative for upgrading an existing installation and must stay enabled. +All customer settings are organized in: -Framework, inventory, callback, notification, and database integrations live under `sky_phone/source/bridge`. The resource has no dependency on any other Sky resource. +```text +sky_phone/config/config.lua +``` -## Weazel News app +The file contains clearly separated sections for: -The built-in Weazel News app publishes articles for every phone user and exposes its editorial -desk only to server-authorized jobs. Configure the job-to-minimum-grade map in -`sky_phone/config/weazel_news.lua`; unlisted jobs remain read-only: +| Section | Purpose | +| --- | --- | +| `Config.Bridge` | Framework, inventory, language, callback timeout, and debug mode | +| `Config.Phone` | Phone item, movement, unique-device mode, and development command | +| `Config.Sim` | Physical or virtual SIM behavior and number formatting | +| `Config.Calls` / `Config.Radio` | Voice providers, call behavior, radio limits, and permissions | +| `Config.Payphones` | Payphone pricing, props, validation, and server-owned locations | +| `Config.Animations` | Phone prop, animations, and portrait/landscape transforms | +| App sections | Limits and behavior for every built-in app | +| `Config.Server` | Stable password and passcode peppers | +| `Config.Companies` | Company directory, jobs, services, and permissions | +| `Config.Media` | FiveManage, GIPHY, uploads, and Gallery imports | +| `Config.Music` | Server music library and playlist limits | +| `Config.Migrations` | Manual LB Phone migration domains | +| `Config.WeazelNews` | Editorial jobs, categories, and article limits | + +Restart `sky_phone` after changing Lua configuration. + +### Language + +Available locales: + +- English: `en` +- German: `de` + +Select the language near the top of `config.lua`: ```lua -AllowedJobs = { - weazel = 0, - reporter = 2, +Config.Bridge.Locale = "en" +``` + +or: + +```lua +Config.Bridge.Locale = "de" +``` + +Locale files are stored separately: + +```text +sky_phone/config/locales/en.lua +sky_phone/config/locales/de.lua +``` + +The German locale uses the complete English structure as a fallback, so newly introduced keys never leave the interface without text. + +### Debug output + +```lua +Config.Bridge.Debug = false +``` + +When enabled, Sky Phone prints debug and informational messages. Warnings and errors are always shown. + +The short LB Phone detection notice also remains visible when debug mode is disabled. + +## Security values + +Sky Phone ships with stable generated defaults in `Config.Server`: + +```lua +Config.Server = { + PasscodePepper = "...", + FlipTokPasswordPepper = "...", + PicstagramPasswordPepper = "...", } ``` -Every create, update, and delete request rechecks the player's current framework job and grade on -the server. Authorized editorial jobs share the newsroom and can manage drafts and published -articles. Cover images must come from the current phone's Gallery, changes use revision checks, and -deletion is retained as an audit-safe soft delete. Runtime migration creates -`sky_phone_weazel_articles` automatically; fresh installations receive the same schema through -`sky_phone/sql/install.sql`. +For a production server, replace them with your own long, random, different values before players create passcodes or social accounts. -## Radio app +Important: -The built-in Radio app supports a primary frequency, volume, recent channels, participant lists, automatic rejoin, join/leave notifications, and an optional service number. YACA and SaltyChat support the configured secondary frequency; PMA Voice exposes one radio channel, so the secondary input is hidden automatically. SaltyChat also exposes the provider-backed radio speaker control when `Config.Speaker.Enabled` is enabled. The control is omitted when the global speaker system is disabled and for YACA or PMA Voice because those adapters do not provide an equivalent speaker API. +- Keep the values private and stable. +- Changing `PasscodePepper` invalidates existing device passcodes. +- Changing a social-app pepper invalidates existing passwords for that app. +- Do not replace these values during routine updates. -Configure frequency bounds and precision, restricted channel ranges and allowed jobs, history length, defaults, badge validation, radio display-name permissions, and the built-in speaker HUD under `Config.Radio`. `Config.Radio.DisplayName.AllowedJobs` maps authoritative framework job names to their minimum grade. Unlisted jobs cannot change the name; an empty name restores the normal player or character name. Channel and display-name access are always checked server-side. `Config.Radio.Hud` controls the phone-owned overlay, its screen edge, offsets, and recent-speaker duration without depending on another HUD resource. Active-speaker highlighting uses the YACA radio events; the Radio app itself continues to support every configured voice provider. +Sky Cloud logins are in-character credentials for the roleplay phone. Players must never reuse a +real-world password. FlipTok and Picstagram passwords are stored as salted hashes using their +configured peppers. -Radio profiles are stored in `sky_phone_radio_profiles`. Runtime migration creates the table automatically; fresh installations receive it through `sky_phone/sql/install.sql`. +The server-only block is evaluated only on the server. Because the project uses a customer-requested single configuration file that is also present in the client resource package, protect access to your distributed resource files if these values must remain strictly secret. -Inventory metadata has no framework-wide standard: providers differ in export names, callback payloads, slot handling, and whether metadata is called `metadata` or `info`. For that reason, `sky_phone` uses explicit provider adapters instead of guessing exports at runtime. Every supported adapter implements slot lookup, item lookup, metadata replacement, capacity handling, add/remove operations, and usable-item registration. Providers without a separate capacity export use their authoritative add operation as the final capacity gate. Phone item metadata is authoritative only when `Config.Phone.Unique = true`; in non-unique mode the persistent character mapping is authoritative instead. +## Inventory items -When physical SIMs are enabled and a SIM is ejected or replaced, the returned inventory item is rebuilt from the authoritative `sky_phone_sims` row. Its metadata contains `sim_metadata_version`, `sim_id`, `phone_number`, `formatted_number`, and `sim_type`. Registered SIMs additionally contain `firstname`, `lastname`, `birthdate`, and `registered_at`. The internal framework owner identifier remains database-only. Inserting the item again resolves the SIM by `sim_id`; contacts and device/Sky Cloud data remain attached to their existing Device or Sky Cloud persistence instead of being copied into inventory metadata. Automatically created virtual SIMs remain database-only and never become inventory items. +### ox_inventory -For `ox_inventory`, configure the phone with `stack = false` when `Config.Phone.Unique = true`; it may use `stack = true` in non-unique mode. Physical SIM items always use `stack = false` and are only needed when `Config.Sim.Enabled = true`. Every usable item should use `consume = 0`. Do not configure a client event or export. Ox then completes its normal server-authoritative use flow and emits `ox_inventory:usedItem`; the bridge resolves the authoritative slot again and only opens the matching Device or SIM. A client export would return before Ox calls `useItem` and therefore prevent `ox_inventory:usedItem` from being emitted. - -Example `ox_inventory/data/items.lua` entries for the default unique-phone, physical-SIM modes: +Default entries for unique phones with physical SIM cards: ```lua ["phone"] = { @@ -265,6 +193,7 @@ Example `ox_inventory/data/items.lua` entries for the default unique-phone, phys close = true, consume = 0, }, + ["sky_phone_sim_registered"] = { label = "Registered SIM", weight = 5, @@ -272,6 +201,7 @@ Example `ox_inventory/data/items.lua` entries for the default unique-phone, phys close = true, consume = 0, }, + ["sky_phone_sim_anonymous"] = { label = "Anonymous SIM", weight = 5, @@ -281,54 +211,328 @@ Example `ox_inventory/data/items.lua` entries for the default unique-phone, phys }, ``` -For QBCore-style item tables, set the phone's `unique` value to match `Config.Phone.Unique`, and use `useable = true` and `shouldClose = true`. When physical SIMs are enabled, define both SIM items with `unique = true`, `useable = true`, and `shouldClose = true`; omit them when SIMs are disabled. The provider adapter registers the server-side usable callbacks; no `lb-phone` event or export is used. +Do not configure an LB Phone client event or client export. Sky Phone registers the usable items through its server-side inventory adapter. -The homescreen is an original implementation inspired by the interaction and layout concepts in [lukejacksonn/homescreen](https://github.com/lukejacksonn/homescreen), inspected at commit [`98a812f`](https://github.com/lukejacksonn/homescreen/tree/98a812f4f7c33594e791d65092f73b8f54b3c598). No source code or image assets from that project are included. +### QBCore-style item tables -## Development +- Set the phone's `unique` value to match `Config.Phone.Unique`. +- Set `useable = true` and `shouldClose = true`. +- Physical SIM items must always be unique. +- SIM items are not required when `Config.Sim.Enabled = false`. -From `frontend/`, run `pnpm dev` for browser development. The phone opens automatically and NUI callbacks are mocked with stateful data. Every built-in app can be opened directly by appending its id to `http://localhost:5174/?apiPort=3002#/apps/`: +## Phone and SIM modes -| Area | App ids | -| ------------------- | ------------------------------------------------------------------------------------------------------------- | -| Communication | `phone`, `messages`, `mail`, `darkchat`, `radio` | -| Social | `feather`, `fliptok`, `picstagram`, `flare`, `crewlink` | -| Services | `companies`, `citymarkt`, `local-pages`, `banking`, `billing`, `garage`, `house`, `map`, `skyride`, `weather` | -| Media and utilities | `camera`, `photos`, `music`, `calendar`, `notes`, `calculator`, `clock`, `app-store`, `settings` | -| Games | `snake`, `memory`, `number-merge`, `minesweeper`, `tower-stack`, `sky-flappy`, `neon-drop` | +The two mode switches are independent: -The browser bootstrap includes contacts, calls, messages, mail, invoices, transactions, vehicles, properties, companies, marketplace profiles and listings, social feeds, media, calendar entries, notes, alarms, game high scores, app settings, and persisted notifications. Mutating callbacks update the in-memory mock state until the mock server restarts. Unknown callbacks fail with `mock_endpoint_missing` instead of silently succeeding. +```lua +Config.Phone.Unique = true +Config.Sim.Enabled = true +``` -System overlays are available through dedicated preview parameters: +| Phone mode | Behavior | +| --- | --- | +| `Unique = true` | Every phone item receives its own IMEI. Settings, apps, local data, linked account, and SIM move with the item. The item must not stack. | +| `Unique = false` | Every framework character receives one persistent virtual device. Any configured phone item opens that device. The item may stack. | -- Lock screen: `http://localhost:5174/?apiPort=3002&lockScreenPreview=1` -- SIM picker: `http://localhost:5174/?apiPort=3002&simPickerPreview=1` -- Payphone: `http://localhost:5174/?apiPort=3002&payphonePreview=1` (dial `5551110001` for a connected call or `5550000000` for a busy line) +| SIM mode | Behavior | +| --- | --- | +| `Enabled = true` | A registered or anonymous physical SIM item is required for cellular service. | +| `Enabled = false` | Sky Phone creates a persistent automatic number for devices without a SIM. Physical SIM items are not required. | -Feather can be opened directly with the following browser scenarios: +When changing these modes on an existing production server, restart the resource and test with a copy of the database first. The first phone used after switching to non-unique mode may adopt an existing valid IMEI so its local data is preserved. -- Full data: `http://localhost:5174/?apiPort=3002#/apps/feather` -- Login and registration: `http://localhost:5174/?apiPort=3002&testScenario=feather-login#/apps/feather` -- Profile onboarding: `http://localhost:5174/?apiPort=3002&testScenario=feather-onboarding#/apps/feather` -- Empty states: `http://localhost:5174/?apiPort=3002&testScenario=feather-empty#/apps/feather` +## Database -EasyShare browser data is available from every app that exposes a share action. Open the seeded -Gallery directly, share an item, and then use the EasyShare and History actions in the sheet: +Runtime migrations create and update the Sky Phone schema automatically. -- Full data, including incoming, transferring, accepted, completed, declined, cancelled, expired, - and failed transfers: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-full#/apps/photos` -- Incoming request only: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-incoming#/apps/photos` -- Transfer history without active requests: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-history#/apps/photos` -- Complete content catalog with contact, document, link, location, note, photo, playlist, post, - profile, text, track, and video: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-catalog#/apps/photos` -- Empty nearby and history states: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-empty#/apps/photos` +For hosts that require a manual fresh installation, import: -In the full scenario, sending to Mia or Jamie creates a pending transfer. Sending to Noah creates a -transfer at 58 percent so the progress and cancel states can be tested. Visibility changes and -accepting or declining the seeded incoming request are kept in memory until the mock server restarts. -The catalog also contains source examples from Companies, Mail, Garage, and House; completed history -rows and rich chat cards can be clicked to verify app/deep-link navigation. +```text +sky_phone/sql/install.sql +``` -The full-data scenario includes posts, replies, quotes, media grids, profiles, ranked hashtags, network search results, and every notification type. Run `pnpm test`, `pnpm typecheck`, `pnpm lint`, and `pnpm build` before packaging. +Keep runtime migrations enabled after importing the SQL file because they remain responsible for future upgrades. -`pnpm build` uses `build.cjs` to replace `sky_phone/source/html` deterministically with the Vite output. Production assets use relative paths so they work through the FiveM NUI protocol. +A Sky Cloud account is optional. Devices without an account retain local settings and supported local app data. Linking an account synchronizes supported data across linked devices. + +## Media and uploads + +Configure FiveManage in the server-only media section: + +```lua +Config.Media.FiveManage.ApiKey = "your-fivemanage-v3-media-token" +``` + +Without a valid token: + +- Camera uploads are disabled +- Video uploads are disabled +- Voice Memo uploads are disabled +- FiveManage Gallery imports are unavailable + +Configure GIF search with: + +```lua +Config.Media.GiphyApiKey = "your-giphy-api-key" +``` + +Gallery import websites are configured under `Config.Media.Import.Websites`. Direct URLs are accepted only when their HTTPS hostname matches the configured allowed hosts. + +## Music + +Place server-owned audio files anywhere below: + +```text +sky_phone/config/music/ +``` + +Supported audio formats: + +- OGG +- MP3 + +Optional artwork may use: + +- WEBP +- PNG +- JPG +- JPEG + +Configure each track in `Config.Music.Tracks`: + +```lua +Config.Music.Tracks = { + { + Id = "night-drive", + Title = "Night Drive", + Artist = "Sky Records", + }, +} +``` + +Name the audio and artwork files after the stable track ID, for example: + +```text +night-drive.ogg +night-drive.webp +``` + +Restart Sky Phone after adding files. A frontend rebuild is not required. + +Players may also add public YouTube video links to their personal music library. + +## Voice and Radio + +### Calls + +```lua +Config.Calls.VoiceProvider = "pma" +``` + +Supported values: + +- `pma` or `pma-voice` +- `saltychat` or `salty` + +SaltyChat supports the provider-backed call speaker feature. PMA Voice keeps the speaker option unavailable. + +### Radio + +```lua +Config.Radio.VoiceProvider = "auto" +``` + +Automatic selection checks YACA, PMA Voice, and SaltyChat. Restricted frequency ranges and job access are configured in `Config.Radio.LockedChannels`. + +Radio display-name permissions are configured in `Config.Radio.DisplayName.AllowedJobs`. + +## Payphones + +Sky Phone includes server-authoritative GTA V payphone locations. Pricing, payment account, props, validation distances, and custom locations are configured under `Config.Payphones`. + +Custom location example: + +```lua +Config.Payphones.Locations = { + { model = "prop_phonebox_01a", coords = { x = 123.45, y = 678.90, z = 21.0 } }, +} +``` + +The model must also be listed in `Config.Payphones.Props`. + +## Commands + +| Command | Where | Purpose | +| --- | --- | --- | +| `/phone` | In game | Opens the development phone command when `Config.Phone.DevelopmentCommand` is enabled | +| `/phonetestdata` | In game | Creates customer-scoped test data when `Config.TestData.Enabled` is enabled | +| `/fliptokverify <@handle> [on\|off]` | In game | Toggles or sets FlipTok verification for configured admin groups | +| `/picstagramverify <@handle> ` | In game | Sets Picstagram verification for configured admin groups | +| `skyphone:migrate lb-phone dry` | Server console | Previews the LB Phone migration | +| `skyphone:migrate lb-phone` | Server console | Imports enabled LB Phone domains | +| `skyphone:migrate lb-phone force` | Server console | Re-runs enabled domains idempotently | +| `skyphone:migrate lb-phone remove` | Server console | Removes imported Sky Phone records and migration markers | + +Command names and admin groups for the social apps are configurable. + +Disable test data on production servers: + +```lua +Config.TestData.Enabled = false +``` + +## LB Phone migration + +Sky Phone detects supported LB Phone database tables during startup and prints a short notice. Detection never starts a migration automatically. + +Recommended workflow: + +1. Create a database backup. +2. Run the preview: + `skyphone:migrate lb-phone dry` +3. Review the domain summaries. +4. Run the import: + `skyphone:migrate lb-phone` +5. Restart and verify the migrated accounts and apps. + +The importer: + +- Reads LB Phone source tables without modifying them +- Supports preserved `_lb` tables created by sd-phone migrations +- Records per-domain completion markers +- Can be retried safely with `force` +- Can remove migration-created Sky Phone data with `remove` +- Reports unsupported source records instead of forcing them into incompatible Sky Phone structures + +Supported domains include devices, settings, alarms, contacts, blocked numbers, calls, messages, photos, notes, wallet, voice memos, Picstagram, Mail, map markers, compatible DarkChat data, FlipTok, and Feather. + +The migration command is server-console only. + +## Garage, Housing, and Companies + +### Garage + +Select the provider under `Config.Garage.System`. Vehicle images use the configured CDN template with an icon fallback when no image is available. + +### Housing + +Select the provider under `Config.Housing.System`. Automatic mode supports the configured provider priority. + +### Companies + +Company jobs, public profiles, service numbers, services, permissions, locations, and default availability are configured under `Config.Companies.Definitions`. + +### Weazel News + +Configure editorial jobs and minimum grades: + +```lua +Config.WeazelNews.AllowedJobs = { + weazel = 0, + reporter = 2, +} +``` + +Unlisted jobs can read news but cannot manage articles. + +## External custom apps + +Sky Phone includes compatibility adapters for supported custom-app contracts from: + +- LB Phone +- 17Movement +- High Phone +- Quasar Smartphone +- YSeries + +The resource provides the compatibility aliases `lb-phone`, `17mov_Phone`, `high-phone`, `qs-smartphone`, and `yseries`. + +See [Custom App Integration](docs/custom-apps.md) for setup details and compatibility limits. + +## Updating Sky Phone + +Before updating: + +1. Back up the database. +2. Back up `config/config.lua` and any custom media. +3. Keep the three pepper values unchanged. +4. Replace the resource files. +5. Reapply customer-specific configuration carefully. +6. Restart Sky Phone and review warnings and errors. + +Do not overwrite a production configuration without comparing it to the new version. + +## Frontend development + +Customers installing a release do not need to build the frontend. + +For development: + +```powershell +cd frontend +pnpm install +pnpm dev +``` + +Create a production frontend build with: + +```powershell +pnpm build +``` + +The production output is written to `sky_phone/source/html`. + +Useful checks: + +```powershell +pnpm typecheck +pnpm test +pnpm lint +pnpm build +``` + +## Troubleshooting + +### The phone item does nothing + +- Confirm the framework and inventory are supported and started first. +- Confirm the item name matches `Config.Phone.Item`. +- Confirm the item is usable. +- In unique mode, confirm the phone is non-stackable. +- Check the server console for inventory adapter warnings. + +### Calls connect without audio + +- Confirm the configured voice resource is running. +- Confirm `Config.Calls.VoiceProvider` matches the installed provider. +- Start the voice resource before Sky Phone. + +### Camera or Voice Memos cannot upload + +- Configure a valid FiveManage V3 Media API token. +- Confirm the token has the required file permissions. +- Restart Sky Phone after changing the token. + +### Social app password or passcode warnings appear + +- Check the values in `Config.Server`. +- Use long, stable values. +- Do not change them after accounts or passcodes have been created. + +### The LB Phone notice appears + +This is only a detection notice. No data is imported automatically. Run the `dry` command from the server console when you are ready. + +### More diagnostic output is needed + +Enable: + +```lua +Config.Bridge.Debug = true +``` + +Reproduce the problem, collect the relevant server and client console lines, and disable debug mode again afterward. + +## Credits and notices + +Third-party acknowledgements and license information are available in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). diff --git a/frontend/src/utils/gameView.ts b/frontend/src/utils/gameView.ts index db771be..9feb503 100644 --- a/frontend/src/utils/gameView.ts +++ b/frontend/src/utils/gameView.ts @@ -256,7 +256,6 @@ export function createGameView( initializeResources() lost = false applySize() - console.info('[Camera] Game-view WebGL context restored.') options.onContextRestored?.() } catch (error) { lost = true diff --git a/sky_phone/config/companies.lua b/sky_phone/config/companies.lua deleted file mode 100644 index e4fd99a..0000000 --- a/sky_phone/config/companies.lua +++ /dev/null @@ -1,253 +0,0 @@ -Config.Companies = { - Enabled = true, - PageSize = 20, - MaximumPageSize = 50, - MaximumOpenRequestsPerSim = 5, - MaximumServices = 25, - MaximumRequestMedia = 3, - SubjectMaxLength = 120, - RequestBodyMaxLength = 2000, - MessageMaxLength = 2000, - ProfileDescriptionMaxLength = 1000, - DistrictMaxLength = 80, - AddressMaxLength = 160, - ServiceTitleMaxLength = 80, - ServiceDescriptionMaxLength = 500, - ServicePriceMaxLength = 80, - AnnouncementTitleMaxLength = 120, - AnnouncementBodyMaxLength = 1000, - AvailabilityMaximumSeconds = 24 * 60 * 60, - AnnouncementMaximumSeconds = 30 * 24 * 60 * 60, - RetentionDays = 180, - RateLimits = { - Read = 120, - Search = 60, - CreateRequest = 5, - Message = 30, - RequestAction = 30, - Profile = 12, - CallAvailability = 30, - }, - CallRouting = { - MaxAttempts = 3, - RingSeconds = 10, - }, - Categories = { - "public_services", - "vehicles", - "transport", - }, - Statuses = { - new = true, - assigned = true, - in_progress = true, - waiting_customer = true, - completed = true, - cancelled = true, - }, - AvailabilityStatuses = { - available = true, - busy = true, - closed = true, - }, - Definitions = { - police = { - Job = "police", - Name = "Los Santos Police Department", - Category = "public_services", - Public = true, - Emergency = true, - Verified = true, - Icon = "shield", - LogoUrl = "https://picsum.photos/seed/companies-police-logo/180/180", - Description = "Public safety, emergency response, and police services.", - DefaultAvailability = "closed", - AcceptsRequests = false, - District = "Mission Row", - LocationLabel = "Mission Row Police Station", - Address = "Mission Row Police Station", - Location = vector3(425.1, -979.5, 30.7), - ServiceLine = { - Number = "911", - AutoContact = true, - CanCall = true, - CanMessage = false, - Routing = "round_robin", - MinimumGrade = 0, - }, - Permissions = { - WorkQueue = 0, - Availability = 1, - Assign = 2, - Profile = 3, - Hours = 3, - Services = 3, - Announcement = 3, - }, - Services = {}, - }, - ambulance = { - Job = "ambulance", - Name = "Emergency Medical Services", - Category = "public_services", - Public = true, - Emergency = true, - Verified = true, - Icon = "medical", - LogoUrl = "https://picsum.photos/seed/companies-ems-logo/180/180", - Description = "Emergency medical response and patient care.", - DefaultAvailability = "closed", - AcceptsRequests = false, - District = "Pillbox Hill", - LocationLabel = "Pillbox Hill Medical Center", - Address = "Pillbox Hill Medical Center", - Location = vector3(307.2, -595.3, 43.3), - ServiceLine = { - Number = "912", - AutoContact = true, - CanCall = true, - CanMessage = false, - Routing = "round_robin", - MinimumGrade = 0, - }, - Permissions = { - WorkQueue = 0, - Availability = 1, - Assign = 2, - Profile = 3, - Hours = 3, - Services = 3, - Announcement = 3, - }, - Services = {}, - }, - fire = { - Job = "fire", - Name = "Los Santos Fire Department", - Category = "public_services", - Public = true, - Emergency = true, - Verified = true, - Icon = "flame", - LogoUrl = "https://picsum.photos/seed/companies-fire-logo/180/180", - Description = "Fire response, rescue, and public safety services.", - DefaultAvailability = "closed", - AcceptsRequests = false, - District = "El Burro Heights", - LocationLabel = "Capital Boulevard Fire Station", - Address = "Capital Boulevard Fire Station", - Location = vector3(1200.6, -1473.6, 34.9), - ServiceLine = { - Number = "913", - AutoContact = true, - CanCall = true, - CanMessage = false, - Routing = "round_robin", - MinimumGrade = 0, - }, - Permissions = { - WorkQueue = 0, - Availability = 1, - Assign = 2, - Profile = 3, - Hours = 3, - Services = 3, - Announcement = 3, - }, - Services = {}, - }, - mechanic = { - Job = "mechanic", - Name = "Los Santos Customs", - Category = "vehicles", - Public = true, - Emergency = false, - Verified = true, - Icon = "wrench", - LogoUrl = "https://picsum.photos/seed/companies-mechanic-logo/180/180", - Description = "Vehicle diagnostics, repairs, and roadside assistance.", - DefaultAvailability = "closed", - AcceptsRequests = true, - District = "Burton", - LocationLabel = "Los Santos Customs", - Address = "Carcer Way", - Location = vector3(-337.3, -136.9, 39.0), - ServiceLine = { - Number = "5550101000", - AutoContact = true, - CanCall = true, - CanMessage = false, - Routing = "round_robin", - MinimumGrade = 0, - }, - Permissions = { - WorkQueue = 0, - Availability = 1, - Assign = 2, - Profile = 3, - Hours = 3, - Services = 3, - Announcement = 3, - }, - Services = { - { - Id = "mechanic-repair", - Title = "Vehicle repair", - Description = "Diagnostics and repairs for road vehicles.", - Price = "Price after inspection", - RequestsEnabled = true, - }, - { - Id = "mechanic-towing", - Title = "Roadside assistance", - Description = "Assistance for disabled vehicles.", - Price = "Price by distance", - RequestsEnabled = true, - }, - }, - }, - taxi = { - Job = "taxi", - Name = "Downtown Cab Co.", - Category = "transport", - Public = true, - Emergency = false, - Verified = true, - Icon = "car", - LogoUrl = "https://picsum.photos/seed/companies-taxi-logo/180/180", - Description = "Staffed taxi rides throughout Los Santos and Blaine County.", - DefaultAvailability = "closed", - AcceptsRequests = true, - District = "East Vinewood", - LocationLabel = "Downtown Cab Co.", - Address = "Tangerine Street", - Location = vector3(895.0, -179.2, 74.7), - ServiceLine = { - Number = "5550102000", - AutoContact = true, - CanCall = true, - CanMessage = false, - Routing = "round_robin", - MinimumGrade = 0, - }, - Permissions = { - WorkQueue = 0, - Availability = 1, - Assign = 2, - Profile = 3, - Hours = 3, - Services = 3, - Announcement = 3, - }, - Services = { - { - Id = "taxi-ride", - Title = "Taxi ride", - Description = "Request a staffed taxi service.", - Price = "Metered fare", - RequestsEnabled = true, - }, - }, - }, - }, -} diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index c857752..44b02a2 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -1,14 +1,24 @@ +--[[ + Sky Phone configuration + + All settings live in this file. Translations remain in config/locales/*.lua. + Sections marked "SERVER ONLY" are guarded with IsDuplicityVersion(), so + passwords, API keys, migration settings, and server-owned locations are not + applied by game clients. + + Keep option names unchanged. Restart sky_phone after editing this file. +]] + +-- ============================================================================= +-- Core, framework and device +-- ============================================================================= + Config.Bridge = { Framework = "auto", -- auto, esx, qbox, qb Inventory = "auto", -- auto, ox, qb, lj, qs, codem, core, mf, smx Locale = "en", CallbackTimeout = 15000, - Debug = false, - DebugLevels = { - info = true, - warn = true, - error = true, - }, + Debug = false, -- true: show debug/info output; warnings and errors are always shown } Config.Command = "phone" @@ -46,7 +56,6 @@ Config.CustomApps = { } Config.Security = { - PasscodePepperConvar = "sky_phone_passcode_pepper", MaximumAttempts = 5, LockSeconds = 30, AttemptsPerMinute = 12, @@ -61,6 +70,10 @@ Config.Sim = { NumberGroups = { 3, 3, 4 }, } +-- ============================================================================= +-- Calls, voice and radio +-- ============================================================================= + Config.Speaker = { Enabled = true, -- global phone and radio speaker controls } @@ -151,6 +164,10 @@ Config.Radio = { }, } +-- ============================================================================= +-- Phone presentation and animations +-- ============================================================================= + Config.Animations = { Enabled = true, PropModel = "prop_npc_phone_02", @@ -185,6 +202,10 @@ Config.Animations = { }, } +-- ============================================================================= +-- Built-in applications +-- ============================================================================= + Config.Messages = { BodyMaxLength = 2000, ConversationScanLimit = 1000, @@ -419,6 +440,10 @@ Config.LocalPages = { CityMarktSharesPerDay = 1, } +-- ============================================================================= +-- Social applications +-- ============================================================================= + Config.FlipTok = { PageSize = 12, CaptionMaxLength = 500, @@ -426,7 +451,6 @@ Config.FlipTok = { BioMaxLength = 160, PasswordMinLength = 8, PasswordMaxLength = 72, - PasswordPepperConvar = "sky_phone_fliptok_password_pepper", MaxVideoDurationMs = 300000, MaxPostMedia = 10, MusicTracks = {}, @@ -447,7 +471,6 @@ Config.Picstagram = { StoryLifetimeSeconds = 24 * 60 * 60, PasswordMinLength = 8, PasswordMaxLength = 72, - PasswordPepperConvar = "sky_phone_picstagram_password_pepper", PostsPerMinute = 6, StoriesPerMinute = 6, CommentsPerMinute = 20, @@ -473,6 +496,10 @@ Config.Feather = { ReportsPerDay = 10, } +-- ============================================================================= +-- Utilities, groups and transport +-- ============================================================================= + Config.MapMarkers = { MaximumMarkers = 50, LabelMaxLength = 40, @@ -610,3 +637,701 @@ Config.SkyRide = { }, }, } + +-- ============================================================================= +-- Server-only configuration +-- ============================================================================= + +if IsDuplicityVersion() then + -- ------------------------------------------------------------------------- + -- Server secrets + -- ------------------------------------------------------------------------- + + Config.Server = { + -- Keep these values private and stable. Changing a pepper invalidates existing app passwords. + PasscodePepper = "f626581802800478346266e66414d8e6f2c28050214a593c25901904c162bffe", + FlipTokPasswordPepper = "a85ea307680f1205a4fda03be8af18ecf7edf16ffc83450b90cac7e41a9719a7", + PicstagramPasswordPepper = "653e41dd19aba5ef750a668ad1886273ba7d0e0420bd5c745748df80a6e22676", + } + + -- ------------------------------------------------------------------------- + -- Server-owned payphone locations + -- ------------------------------------------------------------------------- + + -- Server-owned vanilla payphone positions used for authoritative proximity checks. + -- Generated from DurtyFree/gta-v-data-dumps worldPublicPhones.json at commit b65684e00f689fdec405c5f1055322c802d3c895. + -- Add custom-map booths here; their model must also be listed in Config.Payphones.Props. + Config.Payphones.Locations = { + { model = "prop_phonebox_01a", coords = { x = -1819.2284, y = 796.32294, z = 137.12784 } }, + { model = "prop_phonebox_01a", coords = { x = -1773.0256, y = -503.15234, z = 37.80706 } }, + { model = "prop_phonebox_01a", coords = { x = -1772.2114, y = -504.00488, z = 37.81461 } }, + { model = "prop_phonebox_01a", coords = { x = -1457.3734, y = -148.68604, z = 48.7486 } }, + { model = "prop_phonebox_01a", coords = { x = -1456.838, y = -149.31949, z = 48.68604 } }, + { model = "prop_phonebox_01a", coords = { x = -1456.3501, y = -149.95428, z = 48.61642 } }, + { model = "prop_phonebox_01a", coords = { x = -1438.6545, y = -210.77448, z = 47.10766 } }, + { model = "prop_phonebox_01a", coords = { x = -1418.2983, y = -291.36002, z = 42.96778 } }, + { model = "prop_phonebox_01a", coords = { x = -1417.4769, y = -290.64453, z = 42.93899 } }, + { model = "prop_phonebox_01a", coords = { x = -1416.5211, y = -289.8119, z = 42.90134 } }, + { model = "prop_phonebox_01a", coords = { x = -1318.0975, y = -380.79547, z = 35.73553 } }, + { model = "prop_phonebox_01a", coords = { x = -1316.9534, y = -378.42676, z = 35.74885 } }, + { model = "prop_phonebox_01a", coords = { x = -1316.2688, y = -378.07245, z = 35.73169 } }, + { model = "prop_phonebox_01a", coords = { x = -1315.5924, y = -377.7347, z = 35.72274 } }, + { model = "prop_phonebox_01a", coords = { x = -1261.6484, y = -519.13086, z = 30.83657 } }, + { model = "prop_phonebox_01a", coords = { x = -1260.9482, y = -519.9344, z = 30.75686 } }, + { model = "prop_phonebox_01a", coords = { x = -1260.0995, y = -520.9083, z = 30.66707 } }, + { model = "prop_phonebox_01a", coords = { x = -1121.5917, y = -825.6313, z = 14.94339 } }, + { model = "prop_phonebox_01a", coords = { x = -1120.9409, y = -825.0698, z = 14.98657 } }, + { model = "prop_phonebox_01a", coords = { x = -1120.2446, y = -824.4812, z = 15.06407 } }, + { model = "prop_phonebox_01a", coords = { x = -1079.6154, y = -451.0622, z = 35.6144 } }, + { model = "prop_phonebox_01a", coords = { x = -1079.23, y = -451.8739, z = 35.62138 } }, + { model = "prop_phonebox_01a", coords = { x = -1078.874, y = -452.55182, z = 35.62138 } }, + { model = "prop_phonebox_01a", coords = { x = -956.56256, y = -403.17123, z = 36.81676 } }, + { model = "prop_phonebox_01a", coords = { x = -956.1004, y = -404.09232, z = 36.81755 } }, + { model = "prop_phonebox_01a", coords = { x = -524.4403, y = -300.71704, z = 34.26753 } }, + { model = "prop_phonebox_01a", coords = { x = -523.64453, y = -300.41074, z = 34.26273 } }, + { model = "prop_phonebox_01a", coords = { x = -522.872, y = -300.1134, z = 34.25807 } }, + { model = "prop_phonebox_01a", coords = { x = -449.44238, y = -272.8244, z = 34.93996 } }, + { model = "prop_phonebox_01a", coords = { x = -448.8816, y = -274.12805, z = 34.96191 } }, + { model = "prop_phonebox_01a", coords = { x = -448.36926, y = -275.31906, z = 34.96507 } }, + { model = "prop_phonebox_01a", coords = { x = -329.23917, y = 6224.885, z = 30.47861 } }, + { model = "prop_phonebox_01a", coords = { x = -310.30554, y = 6205.3, z = 30.4465 } }, + { model = "prop_phonebox_01a", coords = { x = -280.64215, y = 6224.2314, z = 30.45544 } }, + { model = "prop_phonebox_01a", coords = { x = -243.25082, y = 279.90717, z = 91.04989 } }, + { model = "prop_phonebox_01a", coords = { x = -234.61494, y = 6176.931, z = 30.43884 } }, + { model = "prop_phonebox_01a", coords = { x = -233.6865, y = 6176.0547, z = 30.43884 } }, + { model = "prop_phonebox_01a", coords = { x = -184.35658, y = 6331.4697, z = 30.48767 } }, + { model = "prop_phonebox_01a", coords = { x = -183.68753, y = 6332.1597, z = 30.48987 } }, + { model = "prop_phonebox_01a", coords = { x = -154.76048, y = 6352.27, z = 30.56079 } }, + { model = "prop_phonebox_01a", coords = { x = -153.88358, y = 6351.417, z = 30.56079 } }, + { model = "prop_phonebox_01a", coords = { x = -119.91727, y = 6287.283, z = 30.45911 } }, + { model = "prop_phonebox_01a", coords = { x = -119.44898, y = 6286.768, z = 30.45911 } }, + { model = "prop_phonebox_01a", coords = { x = -92.08037, y = 6462.644, z = 30.44397 } }, + { model = "prop_phonebox_01a", coords = { x = -90.61212, y = 6464.0566, z = 30.44397 } }, + { model = "prop_phonebox_01a", coords = { x = -46.3855, y = 6511.0156, z = 30.44861 } }, + { model = "prop_phonebox_01a", coords = { x = -25.6331, y = 6495.123, z = 30.48767 } }, + { model = "prop_phonebox_01a", coords = { x = -24.59157, y = 6496.0537, z = 30.48767 } }, + { model = "prop_phonebox_01a", coords = { x = 110.07694, y = -1694.206, z = 28.29156 } }, + { model = "prop_phonebox_01a", coords = { x = 136.9704, y = 196.05042, z = 105.73364 } }, + { model = "prop_phonebox_01a", coords = { x = 137.75732, y = 195.764, z = 105.70988 } }, + { model = "prop_phonebox_01a", coords = { x = 138.48807, y = 195.49808, z = 105.69342 } }, + { model = "prop_phonebox_01a", coords = { x = 173.45892, y = -1547.1165, z = 28.25158 } }, + { model = "prop_phonebox_01a", coords = { x = 215.71725, y = -1783.2102, z = 27.99637 } }, + { model = "prop_phonebox_01a", coords = { x = 215.94612, y = -1518.9603, z = 28.29362 } }, + { model = "prop_phonebox_01a", coords = { x = 228.6216, y = -1545.9579, z = 28.28108 } }, + { model = "prop_phonebox_01a", coords = { x = 229.1375, y = -1545.6771, z = 28.28108 } }, + { model = "prop_phonebox_01a", coords = { x = 295.67847, y = -1360.8624, z = 30.91401 } }, + { model = "prop_phonebox_01a", coords = { x = 296.17984, y = -1360.2825, z = 30.91899 } }, + { model = "prop_phonebox_01a", coords = { x = 532.401, y = -151.79816, z = 56.07613 } }, + { model = "prop_phonebox_01a", coords = { x = 539.5577, y = -166.04846, z = 53.4862 } }, + { model = "prop_phonebox_01a", coords = { x = 812.21716, y = -289.03873, z = 65.46264 } }, + { model = "prop_phonebox_01a", coords = { x = 812.3678, y = -289.84793, z = 65.46264 } }, + { model = "prop_phonebox_01a", coords = { x = 819.023, y = -94.03439, z = 79.57648 } }, + { model = "prop_phonebox_01a", coords = { x = 819.37396, y = -93.47577, z = 79.57648 } }, + { model = "prop_phonebox_01a", coords = { x = 891.8809, y = -140.80609, z = 76.11372 } }, + { model = "prop_phonebox_01a", coords = { x = 963.6167, y = -142.79822, z = 73.46588 } }, + { model = "prop_phonebox_01a", coords = { x = 1079.2015, y = -776.68054, z = 57.25418 } }, + { model = "prop_phonebox_01a", coords = { x = 1156.3375, y = -776.99866, z = 56.58559 } }, + { model = "prop_phonebox_01a", coords = { x = 1159.7463, y = -374.87518, z = 66.51784 } }, + { model = "prop_phonebox_01a", coords = { x = 1166.4825, y = -321.59958, z = 68.25383 } }, + { model = "prop_phonebox_01a", coords = { x = 1169.4127, y = 2702.8025, z = 36.99265 } }, + { model = "prop_phonebox_01a", coords = { x = 1170.3059, y = -455.76053, z = 65.49249 } }, + { model = "prop_phonebox_01a", coords = { x = 1172.7772, y = -297.61606, z = 68.01613 } }, + { model = "prop_phonebox_01a", coords = { x = 1172.8646, y = -298.23972, z = 68.01981 } }, + { model = "prop_phonebox_01a", coords = { x = 1173.8961, y = -421.43643, z = 66.07632 } }, + { model = "prop_phonebox_01a", coords = { x = 1201.426, y = -488.8848, z = 64.67129 } }, + { model = "prop_phonebox_01a", coords = { x = 1222.6125, y = -397.32706, z = 67.32355 } }, + { model = "prop_phonebox_01a", coords = { x = 1801.4076, y = 4597.0137, z = 36.67796 } }, + { model = "prop_phonebox_01a", coords = { x = 2558.9167, y = 367.14368, z = 107.63403 } }, + { model = "prop_phonebox_01b", coords = { x = -1684.4233, y = -266.45306, z = 50.89204 } }, + { model = "prop_phonebox_01b", coords = { x = -1683.9019, y = -265.70874, z = 50.89204 } }, + { model = "prop_phonebox_01b", coords = { x = -1543.9277, y = -433.13232, z = 34.57933 } }, + { model = "prop_phonebox_01b", coords = { x = -1543.0599, y = -432.05966, z = 34.58469 } }, + { model = "prop_phonebox_01b", coords = { x = -1522.4791, y = -407.05118, z = 34.58695 } }, + { model = "prop_phonebox_01b", coords = { x = -1412.1201, y = -383.80542, z = 35.68469 } }, + { model = "prop_phonebox_01b", coords = { x = -1205.1965, y = -1393.6274, z = 3.07721 } }, + { model = "prop_phonebox_01b", coords = { x = -1150.6671, y = -1392.6455, z = 4.11812 } }, + { model = "prop_phonebox_01b", coords = { x = -1142.0598, y = -725.3442, z = 19.77577 } }, + { model = "prop_phonebox_01b", coords = { x = -1080.87, y = -2574.942, z = 12.91528 } }, + { model = "prop_phonebox_01b", coords = { x = -1061.7015, y = -2541.7412, z = 12.91528 } }, + { model = "prop_phonebox_01b", coords = { x = -1061.5474, y = -2541.4744, z = 19.15571 } }, + { model = "prop_phonebox_01b", coords = { x = -1046.9408, y = -2516.175, z = 12.91528 } }, + { model = "prop_phonebox_01b", coords = { x = -1046.8787, y = -2516.0674, z = 19.15571 } }, + { model = "prop_phonebox_01b", coords = { x = -1034.5115, y = -2494.6467, z = 19.15571 } }, + { model = "prop_phonebox_01b", coords = { x = -1024.4517, y = -2477.2227, z = 12.91528 } }, + { model = "prop_phonebox_01b", coords = { x = -765.40045, y = -848.8706, z = 21.11398 } }, + { model = "prop_phonebox_01b", coords = { x = -764.83673, y = -848.8654, z = 21.13071 } }, + { model = "prop_phonebox_01b", coords = { x = -756.90735, y = 5586.8223, z = 35.71351 } }, + { model = "prop_phonebox_01b", coords = { x = -756.90735, y = 5587.636, z = 35.71351 } }, + { model = "prop_phonebox_01b", coords = { x = -745.72156, y = 5558.714, z = 35.71351 } }, + { model = "prop_phonebox_01b", coords = { x = -743.95874, y = 5558.714, z = 35.71351 } }, + { model = "prop_phonebox_01b", coords = { x = -715.921, y = 123.33502, z = 54.99648 } }, + { model = "prop_phonebox_01b", coords = { x = -715.40906, y = 123.65546, z = 55.01274 } }, + { model = "prop_phonebox_01b", coords = { x = -700.7271, y = -916.8699, z = 18.21408 } }, + { model = "prop_phonebox_01b", coords = { x = -700.1226, y = -916.8699, z = 18.21408 } }, + { model = "prop_phonebox_01b", coords = { x = -685.93774, y = -854.8967, z = 22.88396 } }, + { model = "prop_phonebox_01b", coords = { x = -670.3093, y = -819.59973, z = 23.4098 } }, + { model = "prop_phonebox_01b", coords = { x = -669.60815, y = -819.6056, z = 23.42427 } }, + { model = "prop_phonebox_01b", coords = { x = -668.81213, y = -819.6378, z = 23.43811 } }, + { model = "prop_phonebox_01b", coords = { x = -665.19354, y = -670.83777, z = 30.40002 } }, + { model = "prop_phonebox_01b", coords = { x = -664.59607, y = -670.8341, z = 30.40393 } }, + { model = "prop_phonebox_01b", coords = { x = -663.99866, y = -670.83044, z = 30.41797 } }, + { model = "prop_phonebox_01b", coords = { x = -655.2001, y = -859.74493, z = 23.50043 } }, + { model = "prop_phonebox_01b", coords = { x = -654.1605, y = -707.27155, z = 28.40153 } }, + { model = "prop_phonebox_01b", coords = { x = -654.1605, y = -706.51654, z = 28.47383 } }, + { model = "prop_phonebox_01b", coords = { x = -654.1605, y = -705.7615, z = 28.54753 } }, + { model = "prop_phonebox_01b", coords = { x = -611.56744, y = -2237.6104, z = 5.10603 } }, + { model = "prop_phonebox_01b", coords = { x = -530.0182, y = -1286.1543, z = 25.03622 } }, + { model = "prop_phonebox_01b", coords = { x = -529.77765, y = -1285.6444, z = 25.04207 } }, + { model = "prop_phonebox_01b", coords = { x = -529.6009, y = -1286.3458, z = 25.04428 } }, + { model = "prop_phonebox_01b", coords = { x = -529.36365, y = -1285.8345, z = 25.03622 } }, + { model = "prop_phonebox_01b", coords = { x = -468.24023, y = -396.44247, z = 32.8951 } }, + { model = "prop_phonebox_01b", coords = { x = -467.58286, y = -396.50574, z = 32.8951 } }, + { model = "prop_phonebox_01b", coords = { x = -259.3869, y = -604.76556, z = 32.59827 } }, + { model = "prop_phonebox_01b", coords = { x = -259.14352, y = -603.98016, z = 32.65002 } }, + { model = "prop_phonebox_01b", coords = { x = -241.57574, y = -766.2026, z = 31.73654 } }, + { model = "prop_phonebox_01b", coords = { x = -241.29694, y = -765.4682, z = 31.77955 } }, + { model = "prop_phonebox_01b", coords = { x = -239.74597, y = -978.22943, z = 28.26393 } }, + { model = "prop_phonebox_01b", coords = { x = -239.51797, y = -977.59296, z = 28.26393 } }, + { model = "prop_phonebox_01b", coords = { x = -178.84961, y = -52.06338, z = 51.10093 } }, + { model = "prop_phonebox_01b", coords = { x = -177.28662, y = -713.48505, z = 33.39728 } }, + { model = "prop_phonebox_01b", coords = { x = -147.61765, y = -287.15277, z = 39.43044 } }, + { model = "prop_phonebox_01b", coords = { x = -147.37784, y = -286.44186, z = 39.49831 } }, + { model = "prop_phonebox_01b", coords = { x = -73.17541, y = -641.5052, z = 35.24065 } }, + { model = "prop_phonebox_01b", coords = { x = -72.92773, y = -640.8415, z = 35.24065 } }, + { model = "prop_phonebox_01b", coords = { x = -53.45404, y = -94.169, z = 56.7686 } }, + { model = "prop_phonebox_01b", coords = { x = -27.98413, y = -100.90671, z = 56.35694 } }, + { model = "prop_phonebox_01b", coords = { x = -26.48154, y = -110.65947, z = 56.06785 } }, + { model = "prop_phonebox_01b", coords = { x = -8.06136, y = -731.61005, z = 43.22259 } }, + { model = "prop_phonebox_01b", coords = { x = -7.37235, y = -731.8549, z = 43.22768 } }, + { model = "prop_phonebox_01b", coords = { x = 43.99314, y = -680.87616, z = 43.20672 } }, + { model = "prop_phonebox_01b", coords = { x = 44.30204, y = -680.0512, z = 43.20672 } }, + { model = "prop_phonebox_01b", coords = { x = 120.37446, y = -205.12677, z = 53.61985 } }, + { model = "prop_phonebox_01b", coords = { x = 121.18489, y = -205.42413, z = 53.61985 } }, + { model = "prop_phonebox_01b", coords = { x = 129.40686, y = 245.3497, z = 106.42847 } }, + { model = "prop_phonebox_01b", coords = { x = 140.18076, y = -1033.1602, z = 28.34242 } }, + { model = "prop_phonebox_01b", coords = { x = 174.5452, y = -1116.4456, z = 28.28443 } }, + { model = "prop_phonebox_01b", coords = { x = 175.22937, y = -1116.4185, z = 28.28425 } }, + { model = "prop_phonebox_01b", coords = { x = 213.8157, y = -852.6208, z = 29.38956 } }, + { model = "prop_phonebox_01b", coords = { x = 214.44952, y = -852.86725, z = 29.38709 } }, + { model = "prop_phonebox_01b", coords = { x = 233.49872, y = 334.44766, z = 104.52145 } }, + { model = "prop_phonebox_01b", coords = { x = 296.66183, y = -1359.7725, z = 30.92093 } }, + { model = "prop_phonebox_01b", coords = { x = 372.30563, y = -966.37286, z = 28.41298 } }, + { model = "prop_phonebox_01b", coords = { x = 394.25433, y = -799.7535, z = 28.23798 } }, + { model = "prop_phonebox_01b", coords = { x = 394.25433, y = -798.6486, z = 28.23798 } }, + { model = "prop_phonebox_01b", coords = { x = 397.567, y = -921.3287, z = 28.3982 } }, + { model = "prop_phonebox_01b", coords = { x = 397.567, y = -920.658, z = 28.3982 } }, + { model = "prop_phonebox_01b", coords = { x = 415.17245, y = -910.94476, z = 28.3982 } }, + { model = "prop_phonebox_01b", coords = { x = 436.0411, y = 137.20285, z = 99.43968 } }, + { model = "prop_phonebox_01b", coords = { x = 436.83813, y = 136.89954, z = 99.38892 } }, + { model = "prop_phonebox_01b", coords = { x = 439.8047, y = -606.65063, z = 27.69825 } }, + { model = "prop_phonebox_01b", coords = { x = 439.99564, y = -604.67474, z = 27.69747 } }, + { model = "prop_phonebox_01b", coords = { x = 445.3808, y = 3567.5305, z = 32.21765 } }, + { model = "prop_phonebox_01b", coords = { x = 452.6532, y = -612.11816, z = 27.54012 } }, + { model = "prop_phonebox_01b", coords = { x = 452.7997, y = -610.4434, z = 27.5457 } }, + { model = "prop_phonebox_01b", coords = { x = 535.5781, y = 102.93228, z = 95.56698 } }, + { model = "prop_phonebox_01b", coords = { x = 779.83923, y = -1755.3914, z = 28.47611 } }, + { model = "prop_phonebox_01b", coords = { x = 780.2285, y = -1755.4475, z = 28.46564 } }, + { model = "prop_phonebox_01b", coords = { x = 809.3085, y = -1074.9281, z = 27.67919 } }, + { model = "prop_phonebox_01b", coords = { x = 903.52423, y = 3646.1294, z = 31.70571 } }, + { model = "prop_phonebox_01b", coords = { x = 1051.0497, y = 2661.3877, z = 38.52392 } }, + { model = "prop_phonebox_01b", coords = { x = 1181.1997, y = 2703.214, z = 37.1464 } }, + { model = "prop_phonebox_01b", coords = { x = 1206.4275, y = 2647.8894, z = 36.81204 } }, + { model = "prop_phonebox_01b", coords = { x = 1401.1744, y = 3602.0786, z = 34.01619 } }, + { model = "prop_phonebox_01b", coords = { x = 1662.8026, y = 4841.53, z = 41.0313 } }, + { model = "prop_phonebox_01b", coords = { x = 1662.9365, y = 4840.332, z = 41.0313 } }, + { model = "prop_phonebox_01b", coords = { x = 1692.9031, y = 6432.025, z = 31.73361 } }, + { model = "prop_phonebox_01b", coords = { x = 1696.5485, y = 3776.0618, z = 33.71252 } }, + { model = "prop_phonebox_01b", coords = { x = 1696.7177, y = 4790.302, z = 40.89749 } }, + { model = "prop_phonebox_01b", coords = { x = 1860.4072, y = 3696.2563, z = 33.26152 } }, + { model = "prop_phonebox_01b", coords = { x = 1861.0801, y = 3695.0903, z = 33.26152 } }, + { model = "prop_phonebox_01b", coords = { x = 2005.9707, y = 3782.6196, z = 31.15662 } }, + { model = "prop_phonebox_01b", coords = { x = 2006.7942, y = 3783.1003, z = 31.14984 } }, + { model = "prop_phonebox_04", coords = { x = -2969.4265, y = 397.46487, z = 14.10208 } }, + { model = "prop_phonebox_04", coords = { x = -1417.7056, y = -94.50974, z = 51.41046 } }, + { model = "prop_phonebox_04", coords = { x = -1416.8014, y = -94.11159, z = 51.44441 } }, + { model = "prop_phonebox_04", coords = { x = -1415.9122, y = -93.72023, z = 51.49042 } }, + { model = "prop_phonebox_04", coords = { x = -1294.0234, y = -390.34976, z = 35.44277 } }, + { model = "prop_phonebox_04", coords = { x = -1293.4121, y = -391.38864, z = 35.44632 } }, + { model = "prop_phonebox_04", coords = { x = -1241.7491, y = -464.37216, z = 32.537 } }, + { model = "prop_phonebox_04", coords = { x = -1224.2532, y = -322.51794, z = 36.57259 } }, + { model = "prop_phonebox_04", coords = { x = -1223.0624, y = -321.95178, z = 36.59326 } }, + { model = "prop_phonebox_04", coords = { x = -1074.0209, y = -397.75607, z = 35.95449 } }, + { model = "prop_phonebox_04", coords = { x = -1025.3336, y = -216.04681, z = 36.93829 } }, + { model = "prop_phonebox_04", coords = { x = -1023.97375, y = -216.74884, z = 36.9369 } }, + { model = "prop_phonebox_04", coords = { x = -985.19824, y = -414.10977, z = 36.85289 } }, + { model = "prop_phonebox_04", coords = { x = -979.73254, y = -369.2069, z = 36.856 } }, + { model = "prop_phonebox_04", coords = { x = -979.1488, y = -370.3092, z = 36.856 } }, + { model = "prop_phonebox_04", coords = { x = -965.37616, y = -2524.3992, z = 13.00643 } }, + { model = "prop_phonebox_04", coords = { x = -963.65985, y = -247.05435, z = 37.0568 } }, + { model = "prop_phonebox_04", coords = { x = -896.8487, y = -247.80585, z = 39.07844 } }, + { model = "prop_phonebox_04", coords = { x = -865.3409, y = -2528.5645, z = 13.00643 } }, + { model = "prop_phonebox_04", coords = { x = -821.83124, y = -251.49579, z = 36.0627 } }, + { model = "prop_phonebox_04", coords = { x = -821.29346, y = -252.4495, z = 36.05127 } }, + { model = "prop_phonebox_04", coords = { x = -701.46173, y = -371.56976, z = 33.2833 } }, + { model = "prop_phonebox_04", coords = { x = -700.3627, y = -372.04968, z = 33.27078 } }, + { model = "prop_phonebox_04", coords = { x = -619.5328, y = -207.68121, z = 36.3736 } }, + { model = "prop_phonebox_04", coords = { x = -618.975, y = -208.61508, z = 36.35005 } }, + { model = "prop_phonebox_04", coords = { x = -617.05457, y = -422.20978, z = 33.7873 } }, + { model = "prop_phonebox_04", coords = { x = -557.25586, y = -386.67517, z = 34.11347 } }, + { model = "prop_phonebox_04", coords = { x = -556.05286, y = -386.66565, z = 34.11989 } }, + { model = "prop_phonebox_04", coords = { x = -554.8701, y = -386.6563, z = 34.12583 } }, + { model = "prop_phonebox_04", coords = { x = -546.57184, y = -334.10083, z = 34.16116 } }, + { model = "prop_phonebox_04", coords = { x = -544.17737, y = -157.39006, z = 37.53791 } }, + { model = "prop_phonebox_04", coords = { x = -388.49542, y = -321.52948, z = 32.10458 } }, + { model = "prop_phonebox_04", coords = { x = -387.68488, y = -322.21454, z = 32.05279 } }, + { model = "prop_phonebox_04", coords = { x = -360.33978, y = -267.18268, z = 32.73604 } }, + { model = "prop_phonebox_04", coords = { x = -347.059, y = -1490.9738, z = 29.79159 } }, + { model = "prop_phonebox_04", coords = { x = -345.83786, y = -1490.9738, z = 29.7867 } }, + { model = "prop_phonebox_04", coords = { x = -263.0376, y = -766.90546, z = 31.57576 } }, + { model = "prop_phonebox_04", coords = { x = -262.6508, y = -766.04095, z = 31.60592 } }, + { model = "prop_phonebox_04", coords = { x = -213.1738, y = -696.5944, z = 32.80729 } }, + { model = "prop_phonebox_04", coords = { x = -174.76907, y = -674.9272, z = 33.27862 } }, + { model = "prop_phonebox_04", coords = { x = -173.80676, y = -675.35236, z = 33.29762 } }, + { model = "prop_phonebox_04", coords = { x = -138.00961, y = -799.9025, z = 31.10711 } }, + { model = "prop_phonebox_04", coords = { x = -137.64026, y = -798.8024, z = 31.14563 } }, + { model = "prop_phonebox_04", coords = { x = 55.44337, y = -1081.1333, z = 28.45174 } }, + { model = "prop_phonebox_04", coords = { x = 55.90165, y = -1080.282, z = 28.45174 } }, + { model = "prop_phonebox_04", coords = { x = 188.01767, y = -1043.9451, z = 28.32789 } }, + { model = "prop_phonebox_04", coords = { x = 189.79306, y = -1044.5588, z = 28.32789 } }, + { model = "prop_phonebox_04", coords = { x = 298.28317, y = -795.153, z = 28.4778 } }, + { model = "prop_phonebox_04", coords = { x = 298.62607, y = -794.289, z = 28.4778 } }, + { model = "prop_phonebox_04", coords = { x = 347.45938, y = -730.9255, z = 28.28353 } }, + { model = "prop_phonebox_04", coords = { x = 564.7501, y = -1748.7141, z = 28.31245 } }, + { model = "prop_phonebox_04", coords = { x = 653.1738, y = 272.5705, z = 102.29323 } }, + { model = "prop_phonebox_04", coords = { x = 654.2313, y = 271.95996, z = 102.29323 } }, + { model = "prop_phonebox_04", coords = { x = 1214.8445, y = -1385.6348, z = 34.34755 } }, + { model = "prop_phonebox_04", coords = { x = 1214.8445, y = -1384.5386, z = 34.34755 } }, + { model = "prop_phonebox_04", coords = { x = 1662.002, y = 4819.523, z = 41.04535 } }, + { model = "prop_phonebox_04", coords = { x = 1816.4236, y = 3671.6497, z = 33.29268 } }, + { model = "prop_phonebox_04", coords = { x = 1818.1936, y = 3668.7485, z = 33.29268 } }, + { model = "prop_phonebox_04", coords = { x = 2007.0574, y = 3784.7974, z = 31.20895 } }, + } + + -- ------------------------------------------------------------------------- + -- Company directory + -- ------------------------------------------------------------------------- + + Config.Companies = { + Enabled = true, + PageSize = 20, + MaximumPageSize = 50, + MaximumOpenRequestsPerSim = 5, + MaximumServices = 25, + MaximumRequestMedia = 3, + SubjectMaxLength = 120, + RequestBodyMaxLength = 2000, + MessageMaxLength = 2000, + ProfileDescriptionMaxLength = 1000, + DistrictMaxLength = 80, + AddressMaxLength = 160, + ServiceTitleMaxLength = 80, + ServiceDescriptionMaxLength = 500, + ServicePriceMaxLength = 80, + AnnouncementTitleMaxLength = 120, + AnnouncementBodyMaxLength = 1000, + AvailabilityMaximumSeconds = 24 * 60 * 60, + AnnouncementMaximumSeconds = 30 * 24 * 60 * 60, + RetentionDays = 180, + RateLimits = { + Read = 120, + Search = 60, + CreateRequest = 5, + Message = 30, + RequestAction = 30, + Profile = 12, + CallAvailability = 30, + }, + CallRouting = { + MaxAttempts = 3, + RingSeconds = 10, + }, + Categories = { + "public_services", + "vehicles", + "transport", + }, + Statuses = { + new = true, + assigned = true, + in_progress = true, + waiting_customer = true, + completed = true, + cancelled = true, + }, + AvailabilityStatuses = { + available = true, + busy = true, + closed = true, + }, + Definitions = { + police = { + Job = "police", + Name = "Los Santos Police Department", + Category = "public_services", + Public = true, + Emergency = true, + Verified = true, + Icon = "shield", + LogoUrl = "https://picsum.photos/seed/companies-police-logo/180/180", + Description = "Public safety, emergency response, and police services.", + DefaultAvailability = "closed", + AcceptsRequests = false, + District = "Mission Row", + LocationLabel = "Mission Row Police Station", + Address = "Mission Row Police Station", + Location = vector3(425.1, -979.5, 30.7), + ServiceLine = { + Number = "911", + AutoContact = true, + CanCall = true, + CanMessage = false, + Routing = "round_robin", + MinimumGrade = 0, + }, + Permissions = { + WorkQueue = 0, + Availability = 1, + Assign = 2, + Profile = 3, + Hours = 3, + Services = 3, + Announcement = 3, + }, + Services = {}, + }, + ambulance = { + Job = "ambulance", + Name = "Emergency Medical Services", + Category = "public_services", + Public = true, + Emergency = true, + Verified = true, + Icon = "medical", + LogoUrl = "https://picsum.photos/seed/companies-ems-logo/180/180", + Description = "Emergency medical response and patient care.", + DefaultAvailability = "closed", + AcceptsRequests = false, + District = "Pillbox Hill", + LocationLabel = "Pillbox Hill Medical Center", + Address = "Pillbox Hill Medical Center", + Location = vector3(307.2, -595.3, 43.3), + ServiceLine = { + Number = "912", + AutoContact = true, + CanCall = true, + CanMessage = false, + Routing = "round_robin", + MinimumGrade = 0, + }, + Permissions = { + WorkQueue = 0, + Availability = 1, + Assign = 2, + Profile = 3, + Hours = 3, + Services = 3, + Announcement = 3, + }, + Services = {}, + }, + fire = { + Job = "fire", + Name = "Los Santos Fire Department", + Category = "public_services", + Public = true, + Emergency = true, + Verified = true, + Icon = "flame", + LogoUrl = "https://picsum.photos/seed/companies-fire-logo/180/180", + Description = "Fire response, rescue, and public safety services.", + DefaultAvailability = "closed", + AcceptsRequests = false, + District = "El Burro Heights", + LocationLabel = "Capital Boulevard Fire Station", + Address = "Capital Boulevard Fire Station", + Location = vector3(1200.6, -1473.6, 34.9), + ServiceLine = { + Number = "913", + AutoContact = true, + CanCall = true, + CanMessage = false, + Routing = "round_robin", + MinimumGrade = 0, + }, + Permissions = { + WorkQueue = 0, + Availability = 1, + Assign = 2, + Profile = 3, + Hours = 3, + Services = 3, + Announcement = 3, + }, + Services = {}, + }, + mechanic = { + Job = "mechanic", + Name = "Los Santos Customs", + Category = "vehicles", + Public = true, + Emergency = false, + Verified = true, + Icon = "wrench", + LogoUrl = "https://picsum.photos/seed/companies-mechanic-logo/180/180", + Description = "Vehicle diagnostics, repairs, and roadside assistance.", + DefaultAvailability = "closed", + AcceptsRequests = true, + District = "Burton", + LocationLabel = "Los Santos Customs", + Address = "Carcer Way", + Location = vector3(-337.3, -136.9, 39.0), + ServiceLine = { + Number = "5550101000", + AutoContact = true, + CanCall = true, + CanMessage = false, + Routing = "round_robin", + MinimumGrade = 0, + }, + Permissions = { + WorkQueue = 0, + Availability = 1, + Assign = 2, + Profile = 3, + Hours = 3, + Services = 3, + Announcement = 3, + }, + Services = { + { + Id = "mechanic-repair", + Title = "Vehicle repair", + Description = "Diagnostics and repairs for road vehicles.", + Price = "Price after inspection", + RequestsEnabled = true, + }, + { + Id = "mechanic-towing", + Title = "Roadside assistance", + Description = "Assistance for disabled vehicles.", + Price = "Price by distance", + RequestsEnabled = true, + }, + }, + }, + taxi = { + Job = "taxi", + Name = "Downtown Cab Co.", + Category = "transport", + Public = true, + Emergency = false, + Verified = true, + Icon = "car", + LogoUrl = "https://picsum.photos/seed/companies-taxi-logo/180/180", + Description = "Staffed taxi rides throughout Los Santos and Blaine County.", + DefaultAvailability = "closed", + AcceptsRequests = true, + District = "East Vinewood", + LocationLabel = "Downtown Cab Co.", + Address = "Tangerine Street", + Location = vector3(895.0, -179.2, 74.7), + ServiceLine = { + Number = "5550102000", + AutoContact = true, + CanCall = true, + CanMessage = false, + Routing = "round_robin", + MinimumGrade = 0, + }, + Permissions = { + WorkQueue = 0, + Availability = 1, + Assign = 2, + Profile = 3, + Hours = 3, + Services = 3, + Announcement = 3, + }, + Services = { + { + Id = "taxi-ride", + Title = "Taxi ride", + Description = "Request a staffed taxi service.", + Price = "Metered fare", + RequestsEnabled = true, + }, + }, + }, + }, + } + + -- ------------------------------------------------------------------------- + -- Media providers and uploads + -- ------------------------------------------------------------------------- + + Config.Media = { + GiphyApiKey = "", -- Server-only: paste the GIPHY API key here. + GifPageSize = 24, + GifRating = "pg-13", + UrlMaxLength = 2048, + AllowedGifHosts = { "giphy.com" }, + FiveManage = { + ApiKey = "", -- Server-only: paste a newly generated FiveManage V3 Media API token here. + BaseUrl = "https://api.fivemanage.com/api/v3/file", + RequestTimeoutMs = 10000, + UploadTimeoutMs = 25000, + }, + Import = { + Enabled = true, + PageSize = 30, + MaxSelection = 10, + MaxPhotoBytes = 15 * 1024 * 1024, + MaxVideoBytes = 150 * 1024 * 1024, + RevalidateAfterSeconds = 3600, + ListActionsPerMinute = 60, + ImportActionsPerMinute = 20, + CandidateTtlSeconds = 300, + ManifestCacheSeconds = 30, + ManifestMaxBytes = 2 * 1024 * 1024, + ManifestMaxItems = 5000, + Websites = { + { + Id = "fivemanage", + Label = "FiveManage", + Enabled = true, + Adapter = "fivemanage", + Path = "sky_phone/imports", + MediaTypes = { "photo", "video" }, + -- Direct links entered in Gallery must use one of these hosts or a subdomain. + AllowedMediaHosts = { "fivemanage.com" }, + }, + --[[ + { + Id = "city_media", + Label = "City Media", + Enabled = true, + Adapter = "manifest", + ManifestUrl = "https://media.example.com/sky-phone/media.json", + MediaTypes = { "photo", "video" }, + AllowedMediaHosts = { "media.example.com", "cdn.example.com" }, + Auth = { + Type = "bearer", + TokenConvar = "sky_phone_city_media_token", + }, + RequiredAce = "sky_phone.import.city_media", + }, + ]] + }, + }, + Photo = { + Encoding = "jpg", + Quality = 0.95, + }, + Video = { + BitrateKbps = 1500, + }, + UploadSessionTimeoutMs = 60000, + PageSize = 30, + } + + -- ------------------------------------------------------------------------- + -- Music library + -- ------------------------------------------------------------------------- + + Config.Music = { + -- Files are found recursively in config/music by their Id. For example, + -- Id = "night-drive" finds night-drive.ogg/mp3 and optional artwork with + -- the same name (.webp/.png/.jpg/.jpeg), even inside subdirectories. + Tracks = { + -- { + -- Id = "night-drive", + -- Title = "Night Drive", + -- Artist = "Sky Records", + -- }, + }, + + MaximumPersonalSongs = 100, + MaximumPlaylists = 50, + MaximumPlaylistSongs = 250, + PlaylistNameMaxLength = 80, + ActionsPerMinute = 30, + MetadataTimeoutMs = 4500, + } + + -- ------------------------------------------------------------------------- + -- Weazel News + -- ------------------------------------------------------------------------- + + Config.WeazelNews = { + Enabled = true, + PageSize = 20, + MaximumOffset = 10000, + MaximumImages = 6, + SearchMaxLength = 80, + DraftTitleMinLength = 1, + DraftBodyMinLength = 1, + TitleMinLength = 1, + TitleMaxLength = 160, + BodyMinLength = 1, + BodyMaxLength = 12000, + ExcerptMaxLength = 240, + RateLimits = { + Read = 120, + Write = 20, + }, + Categories = { + "official", + "events", + "jobs", + "news", + "business", + }, + -- Framework job name = minimum grade. Every listed job can manage every article. + AllowedJobs = { + weazel = 0, + reporter = 0, + }, + } + + -- ------------------------------------------------------------------------- + -- Database migrations + -- ------------------------------------------------------------------------- + + Config.Migrations = Config.Migrations or {} + + -- Manual, non-destructive import from LB Phone's `phone_*` database tables. + -- The migration never starts during a resource/server restart. Completed + -- domains are recorded in `sky_phone_migrations` for idempotent retries. + -- Import: skyphone:migrate lb-phone + -- Console preview: skyphone:migrate lb-phone dry + -- Idempotent retry: skyphone:migrate lb-phone force + -- Remove imported Sky data: skyphone:migrate lb-phone remove + Config.Migrations.LbPhone = { + SourcePrefix = "phone_", + + -- auto: direct framework identifier first, then an unambiguous license match. + -- identifier: only match phone_phones.owner_id to the framework identifier. + -- license: only resolve phone_phones.owner_id through the framework license. + IdentifierMode = "auto", + + Domains = { + devices = true, + settings = true, + alarms = true, + contacts = true, + blocked = true, + calls = true, + messages = true, + photos = true, + notes = true, + wallet = true, + voiceMemos = true, + picstagram = true, + mail = true, + mapMarkers = true, + darkChat = true, + flipTok = true, + feather = true, + }, + } +end diff --git a/sky_phone/config/locales/de.lua b/sky_phone/config/locales/de.lua new file mode 100644 index 0000000..26e55c0 --- /dev/null +++ b/sky_phone/config/locales/de.lua @@ -0,0 +1,433 @@ +-- German locale. It starts from the complete English locale so newly added keys +-- always have a safe fallback, then translates shared phrases and core screens. + +local function clone(value) + if type(value) ~= "table" then + return value + end + + local copied = {} + for key, nested in pairs(value) do + copied[key] = clone(nested) + end + return copied +end + +local translations = { + ["Add"] = "Hinzufügen", + ["All"] = "Alle", + ["Back"] = "Zurück", + ["Cancel"] = "Abbrechen", + ["Clear"] = "Leeren", + ["Clear All"] = "Alle löschen", + ["Close"] = "Schließen", + ["Continue"] = "Weiter", + ["Create"] = "Erstellen", + ["Delete"] = "Löschen", + ["Done"] = "Fertig", + ["Edit"] = "Bearbeiten", + ["Home"] = "Home", + ["Loading"] = "Lädt", + ["Loading..."] = "Wird geladen...", + ["More"] = "Mehr", + ["Next"] = "Weiter", + ["No"] = "Nein", + ["Open"] = "Öffnen", + ["Pause"] = "Pause", + ["Previous"] = "Zurück", + ["Remove"] = "Entfernen", + ["Reset"] = "Zurücksetzen", + ["Retry"] = "Erneut versuchen", + ["Save"] = "Speichern", + ["Search"] = "Suchen", + ["Select"] = "Auswählen", + ["Send"] = "Senden", + ["Share"] = "Teilen", + ["Start"] = "Starten", + ["Stop"] = "Stoppen", + ["Use"] = "Verwenden", + ["Yes"] = "Ja", + ["Today"] = "Heute", + ["Yesterday"] = "Gestern", + ["Now"] = "Jetzt", + ["now"] = "jetzt", + ["Name"] = "Name", + ["Title"] = "Titel", + ["Description"] = "Beschreibung", + ["Message"] = "Nachricht", + ["Messages"] = "Nachrichten", + ["New Message"] = "Neue Nachricht", + ["Call"] = "Anrufen", + ["Calls"] = "Anrufe", + ["Contacts"] = "Kontakte", + ["Recents"] = "Anrufliste", + ["Favorites"] = "Favoriten", + ["Keypad"] = "Tastatur", + ["Phone"] = "Telefon", + ["Phone Number"] = "Telefonnummer", + ["Unknown"] = "Unbekannt", + ["Incoming"] = "Eingehend", + ["Outgoing"] = "Ausgehend", + ["Missed"] = "Verpasst", + ["Connected"] = "Verbunden", + ["Disconnected"] = "Getrennt", + ["Online"] = "Online", + ["Offline"] = "Offline", + ["Available"] = "Verfügbar", + ["Unavailable"] = "Nicht verfügbar", + ["Enabled"] = "Aktiviert", + ["Disabled"] = "Deaktiviert", + ["Public"] = "Öffentlich", + ["Private"] = "Privat", + ["Everyone"] = "Alle", + ["Nobody"] = "Niemand", + ["Settings"] = "Einstellungen", + ["General"] = "Allgemein", + ["Appearance"] = "Darstellung", + ["Notifications"] = "Mitteilungen", + ["Sounds"] = "Töne", + ["Language"] = "Sprache", + ["About"] = "Info", + ["Account"] = "Account", + ["Password"] = "Passwort", + ["Email"] = "E-Mail", + ["Login"] = "Anmelden", + ["Register"] = "Registrieren", + ["Sign In"] = "Anmelden", + ["Sign Out"] = "Abmelden", + ["Create Account"] = "Account erstellen", + ["Profile"] = "Profil", + ["Edit Profile"] = "Profil bearbeiten", + ["Share Profile"] = "Profil teilen", + ["Followers"] = "Follower", + ["Following"] = "Gefolgt", + ["Follow"] = "Folgen", + ["Unfollow"] = "Entfolgen", + ["Posts"] = "Beiträge", + ["Comments"] = "Kommentare", + ["Likes"] = "Gefällt mir", + ["Activity"] = "Aktivität", + ["Discover"] = "Entdecken", + ["Create Post"] = "Beitrag erstellen", + ["Report"] = "Melden", + ["Block"] = "Blockieren", + ["Unblock"] = "Entsperren", + ["Photo"] = "Foto", + ["Photos"] = "Fotos", + ["Video"] = "Video", + ["Videos"] = "Videos", + ["Camera"] = "Kamera", + ["Gallery"] = "Galerie", + ["Albums"] = "Alben", + ["Health"] = "Gesundheit", + ["Medical ID"] = "Notfallpass", + ["Steps"] = "Schritte", + ["Distance"] = "Distanz", + ["Energy"] = "Energie", + ["Trends"] = "Trends", + ["This week"] = "Diese Woche", + ["Week"] = "Woche", + ["Month"] = "Monat", + ["Calendar"] = "Kalender", + ["Clock"] = "Uhr", + ["Alarm"] = "Wecker", + ["Stopwatch"] = "Stoppuhr", + ["Timer"] = "Timer", + ["Weather"] = "Wetter", + ["Maps"] = "Karten", + ["Music"] = "Musik", + ["Notes"] = "Notizen", + ["Voice Memos"] = "Sprachmemos", + ["Calculator"] = "Rechner", + ["Mail"] = "Mail", + ["Inbox"] = "Posteingang", + ["Sent"] = "Gesendet", + ["Drafts"] = "Entwürfe", + ["Trash"] = "Papierkorb", + ["Banking"] = "Banking", + ["Balance"] = "Kontostand", + ["Transactions"] = "Transaktionen", + ["Transfer"] = "Überweisen", + ["Amount"] = "Betrag", + ["Billing"] = "Rechnungen", + ["Garage"] = "Garage", + ["Parked"] = "Geparkt", + ["Outside"] = "Draußen", + ["Location"] = "Standort", + ["Vehicles"] = "Fahrzeuge", + ["Radio"] = "Funk", + ["Frequency"] = "Frequenz", + ["Volume"] = "Lautstärke", + ["Flashlight"] = "Taschenlampe", + ["Brightness"] = "Helligkeit", + ["Airplane Mode"] = "Flugmodus", + ["Cellular Data"] = "Mobile Daten", + ["Control Center"] = "Kontrollzentrum", + ["Lock Screen"] = "Sperrbildschirm", + ["App Library"] = "App-Mediathek", + ["All Apps"] = "Alle Apps", + ["Apps"] = "Apps", + ["Games"] = "Spiele", + ["Productivity"] = "Produktivität", + ["Shopping"] = "Shopping", + ["Social Networks"] = "Soziale Netzwerke", + ["Utilities"] = "Dienstprogramme", + ["Widgets"] = "Widgets", + ["Small"] = "Klein", + ["Medium"] = "Mittel", + ["Large"] = "Groß", + ["Error"] = "Fehler", + ["Success"] = "Erfolgreich", + ["Request failed"] = "Anfrage fehlgeschlagen", + ["Try Again"] = "Erneut versuchen", + ["No results"] = "Keine Ergebnisse", + ["No results found"] = "Keine Ergebnisse gefunden", + ["No data available"] = "Keine Daten verfügbar", + ["Required"] = "Erforderlich", + ["Optional"] = "Optional", +} + +local function translate_shared(value) + if type(value) ~= "table" then + return translations[value] or value + end + + for key, nested in pairs(value) do + value[key] = translate_shared(nested) + end + return value +end + +local function merge(target, values) + for key, value in pairs(values) do + if type(value) == "table" and type(target[key]) == "table" then + merge(target[key], value) + else + target[key] = value + end + end +end + +local german = translate_shared(clone(Locales["en"] or {})) + +merge(german, { + CommandDescription = "Öffne dein Handy.", + TestData = { + CommandDescription = "Testinhalte für alle datenbasierten Handy-Apps erstellen oder aktualisieren.", + Success = "Die Testinhalte sind bereit. Deine Sky-Cloud-Anmeldung lautet {email}. Öffne das Handy erneut, um alle Apps zu aktualisieren.", + Failed = "Die Testinhalte konnten nicht erstellt werden. Prüfe die Serverkonsole.", + }, + FlipTokCommand = { + usage = "Verwendung: /{command} <@name> [on|off]", + noPermission = "Du darfst die FlipTok-Verifizierung nicht verwalten.", + notFound = "Das FlipTok-Profil @{handle} wurde nicht gefunden.", + updated = "FlipTok @{handle} ist jetzt {state}.", + verified = "verifiziert", + unverified = "nicht verifiziert", + }, + PicstagramCommand = { + usage = "Verwendung: /{command} <@name> ", + noPermission = "Du darfst die Picstagram-Verifizierung nicht verwalten.", + notFound = "Das Picstagram-Profil @{handle} wurde nicht gefunden.", + updated = "Picstagram @{handle} ist jetzt {state}.", + verified = "verifiziert", + unverified = "nicht verifiziert", + }, + DeviceErrors = { + phone_slot_missing = "Das verwendete Handy konnte nicht erkannt werden. Stelle sicher, dass Handys nicht gestapelt sind.", + phone_stacked = "Handys dürfen nicht gestapelt werden.", + invalid_imei = "Dieses Handy besitzt ungültige IMEI-Metadaten.", + metadata_unsupported = "Das konfigurierte Inventar unterstützt keine eindeutigen Handy-Metadaten.", + player_unavailable = "Deine Charakteridentität ist nicht verfügbar.", + sim_slot_missing = "Die verwendete SIM-Karte konnte nicht erkannt werden.", + sim_stacked = "SIM-Karten dürfen nicht gestapelt werden.", + invalid_sim = "Diese SIM-Karte besitzt ungültige Metadaten.", + phone_required = "Du benötigst ein Handy in deinem Inventar.", + operation_in_progress = "Eine andere Handy-Aktion wird bereits ausgeführt.", + voice_unavailable = "Der konfigurierte Sprachdienst ist nicht verfügbar.", + default = "Das Handy konnte nicht geöffnet werden.", + }, + Payphone = { + Interact = "Münztelefon verwenden.", + RingingHelp = "Anruf an {number}... | Auflegen", + ConnectedHelp = "Verbunden mit {number} | {duration} | {currency}{cost} | Auflegen", + }, + Nui = { + Payphone = { + title = "LOS SANTOS MÜNZTELEFON", + subtitle = "ÖFFENTLICHES TELEFON", + numberLabel = "RUFNUMMER", + numberPlaceholder = "Telefonnummer eingeben", + rate = "{currency}{price} / SEK.", + ready = "BEREIT", + dialing = "WIRD GEWÄHLT", + ringing = "KLINGELT", + connected = "VERBUNDEN", + callEnded = "ANRUF BEENDET", + unavailable = "NUMMER NICHT ERREICHBAR", + busy = "LEITUNG BESETZT", + noAnswer = "KEINE ANTWORT", + declined = "ANRUF ABGELEHNT", + disconnected = "VERBINDUNG GETRENNT", + insufficientFunds = "NICHT GENUG GELD", + invalidNumber = "GÜLTIGE NUMMER EINGEBEN", + requestFailed = "ANRUF KONNTE NICHT GESTARTET WERDEN", + voiceUnavailable = "SPRACHDIENST NICHT VERFÜGBAR", + call = "ANRUFEN", + hangup = "AUFLEGEN", + close = "Münztelefon schließen", + delete = "Ziffer löschen", + clear = "Nummer leeren", + keypad = "Wähltastatur", + elapsed = "ZEIT", + cost = "KOSTEN", + }, + Setup = { + title = "Sky Phone Einrichtungsassistent", + ownerFallback = "Sky-Phone-Besitzer", + getStarted = "Loslegen", + setUpLater = "Später einrichten", + development = { skip = "Einrichtung überspringen" }, + welcome = { + title = "Willkommen bei Sky Phone", + eyebrow = "Für dich entwickelt", + body = "Hallo, {name}. Machen wir dieses Handy unverwechselbar zu deinem.", + private = "Privatsphäre inklusive", + personal = "Für dich gemacht", + }, + connection = { + eyebrow = "Mobilfunk", + title = "Deine Verbindung ist bereit", + body = "Sky Phone erkennt automatisch die in diesem Gerät eingelegte SIM-Karte.", + noSim = "Keine SIM eingelegt", + ready = "Mit dem Sky-Netz verbunden", + offline = "Das Handy funktioniert offline; Anrufe und Nachrichten benötigen eine SIM.", + preserved = "Deine Rufnummer und SIM-basierten Unterhaltungen bleiben auf der SIM, auch wenn dieses Handy gelöscht wird.", + }, + cloud = { + eyebrow = "Sky Cloud", + title = "Wichtige Daten immer dabei", + body = "Melde dich an, um unterstützte App-Daten zu synchronisieren und auf einem anderen Sky Phone wiederherzustellen.", + signIn = "Anmelden", + create = "Account erstellen", + email = "Sky-Cloud-E-Mail", + password = "Passwort", + connected = "Sky Cloud verbunden", + signInTitle = "Bei Sky Cloud anmelden", + createTitle = "Sky-Cloud-Account erstellen", + confirmPassword = "Passwort bestätigen", + passwordMismatch = "Die Passwörter stimmen nicht überein.", + signInAction = "Sicher anmelden", + createAction = "Sky-Cloud-Account erstellen", + invalid = "Gib eine gültige E-Mail-Adresse und ein Passwort mit mindestens 6 Zeichen ein.", + }, + security = { + eyebrow = "Datenschutz & Sicherheit", + title = "Dieses Handy schützen", + body = "Wähle einen vier- oder sechsstelligen Code, um Apps und lokale Daten zu schützen.", + create = "Code erstellen", + createSelected = "{count}-stelligen Code erstellen", + lengthTitle = "Codelänge auswählen", + fourDigit = "4-stelliger Code", + sixDigit = "6-stelliger Code", + enter = "Code erstellen", + confirm = "Code bestätigen", + mismatch = "Die Codes stimmen nicht überein. Versuche es erneut.", + failed = "Der Code konnte nicht gespeichert werden.", + }, + appearance = { + eyebrow = "Anzeige", + title = "Darstellung auswählen", + body = "Automatisch folgt deinem System. Du kannst dies jederzeit in den Einstellungen ändern.", + }, + performance = { + eyebrow = "Leistung", + title = "Wähle, wie sich dein Handy anfühlt", + body = "Stimme Reaktionsgeschwindigkeit und visuelle Effekte auf dein FiveM-System ab.", + performance = "Schnellste Reaktion mit optimiertem Glas ohne Unschärfe.", + ultimate = "Volle Tiefe, Live-Unschärfe und das komplette Glaserlebnis.", + changeLater = "Du kannst den Modus später unter Einstellungen › Darstellung ändern.", + }, + wallpaper = { + eyebrow = "Personalisieren", + title = "Mach es zu deinem", + body = "Wähle einen der zwölf Sky-Phone-Hintergründe.", + }, + notifications = { + eyebrow = "Bleib informiert", + title = "Mitteilungen nach deinen Wünschen", + body = "Lege fest, wie Apps dich erreichen dürfen. Wichtige Wecker und Anrufe bleiben verfügbar.", + allow = "App-Mitteilungen erlauben", + allowBody = "Banner, Sperrbildschirm-Updates und Kennzeichen anzeigen.", + sounds = "Mitteilungstöne", + soundsBody = "Bei neuen Mitteilungen einen dezenten Ton abspielen.", + }, + ready = { + eyebrow = "Einrichtung abgeschlossen", + title = "Willkommen, {name}", + body = "Dein Sky Phone ist eingerichtet und bereit. Du kannst alles später in den Einstellungen anpassen.", + localOnly = "Auf diesem Handy gespeichert", + enter = "Sky Phone öffnen", + review = "Einrichtung prüfen", + }, + }, + Common = { + add = "Hinzufügen", back = "Zurück", cancel = "Abbrechen", clear = "Leeren", + close = "Schließen", continue = "Weiter", delete = "Löschen", done = "Fertig", + edit = "Bearbeiten", home = "Home", loading = "Wird geladen", pause = "Pause", + use = "Verwenden", phone = "Telefon", phoneStatus = "Telefonstatus", reset = "Zurücksetzen", + save = "Speichern", search = "Suchen", send = "Senden", start = "Starten", stop = "Stoppen", + signOut = "Abmelden", signingOut = "Wird abgemeldet...", signOutTitle = "Von {app} abmelden?", + signOutBody = "Du wirst nur von {app} abgemeldet. Deine anderen iFruit-Apps bleiben angemeldet.", + signOutFailed = "Abmelden fehlgeschlagen. Versuche es erneut.", + appAuth = { + eyebrow = "iFruit-Account", + title = "Weiter zu {app}", + body = "Verwende deine iFruit-E-Mail und dein Passwort. Diese Anmeldung gilt nur für {app}.", + login = "Anmelden", register = "Registrieren", email = "iFruit-E-Mail", password = "Passwort", + confirm = "Passwort bestätigen", loginAction = "Anmelden", registerAction = "Account erstellen", + }, + }, + ControlCenter = { + airplaneMode = "Flugmodus", brightness = "Helligkeit", calculator = "Rechner", + camera = "Kamera", cellular = "Mobile Daten", close = "Kontrollzentrum schließen", + easyShareContact = "Meinen Kontakt mit Spielern in der Nähe teilen", flashlight = "Taschenlampe", + focus = "Fokus", label = "Kontrollzentrum", media = "Medien", next = "Nächster Titel", + notPlaying = "Keine Wiedergabe", open = "Kontrollzentrum öffnen", play = "Wiedergabe", + previous = "Vorheriger Titel", quickActions = "Schnellaktionen", timer = "Timer", + volume = "Lautstärke", wifi = "WLAN", + }, + Notifications = { + clearAll = "Alle löschen", clear = "Löschen", now = "jetzt", open = "Mitteilung öffnen", + }, + LockScreen = { + label = "Sperrbildschirm", flashlight = "Taschenlampe", camera = "Kamera", + swipeUp = "Zum Öffnen nach oben wischen", + passcode = { + enter = "Code eingeben", unlockSubtitle = "Gib den Code für dieses Handy ein.", + cancel = "Abbrechen", delete = "Ziffer löschen", incorrect = "Falscher Code", + locked = "Zu viele Versuche. In {seconds} Sekunden erneut versuchen.", + tryAgain = "In {seconds} Sekunden erneut versuchen", + rateLimited = "Zu viele Versuche. Bitte warte.", + }, + }, + Home = { + appLibrary = "App-Mediathek", appLibrarySearch = "Apps suchen", allApps = "Alle Apps", + apps = "Apps", dock = "Dock", noApps = "Keine Apps gefunden", + removeApp = "{app} vom Home-Bildschirm entfernen", addToHome = "{app} zum Home-Bildschirm hinzufügen", + addPage = "Home-Bildschirmseite hinzufügen", deletePage = "Aktuelle Home-Bildschirmseite löschen", + removedFromHome = "Vom Home-Bildschirm entfernt", page = "Seite", pages = "Home-Bildschirmseiten", + folders = { + defaultName = "Ordner", close = "Ordner schließen", rename = "Ordner umbenennen", + name = "Ordnername", pages = "Ordnerseiten", + }, + groups = { + suggestions = "Vorschläge", recentlyAdded = "Zuletzt hinzugefügt", games = "Spiele", + productivity = "Produktivität", shopping = "Shopping", social = "Soziale Netzwerke", + utilities = "Dienstprogramme", + }, + }, + }, +}) + +Locales["de"] = german diff --git a/sky_phone/config/media.lua b/sky_phone/config/media.lua deleted file mode 100644 index 23fb357..0000000 --- a/sky_phone/config/media.lua +++ /dev/null @@ -1,64 +0,0 @@ -Config.Media = { - GiphyApiKey = "", -- Server-only: paste the GIPHY API key here. - GifPageSize = 24, - GifRating = "pg-13", - UrlMaxLength = 2048, - AllowedGifHosts = { "giphy.com" }, - FiveManage = { - ApiKey = "", -- Server-only: paste a newly generated FiveManage V3 Media API token here. - BaseUrl = "https://api.fivemanage.com/api/v3/file", - RequestTimeoutMs = 10000, - UploadTimeoutMs = 25000, - }, - Import = { - Enabled = true, - PageSize = 30, - MaxSelection = 10, - MaxPhotoBytes = 15 * 1024 * 1024, - MaxVideoBytes = 150 * 1024 * 1024, - RevalidateAfterSeconds = 3600, - ListActionsPerMinute = 60, - ImportActionsPerMinute = 20, - CandidateTtlSeconds = 300, - ManifestCacheSeconds = 30, - ManifestMaxBytes = 2 * 1024 * 1024, - ManifestMaxItems = 5000, - Websites = { - { - Id = "fivemanage", - Label = "FiveManage", - Enabled = true, - Adapter = "fivemanage", - Path = "sky_phone/imports", - MediaTypes = { "photo", "video" }, - -- Direct links entered in Gallery must use one of these hosts or a subdomain. - AllowedMediaHosts = { "fivemanage.com" }, - }, - --[[ - { - Id = "city_media", - Label = "City Media", - Enabled = true, - Adapter = "manifest", - ManifestUrl = "https://media.example.com/sky-phone/media.json", - MediaTypes = { "photo", "video" }, - AllowedMediaHosts = { "media.example.com", "cdn.example.com" }, - Auth = { - Type = "bearer", - TokenConvar = "sky_phone_city_media_token", - }, - RequiredAce = "sky_phone.import.city_media", - }, - ]] - }, - }, - Photo = { - Encoding = "jpg", - Quality = 0.95, - }, - Video = { - BitrateKbps = 1500, - }, - UploadSessionTimeoutMs = 60000, - PageSize = 30, -} diff --git a/sky_phone/config/music.lua b/sky_phone/config/music.lua deleted file mode 100644 index 734848b..0000000 --- a/sky_phone/config/music.lua +++ /dev/null @@ -1,19 +0,0 @@ -Config.Music = { - -- Files are found recursively in config/music by their Id. For example, - -- Id = "night-drive" finds night-drive.ogg/mp3 and optional artwork with - -- the same name (.webp/.png/.jpg/.jpeg), even inside subdirectories. - Tracks = { - -- { - -- Id = "night-drive", - -- Title = "Night Drive", - -- Artist = "Sky Records", - -- }, - }, - - MaximumPersonalSongs = 100, - MaximumPlaylists = 50, - MaximumPlaylistSongs = 250, - PlaylistNameMaxLength = 80, - ActionsPerMinute = 30, - MetadataTimeoutMs = 4500, -} diff --git a/sky_phone/config/payphones.lua b/sky_phone/config/payphones.lua deleted file mode 100644 index e2a3ecd..0000000 --- a/sky_phone/config/payphones.lua +++ /dev/null @@ -1,248 +0,0 @@ --- Server-owned vanilla payphone positions used for authoritative proximity checks. --- Generated from DurtyFree/gta-v-data-dumps worldPublicPhones.json at commit b65684e00f689fdec405c5f1055322c802d3c895. --- Add custom-map booths here; their model must also be listed in Config.Payphones.Props. -Config.Payphones.Locations = { - { model = "prop_phonebox_01a", coords = { x = -1819.2284, y = 796.32294, z = 137.12784 } }, - { model = "prop_phonebox_01a", coords = { x = -1773.0256, y = -503.15234, z = 37.80706 } }, - { model = "prop_phonebox_01a", coords = { x = -1772.2114, y = -504.00488, z = 37.81461 } }, - { model = "prop_phonebox_01a", coords = { x = -1457.3734, y = -148.68604, z = 48.7486 } }, - { model = "prop_phonebox_01a", coords = { x = -1456.838, y = -149.31949, z = 48.68604 } }, - { model = "prop_phonebox_01a", coords = { x = -1456.3501, y = -149.95428, z = 48.61642 } }, - { model = "prop_phonebox_01a", coords = { x = -1438.6545, y = -210.77448, z = 47.10766 } }, - { model = "prop_phonebox_01a", coords = { x = -1418.2983, y = -291.36002, z = 42.96778 } }, - { model = "prop_phonebox_01a", coords = { x = -1417.4769, y = -290.64453, z = 42.93899 } }, - { model = "prop_phonebox_01a", coords = { x = -1416.5211, y = -289.8119, z = 42.90134 } }, - { model = "prop_phonebox_01a", coords = { x = -1318.0975, y = -380.79547, z = 35.73553 } }, - { model = "prop_phonebox_01a", coords = { x = -1316.9534, y = -378.42676, z = 35.74885 } }, - { model = "prop_phonebox_01a", coords = { x = -1316.2688, y = -378.07245, z = 35.73169 } }, - { model = "prop_phonebox_01a", coords = { x = -1315.5924, y = -377.7347, z = 35.72274 } }, - { model = "prop_phonebox_01a", coords = { x = -1261.6484, y = -519.13086, z = 30.83657 } }, - { model = "prop_phonebox_01a", coords = { x = -1260.9482, y = -519.9344, z = 30.75686 } }, - { model = "prop_phonebox_01a", coords = { x = -1260.0995, y = -520.9083, z = 30.66707 } }, - { model = "prop_phonebox_01a", coords = { x = -1121.5917, y = -825.6313, z = 14.94339 } }, - { model = "prop_phonebox_01a", coords = { x = -1120.9409, y = -825.0698, z = 14.98657 } }, - { model = "prop_phonebox_01a", coords = { x = -1120.2446, y = -824.4812, z = 15.06407 } }, - { model = "prop_phonebox_01a", coords = { x = -1079.6154, y = -451.0622, z = 35.6144 } }, - { model = "prop_phonebox_01a", coords = { x = -1079.23, y = -451.8739, z = 35.62138 } }, - { model = "prop_phonebox_01a", coords = { x = -1078.874, y = -452.55182, z = 35.62138 } }, - { model = "prop_phonebox_01a", coords = { x = -956.56256, y = -403.17123, z = 36.81676 } }, - { model = "prop_phonebox_01a", coords = { x = -956.1004, y = -404.09232, z = 36.81755 } }, - { model = "prop_phonebox_01a", coords = { x = -524.4403, y = -300.71704, z = 34.26753 } }, - { model = "prop_phonebox_01a", coords = { x = -523.64453, y = -300.41074, z = 34.26273 } }, - { model = "prop_phonebox_01a", coords = { x = -522.872, y = -300.1134, z = 34.25807 } }, - { model = "prop_phonebox_01a", coords = { x = -449.44238, y = -272.8244, z = 34.93996 } }, - { model = "prop_phonebox_01a", coords = { x = -448.8816, y = -274.12805, z = 34.96191 } }, - { model = "prop_phonebox_01a", coords = { x = -448.36926, y = -275.31906, z = 34.96507 } }, - { model = "prop_phonebox_01a", coords = { x = -329.23917, y = 6224.885, z = 30.47861 } }, - { model = "prop_phonebox_01a", coords = { x = -310.30554, y = 6205.3, z = 30.4465 } }, - { model = "prop_phonebox_01a", coords = { x = -280.64215, y = 6224.2314, z = 30.45544 } }, - { model = "prop_phonebox_01a", coords = { x = -243.25082, y = 279.90717, z = 91.04989 } }, - { model = "prop_phonebox_01a", coords = { x = -234.61494, y = 6176.931, z = 30.43884 } }, - { model = "prop_phonebox_01a", coords = { x = -233.6865, y = 6176.0547, z = 30.43884 } }, - { model = "prop_phonebox_01a", coords = { x = -184.35658, y = 6331.4697, z = 30.48767 } }, - { model = "prop_phonebox_01a", coords = { x = -183.68753, y = 6332.1597, z = 30.48987 } }, - { model = "prop_phonebox_01a", coords = { x = -154.76048, y = 6352.27, z = 30.56079 } }, - { model = "prop_phonebox_01a", coords = { x = -153.88358, y = 6351.417, z = 30.56079 } }, - { model = "prop_phonebox_01a", coords = { x = -119.91727, y = 6287.283, z = 30.45911 } }, - { model = "prop_phonebox_01a", coords = { x = -119.44898, y = 6286.768, z = 30.45911 } }, - { model = "prop_phonebox_01a", coords = { x = -92.08037, y = 6462.644, z = 30.44397 } }, - { model = "prop_phonebox_01a", coords = { x = -90.61212, y = 6464.0566, z = 30.44397 } }, - { model = "prop_phonebox_01a", coords = { x = -46.3855, y = 6511.0156, z = 30.44861 } }, - { model = "prop_phonebox_01a", coords = { x = -25.6331, y = 6495.123, z = 30.48767 } }, - { model = "prop_phonebox_01a", coords = { x = -24.59157, y = 6496.0537, z = 30.48767 } }, - { model = "prop_phonebox_01a", coords = { x = 110.07694, y = -1694.206, z = 28.29156 } }, - { model = "prop_phonebox_01a", coords = { x = 136.9704, y = 196.05042, z = 105.73364 } }, - { model = "prop_phonebox_01a", coords = { x = 137.75732, y = 195.764, z = 105.70988 } }, - { model = "prop_phonebox_01a", coords = { x = 138.48807, y = 195.49808, z = 105.69342 } }, - { model = "prop_phonebox_01a", coords = { x = 173.45892, y = -1547.1165, z = 28.25158 } }, - { model = "prop_phonebox_01a", coords = { x = 215.71725, y = -1783.2102, z = 27.99637 } }, - { model = "prop_phonebox_01a", coords = { x = 215.94612, y = -1518.9603, z = 28.29362 } }, - { model = "prop_phonebox_01a", coords = { x = 228.6216, y = -1545.9579, z = 28.28108 } }, - { model = "prop_phonebox_01a", coords = { x = 229.1375, y = -1545.6771, z = 28.28108 } }, - { model = "prop_phonebox_01a", coords = { x = 295.67847, y = -1360.8624, z = 30.91401 } }, - { model = "prop_phonebox_01a", coords = { x = 296.17984, y = -1360.2825, z = 30.91899 } }, - { model = "prop_phonebox_01a", coords = { x = 532.401, y = -151.79816, z = 56.07613 } }, - { model = "prop_phonebox_01a", coords = { x = 539.5577, y = -166.04846, z = 53.4862 } }, - { model = "prop_phonebox_01a", coords = { x = 812.21716, y = -289.03873, z = 65.46264 } }, - { model = "prop_phonebox_01a", coords = { x = 812.3678, y = -289.84793, z = 65.46264 } }, - { model = "prop_phonebox_01a", coords = { x = 819.023, y = -94.03439, z = 79.57648 } }, - { model = "prop_phonebox_01a", coords = { x = 819.37396, y = -93.47577, z = 79.57648 } }, - { model = "prop_phonebox_01a", coords = { x = 891.8809, y = -140.80609, z = 76.11372 } }, - { model = "prop_phonebox_01a", coords = { x = 963.6167, y = -142.79822, z = 73.46588 } }, - { model = "prop_phonebox_01a", coords = { x = 1079.2015, y = -776.68054, z = 57.25418 } }, - { model = "prop_phonebox_01a", coords = { x = 1156.3375, y = -776.99866, z = 56.58559 } }, - { model = "prop_phonebox_01a", coords = { x = 1159.7463, y = -374.87518, z = 66.51784 } }, - { model = "prop_phonebox_01a", coords = { x = 1166.4825, y = -321.59958, z = 68.25383 } }, - { model = "prop_phonebox_01a", coords = { x = 1169.4127, y = 2702.8025, z = 36.99265 } }, - { model = "prop_phonebox_01a", coords = { x = 1170.3059, y = -455.76053, z = 65.49249 } }, - { model = "prop_phonebox_01a", coords = { x = 1172.7772, y = -297.61606, z = 68.01613 } }, - { model = "prop_phonebox_01a", coords = { x = 1172.8646, y = -298.23972, z = 68.01981 } }, - { model = "prop_phonebox_01a", coords = { x = 1173.8961, y = -421.43643, z = 66.07632 } }, - { model = "prop_phonebox_01a", coords = { x = 1201.426, y = -488.8848, z = 64.67129 } }, - { model = "prop_phonebox_01a", coords = { x = 1222.6125, y = -397.32706, z = 67.32355 } }, - { model = "prop_phonebox_01a", coords = { x = 1801.4076, y = 4597.0137, z = 36.67796 } }, - { model = "prop_phonebox_01a", coords = { x = 2558.9167, y = 367.14368, z = 107.63403 } }, - { model = "prop_phonebox_01b", coords = { x = -1684.4233, y = -266.45306, z = 50.89204 } }, - { model = "prop_phonebox_01b", coords = { x = -1683.9019, y = -265.70874, z = 50.89204 } }, - { model = "prop_phonebox_01b", coords = { x = -1543.9277, y = -433.13232, z = 34.57933 } }, - { model = "prop_phonebox_01b", coords = { x = -1543.0599, y = -432.05966, z = 34.58469 } }, - { model = "prop_phonebox_01b", coords = { x = -1522.4791, y = -407.05118, z = 34.58695 } }, - { model = "prop_phonebox_01b", coords = { x = -1412.1201, y = -383.80542, z = 35.68469 } }, - { model = "prop_phonebox_01b", coords = { x = -1205.1965, y = -1393.6274, z = 3.07721 } }, - { model = "prop_phonebox_01b", coords = { x = -1150.6671, y = -1392.6455, z = 4.11812 } }, - { model = "prop_phonebox_01b", coords = { x = -1142.0598, y = -725.3442, z = 19.77577 } }, - { model = "prop_phonebox_01b", coords = { x = -1080.87, y = -2574.942, z = 12.91528 } }, - { model = "prop_phonebox_01b", coords = { x = -1061.7015, y = -2541.7412, z = 12.91528 } }, - { model = "prop_phonebox_01b", coords = { x = -1061.5474, y = -2541.4744, z = 19.15571 } }, - { model = "prop_phonebox_01b", coords = { x = -1046.9408, y = -2516.175, z = 12.91528 } }, - { model = "prop_phonebox_01b", coords = { x = -1046.8787, y = -2516.0674, z = 19.15571 } }, - { model = "prop_phonebox_01b", coords = { x = -1034.5115, y = -2494.6467, z = 19.15571 } }, - { model = "prop_phonebox_01b", coords = { x = -1024.4517, y = -2477.2227, z = 12.91528 } }, - { model = "prop_phonebox_01b", coords = { x = -765.40045, y = -848.8706, z = 21.11398 } }, - { model = "prop_phonebox_01b", coords = { x = -764.83673, y = -848.8654, z = 21.13071 } }, - { model = "prop_phonebox_01b", coords = { x = -756.90735, y = 5586.8223, z = 35.71351 } }, - { model = "prop_phonebox_01b", coords = { x = -756.90735, y = 5587.636, z = 35.71351 } }, - { model = "prop_phonebox_01b", coords = { x = -745.72156, y = 5558.714, z = 35.71351 } }, - { model = "prop_phonebox_01b", coords = { x = -743.95874, y = 5558.714, z = 35.71351 } }, - { model = "prop_phonebox_01b", coords = { x = -715.921, y = 123.33502, z = 54.99648 } }, - { model = "prop_phonebox_01b", coords = { x = -715.40906, y = 123.65546, z = 55.01274 } }, - { model = "prop_phonebox_01b", coords = { x = -700.7271, y = -916.8699, z = 18.21408 } }, - { model = "prop_phonebox_01b", coords = { x = -700.1226, y = -916.8699, z = 18.21408 } }, - { model = "prop_phonebox_01b", coords = { x = -685.93774, y = -854.8967, z = 22.88396 } }, - { model = "prop_phonebox_01b", coords = { x = -670.3093, y = -819.59973, z = 23.4098 } }, - { model = "prop_phonebox_01b", coords = { x = -669.60815, y = -819.6056, z = 23.42427 } }, - { model = "prop_phonebox_01b", coords = { x = -668.81213, y = -819.6378, z = 23.43811 } }, - { model = "prop_phonebox_01b", coords = { x = -665.19354, y = -670.83777, z = 30.40002 } }, - { model = "prop_phonebox_01b", coords = { x = -664.59607, y = -670.8341, z = 30.40393 } }, - { model = "prop_phonebox_01b", coords = { x = -663.99866, y = -670.83044, z = 30.41797 } }, - { model = "prop_phonebox_01b", coords = { x = -655.2001, y = -859.74493, z = 23.50043 } }, - { model = "prop_phonebox_01b", coords = { x = -654.1605, y = -707.27155, z = 28.40153 } }, - { model = "prop_phonebox_01b", coords = { x = -654.1605, y = -706.51654, z = 28.47383 } }, - { model = "prop_phonebox_01b", coords = { x = -654.1605, y = -705.7615, z = 28.54753 } }, - { model = "prop_phonebox_01b", coords = { x = -611.56744, y = -2237.6104, z = 5.10603 } }, - { model = "prop_phonebox_01b", coords = { x = -530.0182, y = -1286.1543, z = 25.03622 } }, - { model = "prop_phonebox_01b", coords = { x = -529.77765, y = -1285.6444, z = 25.04207 } }, - { model = "prop_phonebox_01b", coords = { x = -529.6009, y = -1286.3458, z = 25.04428 } }, - { model = "prop_phonebox_01b", coords = { x = -529.36365, y = -1285.8345, z = 25.03622 } }, - { model = "prop_phonebox_01b", coords = { x = -468.24023, y = -396.44247, z = 32.8951 } }, - { model = "prop_phonebox_01b", coords = { x = -467.58286, y = -396.50574, z = 32.8951 } }, - { model = "prop_phonebox_01b", coords = { x = -259.3869, y = -604.76556, z = 32.59827 } }, - { model = "prop_phonebox_01b", coords = { x = -259.14352, y = -603.98016, z = 32.65002 } }, - { model = "prop_phonebox_01b", coords = { x = -241.57574, y = -766.2026, z = 31.73654 } }, - { model = "prop_phonebox_01b", coords = { x = -241.29694, y = -765.4682, z = 31.77955 } }, - { model = "prop_phonebox_01b", coords = { x = -239.74597, y = -978.22943, z = 28.26393 } }, - { model = "prop_phonebox_01b", coords = { x = -239.51797, y = -977.59296, z = 28.26393 } }, - { model = "prop_phonebox_01b", coords = { x = -178.84961, y = -52.06338, z = 51.10093 } }, - { model = "prop_phonebox_01b", coords = { x = -177.28662, y = -713.48505, z = 33.39728 } }, - { model = "prop_phonebox_01b", coords = { x = -147.61765, y = -287.15277, z = 39.43044 } }, - { model = "prop_phonebox_01b", coords = { x = -147.37784, y = -286.44186, z = 39.49831 } }, - { model = "prop_phonebox_01b", coords = { x = -73.17541, y = -641.5052, z = 35.24065 } }, - { model = "prop_phonebox_01b", coords = { x = -72.92773, y = -640.8415, z = 35.24065 } }, - { model = "prop_phonebox_01b", coords = { x = -53.45404, y = -94.169, z = 56.7686 } }, - { model = "prop_phonebox_01b", coords = { x = -27.98413, y = -100.90671, z = 56.35694 } }, - { model = "prop_phonebox_01b", coords = { x = -26.48154, y = -110.65947, z = 56.06785 } }, - { model = "prop_phonebox_01b", coords = { x = -8.06136, y = -731.61005, z = 43.22259 } }, - { model = "prop_phonebox_01b", coords = { x = -7.37235, y = -731.8549, z = 43.22768 } }, - { model = "prop_phonebox_01b", coords = { x = 43.99314, y = -680.87616, z = 43.20672 } }, - { model = "prop_phonebox_01b", coords = { x = 44.30204, y = -680.0512, z = 43.20672 } }, - { model = "prop_phonebox_01b", coords = { x = 120.37446, y = -205.12677, z = 53.61985 } }, - { model = "prop_phonebox_01b", coords = { x = 121.18489, y = -205.42413, z = 53.61985 } }, - { model = "prop_phonebox_01b", coords = { x = 129.40686, y = 245.3497, z = 106.42847 } }, - { model = "prop_phonebox_01b", coords = { x = 140.18076, y = -1033.1602, z = 28.34242 } }, - { model = "prop_phonebox_01b", coords = { x = 174.5452, y = -1116.4456, z = 28.28443 } }, - { model = "prop_phonebox_01b", coords = { x = 175.22937, y = -1116.4185, z = 28.28425 } }, - { model = "prop_phonebox_01b", coords = { x = 213.8157, y = -852.6208, z = 29.38956 } }, - { model = "prop_phonebox_01b", coords = { x = 214.44952, y = -852.86725, z = 29.38709 } }, - { model = "prop_phonebox_01b", coords = { x = 233.49872, y = 334.44766, z = 104.52145 } }, - { model = "prop_phonebox_01b", coords = { x = 296.66183, y = -1359.7725, z = 30.92093 } }, - { model = "prop_phonebox_01b", coords = { x = 372.30563, y = -966.37286, z = 28.41298 } }, - { model = "prop_phonebox_01b", coords = { x = 394.25433, y = -799.7535, z = 28.23798 } }, - { model = "prop_phonebox_01b", coords = { x = 394.25433, y = -798.6486, z = 28.23798 } }, - { model = "prop_phonebox_01b", coords = { x = 397.567, y = -921.3287, z = 28.3982 } }, - { model = "prop_phonebox_01b", coords = { x = 397.567, y = -920.658, z = 28.3982 } }, - { model = "prop_phonebox_01b", coords = { x = 415.17245, y = -910.94476, z = 28.3982 } }, - { model = "prop_phonebox_01b", coords = { x = 436.0411, y = 137.20285, z = 99.43968 } }, - { model = "prop_phonebox_01b", coords = { x = 436.83813, y = 136.89954, z = 99.38892 } }, - { model = "prop_phonebox_01b", coords = { x = 439.8047, y = -606.65063, z = 27.69825 } }, - { model = "prop_phonebox_01b", coords = { x = 439.99564, y = -604.67474, z = 27.69747 } }, - { model = "prop_phonebox_01b", coords = { x = 445.3808, y = 3567.5305, z = 32.21765 } }, - { model = "prop_phonebox_01b", coords = { x = 452.6532, y = -612.11816, z = 27.54012 } }, - { model = "prop_phonebox_01b", coords = { x = 452.7997, y = -610.4434, z = 27.5457 } }, - { model = "prop_phonebox_01b", coords = { x = 535.5781, y = 102.93228, z = 95.56698 } }, - { model = "prop_phonebox_01b", coords = { x = 779.83923, y = -1755.3914, z = 28.47611 } }, - { model = "prop_phonebox_01b", coords = { x = 780.2285, y = -1755.4475, z = 28.46564 } }, - { model = "prop_phonebox_01b", coords = { x = 809.3085, y = -1074.9281, z = 27.67919 } }, - { model = "prop_phonebox_01b", coords = { x = 903.52423, y = 3646.1294, z = 31.70571 } }, - { model = "prop_phonebox_01b", coords = { x = 1051.0497, y = 2661.3877, z = 38.52392 } }, - { model = "prop_phonebox_01b", coords = { x = 1181.1997, y = 2703.214, z = 37.1464 } }, - { model = "prop_phonebox_01b", coords = { x = 1206.4275, y = 2647.8894, z = 36.81204 } }, - { model = "prop_phonebox_01b", coords = { x = 1401.1744, y = 3602.0786, z = 34.01619 } }, - { model = "prop_phonebox_01b", coords = { x = 1662.8026, y = 4841.53, z = 41.0313 } }, - { model = "prop_phonebox_01b", coords = { x = 1662.9365, y = 4840.332, z = 41.0313 } }, - { model = "prop_phonebox_01b", coords = { x = 1692.9031, y = 6432.025, z = 31.73361 } }, - { model = "prop_phonebox_01b", coords = { x = 1696.5485, y = 3776.0618, z = 33.71252 } }, - { model = "prop_phonebox_01b", coords = { x = 1696.7177, y = 4790.302, z = 40.89749 } }, - { model = "prop_phonebox_01b", coords = { x = 1860.4072, y = 3696.2563, z = 33.26152 } }, - { model = "prop_phonebox_01b", coords = { x = 1861.0801, y = 3695.0903, z = 33.26152 } }, - { model = "prop_phonebox_01b", coords = { x = 2005.9707, y = 3782.6196, z = 31.15662 } }, - { model = "prop_phonebox_01b", coords = { x = 2006.7942, y = 3783.1003, z = 31.14984 } }, - { model = "prop_phonebox_04", coords = { x = -2969.4265, y = 397.46487, z = 14.10208 } }, - { model = "prop_phonebox_04", coords = { x = -1417.7056, y = -94.50974, z = 51.41046 } }, - { model = "prop_phonebox_04", coords = { x = -1416.8014, y = -94.11159, z = 51.44441 } }, - { model = "prop_phonebox_04", coords = { x = -1415.9122, y = -93.72023, z = 51.49042 } }, - { model = "prop_phonebox_04", coords = { x = -1294.0234, y = -390.34976, z = 35.44277 } }, - { model = "prop_phonebox_04", coords = { x = -1293.4121, y = -391.38864, z = 35.44632 } }, - { model = "prop_phonebox_04", coords = { x = -1241.7491, y = -464.37216, z = 32.537 } }, - { model = "prop_phonebox_04", coords = { x = -1224.2532, y = -322.51794, z = 36.57259 } }, - { model = "prop_phonebox_04", coords = { x = -1223.0624, y = -321.95178, z = 36.59326 } }, - { model = "prop_phonebox_04", coords = { x = -1074.0209, y = -397.75607, z = 35.95449 } }, - { model = "prop_phonebox_04", coords = { x = -1025.3336, y = -216.04681, z = 36.93829 } }, - { model = "prop_phonebox_04", coords = { x = -1023.97375, y = -216.74884, z = 36.9369 } }, - { model = "prop_phonebox_04", coords = { x = -985.19824, y = -414.10977, z = 36.85289 } }, - { model = "prop_phonebox_04", coords = { x = -979.73254, y = -369.2069, z = 36.856 } }, - { model = "prop_phonebox_04", coords = { x = -979.1488, y = -370.3092, z = 36.856 } }, - { model = "prop_phonebox_04", coords = { x = -965.37616, y = -2524.3992, z = 13.00643 } }, - { model = "prop_phonebox_04", coords = { x = -963.65985, y = -247.05435, z = 37.0568 } }, - { model = "prop_phonebox_04", coords = { x = -896.8487, y = -247.80585, z = 39.07844 } }, - { model = "prop_phonebox_04", coords = { x = -865.3409, y = -2528.5645, z = 13.00643 } }, - { model = "prop_phonebox_04", coords = { x = -821.83124, y = -251.49579, z = 36.0627 } }, - { model = "prop_phonebox_04", coords = { x = -821.29346, y = -252.4495, z = 36.05127 } }, - { model = "prop_phonebox_04", coords = { x = -701.46173, y = -371.56976, z = 33.2833 } }, - { model = "prop_phonebox_04", coords = { x = -700.3627, y = -372.04968, z = 33.27078 } }, - { model = "prop_phonebox_04", coords = { x = -619.5328, y = -207.68121, z = 36.3736 } }, - { model = "prop_phonebox_04", coords = { x = -618.975, y = -208.61508, z = 36.35005 } }, - { model = "prop_phonebox_04", coords = { x = -617.05457, y = -422.20978, z = 33.7873 } }, - { model = "prop_phonebox_04", coords = { x = -557.25586, y = -386.67517, z = 34.11347 } }, - { model = "prop_phonebox_04", coords = { x = -556.05286, y = -386.66565, z = 34.11989 } }, - { model = "prop_phonebox_04", coords = { x = -554.8701, y = -386.6563, z = 34.12583 } }, - { model = "prop_phonebox_04", coords = { x = -546.57184, y = -334.10083, z = 34.16116 } }, - { model = "prop_phonebox_04", coords = { x = -544.17737, y = -157.39006, z = 37.53791 } }, - { model = "prop_phonebox_04", coords = { x = -388.49542, y = -321.52948, z = 32.10458 } }, - { model = "prop_phonebox_04", coords = { x = -387.68488, y = -322.21454, z = 32.05279 } }, - { model = "prop_phonebox_04", coords = { x = -360.33978, y = -267.18268, z = 32.73604 } }, - { model = "prop_phonebox_04", coords = { x = -347.059, y = -1490.9738, z = 29.79159 } }, - { model = "prop_phonebox_04", coords = { x = -345.83786, y = -1490.9738, z = 29.7867 } }, - { model = "prop_phonebox_04", coords = { x = -263.0376, y = -766.90546, z = 31.57576 } }, - { model = "prop_phonebox_04", coords = { x = -262.6508, y = -766.04095, z = 31.60592 } }, - { model = "prop_phonebox_04", coords = { x = -213.1738, y = -696.5944, z = 32.80729 } }, - { model = "prop_phonebox_04", coords = { x = -174.76907, y = -674.9272, z = 33.27862 } }, - { model = "prop_phonebox_04", coords = { x = -173.80676, y = -675.35236, z = 33.29762 } }, - { model = "prop_phonebox_04", coords = { x = -138.00961, y = -799.9025, z = 31.10711 } }, - { model = "prop_phonebox_04", coords = { x = -137.64026, y = -798.8024, z = 31.14563 } }, - { model = "prop_phonebox_04", coords = { x = 55.44337, y = -1081.1333, z = 28.45174 } }, - { model = "prop_phonebox_04", coords = { x = 55.90165, y = -1080.282, z = 28.45174 } }, - { model = "prop_phonebox_04", coords = { x = 188.01767, y = -1043.9451, z = 28.32789 } }, - { model = "prop_phonebox_04", coords = { x = 189.79306, y = -1044.5588, z = 28.32789 } }, - { model = "prop_phonebox_04", coords = { x = 298.28317, y = -795.153, z = 28.4778 } }, - { model = "prop_phonebox_04", coords = { x = 298.62607, y = -794.289, z = 28.4778 } }, - { model = "prop_phonebox_04", coords = { x = 347.45938, y = -730.9255, z = 28.28353 } }, - { model = "prop_phonebox_04", coords = { x = 564.7501, y = -1748.7141, z = 28.31245 } }, - { model = "prop_phonebox_04", coords = { x = 653.1738, y = 272.5705, z = 102.29323 } }, - { model = "prop_phonebox_04", coords = { x = 654.2313, y = 271.95996, z = 102.29323 } }, - { model = "prop_phonebox_04", coords = { x = 1214.8445, y = -1385.6348, z = 34.34755 } }, - { model = "prop_phonebox_04", coords = { x = 1214.8445, y = -1384.5386, z = 34.34755 } }, - { model = "prop_phonebox_04", coords = { x = 1662.002, y = 4819.523, z = 41.04535 } }, - { model = "prop_phonebox_04", coords = { x = 1816.4236, y = 3671.6497, z = 33.29268 } }, - { model = "prop_phonebox_04", coords = { x = 1818.1936, y = 3668.7485, z = 33.29268 } }, - { model = "prop_phonebox_04", coords = { x = 2007.0574, y = 3784.7974, z = 31.20895 } }, -} diff --git a/sky_phone/config/weazel_news.lua b/sky_phone/config/weazel_news.lua deleted file mode 100644 index 33beff0..0000000 --- a/sky_phone/config/weazel_news.lua +++ /dev/null @@ -1,30 +0,0 @@ -Config.WeazelNews = { - Enabled = true, - PageSize = 20, - MaximumOffset = 10000, - MaximumImages = 6, - SearchMaxLength = 80, - DraftTitleMinLength = 1, - DraftBodyMinLength = 1, - TitleMinLength = 1, - TitleMaxLength = 160, - BodyMinLength = 1, - BodyMaxLength = 12000, - ExcerptMaxLength = 240, - RateLimits = { - Read = 120, - Write = 20, - }, - Categories = { - "official", - "events", - "jobs", - "news", - "business", - }, - -- Framework job name = minimum grade. Every listed job can manage every article. - AllowedJobs = { - weazel = 0, - reporter = 0, - }, -} diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index efe1958..e2cebd0 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -24,7 +24,8 @@ shared_scripts { client_scripts { 'config/config.lua', - 'config/locales/*.lua', + 'config/locales/en.lua', + 'config/locales/de.lua', 'source/bridge/client/framework.lua', 'source/bridge/client/callbacks.lua', 'source/bridge/client/housing.lua', @@ -49,12 +50,8 @@ client_scripts { server_scripts { '@oxmysql/lib/MySQL.lua', 'config/config.lua', - 'config/payphones.lua', - 'config/companies.lua', - 'config/media.lua', - 'config/music.lua', - 'config/weazel_news.lua', - 'config/locales/*.lua', + 'config/locales/en.lua', + 'config/locales/de.lua', 'source/bridge/server/database.lua', 'source/bridge/server/migrations.lua', 'source/bridge/server/callbacks.lua', @@ -68,6 +65,7 @@ server_scripts { 'source/server/custom_apps.lua', 'source/server/custom_app_compat.lua', 'source/server/db_migrate.lua', + 'source/server/lb_phone_migration.lua', 'source/server/media_metadata.lua', 'source/server/companies.lua', 'source/server/sim.lua', diff --git a/sky_phone/source/bridge/client/housing/esx_property.lua b/sky_phone/source/bridge/client/housing/esx_property.lua index 9c9ef8c..0ed3be6 100644 --- a/sky_phone/source/bridge/client/housing/esx_property.lua +++ b/sky_phone/source/bridge/client/housing/esx_property.lua @@ -3,7 +3,7 @@ if Bridge.Framework.GetName() ~= "esx" then end if GetResourceState("esx_property") ~= "started" then - print("[sky_phone] esx_property is not started; the housing provider is disabled.") + Bridge.Debug("warn", "[sky_phone] esx_property is not started; the housing provider is disabled.") return end diff --git a/sky_phone/source/bridge/shared.lua b/sky_phone/source/bridge/shared.lua index a73c46f..7db3630 100644 --- a/sky_phone/source/bridge/shared.lua +++ b/sky_phone/source/bridge/shared.lua @@ -34,8 +34,9 @@ function Bridge.Debug(level, message, ...) end local bridge_config = Config and Config.Bridge or nil - local enabled = options and options.always - or bridge_config and (bridge_config.Debug or bridge_config.DebugLevels and bridge_config.DebugLevels[level]) + local important = level == "warn" or level == "error" + local notice = options and options.notice == true + local enabled = important or notice or bridge_config and bridge_config.Debug == true if not enabled then return end diff --git a/sky_phone/source/client/custom_app_compat.lua b/sky_phone/source/client/custom_app_compat.lua index 1d5a89f..80ff2be 100644 --- a/sky_phone/source/client/custom_app_compat.lua +++ b/sky_phone/source/client/custom_app_compat.lua @@ -11,10 +11,10 @@ local function get_calling_resource(export_name) return owner_resource end - print(("[%s] %s rejected: the export must be called by another resource."):format( + Bridge.Debug("warn", "[%s] %s rejected: the export must be called by another resource.", RESOURCE_NAME, export_name - )) + ) return nil, "invalid_owner" end @@ -96,12 +96,12 @@ function SkyPhoneApps.RegisterCompatibilityExport(owner_resource, app_data) end if not definition then - print(("[%s] %s registration rejected for %s: %s."):format( + Bridge.Debug("warn", "[%s] %s registration rejected for %s: %s.", RESOURCE_NAME, provider, owner_resource, definition_error - )) + ) return false, definition_error end return register_provider_app(provider, owner_resource, definition, copy_record_data(app_data)) @@ -122,11 +122,11 @@ local function add_17mov_application(app_data) local definition, definition_error = SkyPhoneCompatibility.Build17MovDefinition(app_data) if not definition then - print(("[%s] 17mov registration rejected for %s: %s."):format( + Bridge.Debug("warn", "[%s] 17mov registration rejected for %s: %s.", RESOURCE_NAME, owner_resource, definition_error - )) + ) return false, definition_error end return register_provider_app( @@ -178,11 +178,11 @@ local function add_high_application(app_name, data, locales) locales ) if not definition then - print(("[%s] High Phone registration rejected for %s: %s."):format( + Bridge.Debug("warn", "[%s] High Phone registration rejected for %s: %s.", RESOURCE_NAME, owner_resource, definition_error - )) + ) return false, definition_error end @@ -227,11 +227,11 @@ end local function add_quasar_app_for_owner(owner_resource, app_data) local definition, definition_error = SkyPhoneCompatibility.BuildQuasarDefinition(app_data) if not definition then - print(("[%s] Quasar registration rejected for %s: %s."):format( + Bridge.Debug("warn", "[%s] Quasar registration rejected for %s: %s.", RESOURCE_NAME, owner_resource, definition_error - )) + ) return false, definition_error end @@ -465,7 +465,7 @@ local function register_high_server_app(owner_resource, definition, revision) or revision ~= math.floor(revision) or revision < 1 then - print(("[%s] Rejected invalid High Phone server application snapshot."):format(RESOURCE_NAME)) + Bridge.Debug("warn", "[%s] Rejected invalid High Phone server application snapshot.", RESOURCE_NAME) return nil end @@ -476,10 +476,10 @@ local function register_high_server_app(owner_resource, definition, revision) return app_id end if existing.owner_resource ~= owner_resource then - print(("[%s] Rejected conflicting High Phone owner for %s."):format( + Bridge.Debug("warn", "[%s] Rejected conflicting High Phone owner for %s.", RESOURCE_NAME, app_id - )) + ) return app_id end end @@ -512,12 +512,12 @@ local function register_high_server_app(owner_resource, definition, revision) provider = providers.high, } elseif not existing or existing.revision ~= revision or existing.last_error ~= error_message then - print(("[%s] Could not register High Phone server application %s revision %s: %s."):format( + Bridge.Debug("error", "[%s] Could not register High Phone server application %s revision %s: %s.", RESOURCE_NAME, app_id, revision, error_message or "unknown_error" - )) + ) end return app_id end @@ -542,18 +542,18 @@ local function remove_high_server_app(owner_resource, app_id) nil ) if not success then - print(("[%s] Could not restore High Phone client application %s: %s."):format( + Bridge.Debug("error", "[%s] Could not restore High Phone client application %s: %s.", RESOURCE_NAME, app_id, error_message or "unknown_error" - )) + ) end end end RegisterNetEvent("sky_phone:compat:high:client:syncApplication", function(owner_resource, definition, revision) if source ~= 65535 then - print(("[%s] Rejected locally invoked High Phone application sync."):format(RESOURCE_NAME)) + Bridge.Debug("warn", "[%s] Rejected locally invoked High Phone application sync.", RESOURCE_NAME) return end register_high_server_app(owner_resource, definition, revision) @@ -561,11 +561,11 @@ end) RegisterNetEvent("sky_phone:compat:high:client:removeApplication", function(owner_resource, app_id) if source ~= 65535 then - print(("[%s] Rejected locally invoked High Phone application removal."):format(RESOURCE_NAME)) + Bridge.Debug("warn", "[%s] Rejected locally invoked High Phone application removal.", RESOURCE_NAME) return end if type(owner_resource) ~= "string" or type(app_id) ~= "string" then - print(("[%s] Rejected invalid High Phone application removal."):format(RESOURCE_NAME)) + Bridge.Debug("warn", "[%s] Rejected invalid High Phone application removal.", RESOURCE_NAME) return end remove_high_server_app(owner_resource, app_id) @@ -573,11 +573,11 @@ end) RegisterNetEvent("sky_phone:compat:high:client:replaceSnapshot", function(snapshot) if source ~= 65535 then - print(("[%s] Rejected locally invoked High Phone application snapshot."):format(RESOURCE_NAME)) + Bridge.Debug("warn", "[%s] Rejected locally invoked High Phone application snapshot.", RESOURCE_NAME) return end if type(snapshot) ~= "table" then - print(("[%s] Rejected invalid High Phone application snapshot."):format(RESOURCE_NAME)) + Bridge.Debug("warn", "[%s] Rejected invalid High Phone application snapshot.", RESOURCE_NAME) return end diff --git a/sky_phone/source/client/custom_apps.lua b/sky_phone/source/client/custom_apps.lua index ea0ed2c..86d5b57 100644 --- a/sky_phone/source/client/custom_apps.lua +++ b/sky_phone/source/client/custom_apps.lua @@ -382,9 +382,13 @@ local function normalize_external_definition(owner_resource, adapter_resource, d if definition.compatibility == nil then return nil, icon_error end - print(( - "[sky_phone] Custom app '%s' from '%s' uses an unavailable icon (%s); using the default icon." - ):format(definition.id, owner_resource, icon_error)) + Bridge.Debug( + "warn", + "[sky_phone] Custom app '%s' from '%s' uses an unavailable icon (%s); using the default icon.", + definition.id, + owner_resource, + icon_error + ) end local permissions, permissions_error = SkyPhoneApps.ValidatePermissions(definition.permissions) @@ -574,9 +578,13 @@ local function register_app(app) local app_id = app.catalog.id local existing = apps_by_id[app_id] if existing then - print(( - "[sky_phone] Rejected duplicate custom app '%s' from '%s'; it is already owned by '%s'." - ):format(app_id, app.ownerResource, existing.ownerResource)) + Bridge.Debug( + "warn", + "[sky_phone] Rejected duplicate custom app '%s' from '%s'; it is already owned by '%s'.", + app_id, + app.ownerResource, + existing.ownerResource + ) return false, "duplicate_app_id" end @@ -602,11 +610,11 @@ local function invoke_hook(app, hook_name, payload) return true end - print(("[sky_phone] Custom app '%s' hook '%s' failed: %s"):format( + Bridge.Debug("error", "[sky_phone] Custom app '%s' hook '%s' failed: %s", app.catalog.id, hook_name, tostring(hook_error) - )) + ) return false end diff --git a/sky_phone/source/server/companies.lua b/sky_phone/source/server/companies.lua index a51dfac..e955f1b 100644 --- a/sky_phone/source/server/companies.lua +++ b/sky_phone/source/server/companies.lua @@ -903,7 +903,7 @@ local function cleanup_retained_data() }, }) if not success then - print("[sky_phone] Companies retention cleanup failed.") + Bridge.Debug("error", "[sky_phone] Companies retention cleanup failed.") end end diff --git a/sky_phone/source/server/custom_app_compat.lua b/sky_phone/source/server/custom_app_compat.lua index 022c21b..4ebf92f 100644 --- a/sky_phone/source/server/custom_app_compat.lua +++ b/sky_phone/source/server/custom_app_compat.lua @@ -11,10 +11,10 @@ local function get_calling_resource(export_name) return owner_resource end - print(("[%s] %s rejected: the export must be called by another resource."):format( + Bridge.Debug("warn", "[%s] %s rejected: the export must be called by another resource.", RESOURCE_NAME, export_name - )) + ) return nil, "invalid_owner" end @@ -31,11 +31,11 @@ local function add_high_application(app_name, data, locales) locales ) if not definition then - print(("[%s] High Phone registration rejected for %s: %s."):format( + Bridge.Debug("warn", "[%s] High Phone registration rejected for %s: %s.", RESOURCE_NAME, owner_resource, definition_error - )) + ) return false, definition_error end @@ -77,9 +77,9 @@ end RegisterNetEvent("sky_phone:compat:high:server:requestSnapshot", function() local player_source = source if player_source <= 0 then - print(("[%s] Rejected High Phone snapshot request without a player source."):format( + Bridge.Debug("warn", "[%s] Rejected High Phone snapshot request without a player source.", RESOURCE_NAME - )) + ) return end @@ -91,10 +91,10 @@ RegisterNetEvent("sky_phone:compat:high:server:requestSnapshot", function() local log_elapsed = last_log and now - last_log or SNAPSHOT_REJECTION_LOG_COOLDOWN_MS if log_elapsed < 0 or log_elapsed >= SNAPSHOT_REJECTION_LOG_COOLDOWN_MS then snapshot_rejection_logs[player_source] = now - print(("[%s] Rate-limited High Phone snapshot request from player %s."):format( + Bridge.Debug("warn", "[%s] Rate-limited High Phone snapshot request from player %s.", RESOURCE_NAME, player_source - )) + ) end return end diff --git a/sky_phone/source/server/custom_apps.lua b/sky_phone/source/server/custom_apps.lua index e53219b..6ba4abf 100644 --- a/sky_phone/source/server/custom_apps.lua +++ b/sky_phone/source/server/custom_apps.lua @@ -53,9 +53,13 @@ local function register_policy(policy) local app_id = policy.id local existing = policies_by_id[app_id] if existing then - print(( - "[sky_phone] Rejected duplicate custom app policy '%s' from '%s'; it is already owned by '%s'." - ):format(app_id, policy.ownerResource, existing.ownerResource)) + Bridge.Debug( + "warn", + "[sky_phone] Rejected duplicate custom app policy '%s' from '%s'; it is already owned by '%s'.", + app_id, + policy.ownerResource, + existing.ownerResource + ) return false, "duplicate_app_id" end diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 653f660..3607bbb 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -233,6 +233,71 @@ local schema = { }, tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", }, + { + name = "sky_phone_migrations", + columns = { + { name = "name", type = "VARCHAR(96) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "source", type = "VARCHAR(32) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "stats", type = "LONGTEXT NULL" }, + { name = "completed_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "name", + indexes = { + { name = "idx_sky_phone_migrations_source", columns = "(`source`, `completed_at`)" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_migration_numbers", + columns = { + { name = "source", type = "VARCHAR(32) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "source_number", type = "VARCHAR(64) NOT NULL" }, + { name = "phone_number", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "sim_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "owned", type = "TINYINT(1) NOT NULL DEFAULT 0" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = { "source", "source_number" }, + uniqueKeys = { + { name = "uniq_sky_phone_migration_number", columns = "(`source`, `phone_number`)" }, + }, + indexes = { + { name = "idx_sky_phone_migration_numbers_sim", columns = "(`sim_id`)" }, + }, + foreignKeys = { + { column = "sim_id", references = "`sky_phone_sims` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_migration_owners", + columns = { + { name = "source", type = "VARCHAR(32) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "source_phone_id", type = "VARCHAR(100) NOT NULL" }, + { name = "source_owner_id", type = "VARCHAR(100) NOT NULL" }, + { name = "owner_identifier", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "source_phone_number", type = "VARCHAR(64) NOT NULL" }, + { name = "device_imei", type = "CHAR(15) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "sim_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "account_id", type = "BIGINT UNSIGNED NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = { "source", "source_phone_id" }, + uniqueKeys = { + { name = "uniq_sky_phone_migration_owner_number", columns = "(`source`, `source_phone_number`)" }, + }, + indexes = { + { name = "idx_sky_phone_migration_owner", columns = "(`source`, `owner_identifier`)" }, + { name = "idx_sky_phone_migration_owner_device", columns = "(`device_imei`)" }, + { name = "idx_sky_phone_migration_owner_account", columns = "(`account_id`)" }, + }, + foreignKeys = { + { column = "device_imei", references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE" }, + { column = "sim_id", references = "`sky_phone_sims` (`id`) ON DELETE CASCADE" }, + { column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE SET NULL" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, { name = "sky_phone_device_data", columns = { diff --git a/sky_phone/source/server/fliptok.lua b/sky_phone/source/server/fliptok.lua index 15b57c1..b3ad2f1 100644 --- a/sky_phone/source/server/fliptok.lua +++ b/sky_phone/source/server/fliptok.lua @@ -14,14 +14,12 @@ local custom_music_extensions = { wav = true, webm = true, } -local password_pepper = GetConvar(Config.FlipTok.PasswordPepperConvar, "") +local password_pepper = tostring(Config.Server.FlipTokPasswordPepper or "") if password_pepper == "" then Bridge.Debug( "warn", - "[sky_phone] FlipTok password pepper convar '%s' is empty; configure it before production use.", - Config.FlipTok.PasswordPepperConvar, - { always = true } + "[sky_phone] Config.Server.FlipTokPasswordPepper is empty; configure it in config/config.lua before production use." ) end @@ -921,7 +919,7 @@ RegisterCommand(Config.FlipTok.VerifyCommand, function(source, arguments) end local function send_command_feedback(message, notification_type) if source == 0 then - print(message) + Bridge.Debug(notification_type == "error" and "error" or "info", message) return end @@ -932,7 +930,11 @@ RegisterCommand(Config.FlipTok.VerifyCommand, function(source, arguments) end if source ~= 0 and not Bridge.Framework.HasAdminGroup(source, Config.FlipTok.AdminGroups) then send_command_feedback(command_locale.noPermission, "error") - print(("[sky_phone] Player %d attempted to use the FlipTok verification command without an admin group."):format(source)) + Bridge.Debug( + "warn", + "[sky_phone] Player %d attempted to use the FlipTok verification command without an admin group.", + source + ) return end local handle = type(arguments[1]) == "string" and arguments[1]:lower():gsub("^@", "") or "" diff --git a/sky_phone/source/server/lb_phone_migration.lua b/sky_phone/source/server/lb_phone_migration.lua new file mode 100644 index 0000000..7ae734f --- /dev/null +++ b/sky_phone/source/server/lb_phone_migration.lua @@ -0,0 +1,3727 @@ +-- 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 + +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_done(domain) + local rows = Bridge.Database.Query([[ + SELECT 1 FROM `sky_phone_migrations` WHERE `name` = ? LIMIT 1 + ]], { marker_prefix .. 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 (?, ?, ?) + ]], { marker_prefix .. 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 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` + ]]):format(accounts_table), { source_name }) + local existing_profiles = Bridge.Database.Query( + "SELECT `id`, `handle`, `account_id` FROM `sky_phone_picstagram_profiles`", {} + ) + local existing_by_handle = {} + for index = 1, #existing_profiles do + existing_by_handle[tostring(existing_profiles[index].handle):lower()] = existing_profiles[index] + end + + local entries = {} + local cloud_accounts = {} + 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) + local existing = existing_by_handle[handle] + if not existing or existing.id == profile_id then + local email = synthetic_email("ig", handle) + entries[#entries + 1] = { + source = row, + handle = handle, + profile_id = profile_id, + email = email, + } + cloud_accounts[#cloud_accounts + 1] = { + email, + deterministic_hex("lb-phone-picstagram-account", handle), + } + else + skipped = skipped + 1 + end + 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 + + insert_many("INSERT IGNORE INTO `sky_phone_accounts` (`email`, `password`) VALUES", 2, cloud_accounts) + local all_cloud_accounts = Bridge.Database.Query("SELECT `id`, `email` FROM `sky_phone_accounts`", {}) + local account_by_email = {} + for index = 1, #all_cloud_accounts do + account_by_email[tostring(all_cloud_accounts[index].email):lower()] = tonumber(all_cloud_accounts[index].id) + end + + local avatar_rows = {} + for index = 1, #entries do + local entry = entries[index] + entry.account_id = account_by_email[entry.email] + 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)] = 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 table_exists(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)] + 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 IGNORE INTO `sky_phone_picstagram_sessions` (`device_imei`, `profile_id`) VALUES + ]], 2, session_rows) + + 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)] + 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)] + 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)] + 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)] + local following = profile_by_username[tostring(follows[index].followed)] + 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)] + local requestee = profile_by_username[tostring(requests[index].requestee)] + 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)] + 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)] + 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, + 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 = profile_by_username[usernames[1]:lower()] + local second_profile = profile_by_username[usernames[2]:lower()] + if tonumber(channel.member_count) == 2 and #usernames == 2 + and 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` + ]]):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, + email = synthetic_email("flip", 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 cloud_rows = {} + for index = 1, #entries do + cloud_rows[#cloud_rows + 1] = { + entries[index].email, + deterministic_hex("lb-phone-fliptok-account", entries[index].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 = {} + for index = 1, #target_accounts do + account_by_email[tostring(target_accounts[index].email):lower()] = tonumber(target_accounts[index].id) + end + local target_profiles = Bridge.Database.Query( + "SELECT `id`, `account_id`, `handle` FROM `sky_phone_fliptok_profiles`", {} + ) + local existing_by_handle = {} + for index = 1, #target_profiles do + existing_by_handle[tostring(target_profiles[index].handle):lower()] = target_profiles[index] + end + + local accepted = {} + local avatar_rows = {} + for index = 1, #entries do + local entry = entries[index] + entry.account_id = account_by_email[entry.email] + local existing = existing_by_handle[entry.handle] + if entry.account_id and (not existing or tonumber(existing.account_id) == entry.account_id) then + accepted[#accepted + 1] = entry + 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 + else + skipped = skipped + 1 + 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) + 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 table_exists(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 IGNORE INTO `sky_phone_fliptok_sessions` (`device_imei`, `profile_id`) VALUES + ]], 2, session_rows) + + 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, + 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.`phone_number`, account.`date_joined`, account.`username` + ]]):format(accounts_table), { source_name }) + local entries = {} + local entry_by_source_username = {} + 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 + entry_by_source_username[tostring(row.username)] = 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 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 ('twitter', 'feather') + ]]):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, #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):lower() + end + end + + local target_profiles = Bridge.Database.Query( + "SELECT `id`, `account_id`, `handle` FROM `sky_phone_feather_profiles`", {} + ) + local occupied_accounts = {} + local existing_by_handle = {} + for index = 1, #target_profiles do + occupied_accounts[tonumber(target_profiles[index].account_id)] = target_profiles[index] + existing_by_handle[tostring(target_profiles[index].handle):lower()] = target_profiles[index] + end + local cloud_rows = {} + for index = 1, #entries do + local entry = entries[index] + local owner_account_id = tonumber(entry.source.owner_account_id) + local existing = existing_by_handle[entry.handle] + local preferred_for_owner = owner_account_id + and preferred[owner_account_id] == tostring(entry.source.username):lower() + local may_use_owner = preferred_for_owner and ( + not occupied_accounts[owner_account_id] + or (existing and tonumber(existing.account_id) == owner_account_id) + ) + if may_use_owner then + entry.account_id = owner_account_id + occupied_accounts[owner_account_id] = occupied_accounts[owner_account_id] or true + else + entry.email = synthetic_email("feather", entry.handle) + cloud_rows[#cloud_rows + 1] = { + entry.email, + deterministic_hex("lb-phone-feather-account", entry.handle), + } + end + 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 = {} + for index = 1, #target_accounts do + account_by_email[tostring(target_accounts[index].email):lower()] = tonumber(target_accounts[index].id) + end + + local accepted = {} + local avatar_rows = {} + for index = 1, #entries do + local entry = entries[index] + entry.account_id = entry.account_id or account_by_email[entry.email] + local existing = existing_by_handle[entry.handle] + if entry.account_id and (not existing or tonumber(existing.account_id) == entry.account_id) then + accepted[#accepted + 1] = entry + 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 + else + skipped = skipped + 1 + 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) + 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, + 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 domain_order = { + "devices", + "settings", + "alarms", + "contacts", + "blocked", + "calls", + "messages", + "photos", + "notes", + "wallet", + "voiceMemos", + "picstagram", + "mail", + "mapMarkers", + "darkChat", + "flipTok", + "feather", +} + +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, +} + +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", + }, +} + +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", +} + +local summary_metrics = { + { "imported", "imported" }, + { "resolved", "matched" }, + { "profiles", "profiles" }, + { "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 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) diff --git a/sky_phone/source/server/media.lua b/sky_phone/source/server/media.lua index 49c2280..1dd2d46 100644 --- a/sky_phone/source/server/media.lua +++ b/sky_phone/source/server/media.lua @@ -970,6 +970,9 @@ AddEventHandler("playerDropped", function() end) if not api_configured() then - print("^3[sky_phone] Camera uploads and FiveManage imports are disabled until Config.Media.FiveManage.ApiKey is set in config/media.lua.^7") + Bridge.Debug( + "warn", + "[sky_phone] Camera uploads and FiveManage imports are disabled until Config.Media.FiveManage.ApiKey is set in config/config.lua." + ) end end) diff --git a/sky_phone/source/server/phone.lua b/sky_phone/source/server/phone.lua index 98d9d20..fdb1ccd 100644 --- a/sky_phone/source/server/phone.lua +++ b/sky_phone/source/server/phone.lua @@ -10,13 +10,11 @@ local auth_attempts = {} local operation_attempts = {} local character_device_cache = {} local max_device_data_bytes = 100000 -local passcode_pepper = GetConvar(Config.Security.PasscodePepperConvar, "") +local passcode_pepper = tostring(Config.Server.PasscodePepper or "") if passcode_pepper == "" then Bridge.Debug( "warn", - "[sky_phone] Passcode pepper convar '%s' is empty; configure it before production use.", - Config.Security.PasscodePepperConvar, - { always = true } + "[sky_phone] Config.Server.PasscodePepper is empty; configure it in config/config.lua before production use." ) end local allowed_device_namespaces = { @@ -86,6 +84,36 @@ local function character_identifier(source) return identifier end +local function migrated_device_for_character(source) + local identifier = character_identifier(source) + if not identifier then + return nil + end + + local rows = Bridge.Database.Query([[ + SELECT mapping.`device_imei` + FROM `sky_phone_migration_owners` mapping + JOIN `sky_phone_character_devices` character_device + ON character_device.`owner_identifier` = mapping.`owner_identifier` + AND character_device.`device_imei` = mapping.`device_imei` + JOIN `sky_phone_devices` device ON device.`imei` = mapping.`device_imei` + WHERE mapping.`source` = 'lb-phone' AND mapping.`owner_identifier` = ? + ORDER BY mapping.`created_at` DESC + LIMIT 1 + ]], { identifier }) + local imei = rows[1] and rows[1].device_imei or nil + if imei and not SkyPhoneImei.IsValid(imei) then + Bridge.Debug( + "error", + "[sky_phone] LB Phone migration mapping contains invalid IMEI '%s' for '%s'.", + tostring(imei), + identifier + ) + return nil + end + return imei +end + local function load_character_device(source, identifier) identifier = identifier or character_identifier(source) if not identifier then @@ -304,6 +332,30 @@ local function ensure_device(source, slot) end if not imei then + imei = migrated_device_for_character(source) + if imei then + metadata.imei = imei + local metadata_written = Bridge.Inventory.SetSlotMetadata(source, slot.slot, metadata) + if not metadata_written then + Bridge.Debug( + "error", + "[sky_phone] Inventory '%s' could not adopt migrated phone metadata for source %s slot %s.", + Bridge.Inventory.GetResourceName(), + tostring(source), + tostring(slot.slot) + ) + return nil, "metadata_unsupported" + end + Bridge.Debug( + "info", + "[sky_phone] Adopted LB Phone migrated device %s for source %s.", + imei, + tostring(source), + { always = true } + ) + return imei + end + imei = reserve_imei() metadata.imei = imei Bridge.Debug( diff --git a/sky_phone/source/server/picstagram.lua b/sky_phone/source/server/picstagram.lua index 76e8f73..e659fdb 100644 --- a/sky_phone/source/server/picstagram.lua +++ b/sky_phone/source/server/picstagram.lua @@ -1,13 +1,11 @@ Bridge.Database.AfterMigration("sky_phone", function() local report_reasons = {} -local password_pepper = GetConvar(Config.Picstagram.PasswordPepperConvar, "") +local password_pepper = tostring(Config.Server.PicstagramPasswordPepper or "") if password_pepper == "" then Bridge.Debug( "warn", - "[sky_phone] Picstagram password pepper convar '%s' is empty; configure it before production use.", - Config.Picstagram.PasswordPepperConvar, - { always = true } + "[sky_phone] Config.Server.PicstagramPasswordPepper is empty; configure it in config/config.lua before production use." ) end @@ -1444,7 +1442,7 @@ RegisterCommand(Config.Picstagram.VerifyCommand, function(source, args) end local function send_command_feedback(message, notification_type) if source == 0 then - print(message) + Bridge.Debug(notification_type == "error" and "error" or "info", message) return end TriggerClientEvent("sky_phone:picstagram:command-feedback", source, { diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index 832e4b5..9cfc4cf 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -105,6 +105,48 @@ CREATE TABLE IF NOT EXISTS `sky_phone_character_devices` ( FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `sky_phone_migrations` ( + `name` VARCHAR(96) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `source` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `stats` LONGTEXT NULL, + `completed_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`name`), + KEY `idx_sky_phone_migrations_source` (`source`, `completed_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_migration_numbers` ( + `source` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `source_number` VARCHAR(64) NOT NULL, + `phone_number` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `owned` TINYINT(1) NOT NULL DEFAULT 0, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`source`, `source_number`), + UNIQUE KEY `uniq_sky_phone_migration_number` (`source`, `phone_number`), + KEY `idx_sky_phone_migration_numbers_sim` (`sim_id`), + FOREIGN KEY (`sim_id`) REFERENCES `sky_phone_sims` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_migration_owners` ( + `source` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `source_phone_id` VARCHAR(100) NOT NULL, + `source_owner_id` VARCHAR(100) NOT NULL, + `owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `source_phone_number` VARCHAR(64) NOT NULL, + `device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `account_id` BIGINT UNSIGNED NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`source`, `source_phone_id`), + UNIQUE KEY `uniq_sky_phone_migration_owner_number` (`source`, `source_phone_number`), + KEY `idx_sky_phone_migration_owner` (`source`, `owner_identifier`), + KEY `idx_sky_phone_migration_owner_device` (`device_imei`), + KEY `idx_sky_phone_migration_owner_account` (`account_id`), + FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE, + FOREIGN KEY (`sim_id`) REFERENCES `sky_phone_sims` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `sky_phone_device_data` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,