mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:01:31 +00:00
ENH - merge dev into Companies feature
This commit is contained in:
@@ -88,16 +88,68 @@ 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, Konsta UI 5, and Tailwind CSS 4. Each non-stackable `phone` item receives a unique 15-digit IMEI and owns its server-persisted device state. The phone opens through the usable item; `/phone` is disabled unless `Config.Phone.DevelopmentCommand` is enabled explicitly.
|
||||
Standalone FiveM phone built with Vue 3, TypeScript, Pinia, Vue Router, Konsta UI 5, and Tailwind CSS 4. The phone opens through the usable item; `/phone` is disabled unless `Config.Phone.DevelopmentCommand` is enabled explicitly. Phone identity and SIM-card behavior are selected independently through `Config.Phone.Unique` and `Config.Sim.Enabled`.
|
||||
|
||||
An iFruit 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 cloud account; an existing cloud dataset wins over local contacts and recents. Signing out keeps an editable local snapshot without deleting 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 iFruit
|
||||
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.
|
||||
|
||||
## 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.
|
||||
- A non-stackable inventory item named `phone`.
|
||||
- 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.
|
||||
- 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"`.
|
||||
- A FiveManage V3 Media API token for Camera photo/video uploads and Gallery deletion. Set the
|
||||
@@ -118,7 +170,7 @@ GIPHY provides trending and searched GIFs through a paginated server-side proxy.
|
||||
only the server uses the GIPHY key. Photo and video actions in Messages are intentionally inactive
|
||||
until their dedicated implementation is available.
|
||||
|
||||
Database migrations run automatically. Existing `sky_phone_mail_accounts` installations are renamed to `sky_phone_accounts` while preserving account IDs and mail foreign keys. iFruit passwords are intentional in-character credentials and remain plaintext `VARCHAR(64)` values; registration screens warn players never to reuse a real password.
|
||||
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`. iFruit passwords are intentional in-character credentials and remain plaintext `VARCHAR(64)` values; registration screens warn players never to reuse a real password.
|
||||
|
||||
Camera and Gallery media is stored in `sky_phone_media`. Signed-out captures belong to the current
|
||||
IMEI; linking an iFruit account moves those rows into the account gallery so every linked phone sees
|
||||
@@ -137,13 +189,13 @@ Configure frequency bounds and precision, restricted channel ranges and allowed
|
||||
|
||||
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`.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
When 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/cloud data remain attached to their existing phone-owned persistence instead of being copied into inventory metadata.
|
||||
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/cloud data remain attached to their existing Device or cloud persistence instead of being copied into inventory metadata. Automatically created virtual SIMs remain database-only and never become inventory items.
|
||||
|
||||
For `ox_inventory`, configure all three items with `stack = false` and `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.
|
||||
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:
|
||||
Example `ox_inventory/data/items.lua` entries for the default unique-phone, physical-SIM modes:
|
||||
|
||||
```lua
|
||||
["phone"] = {
|
||||
@@ -169,7 +221,7 @@ Example `ox_inventory/data/items.lua` entries:
|
||||
},
|
||||
```
|
||||
|
||||
For QBCore-style item tables, define the same names with `unique = true`, `useable = true`, and `shouldClose = true`. The provider adapter registers the server-side usable callbacks; no `lb-phone` event or export is used.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -261,6 +261,9 @@ const notifications = useNotificationsStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isAppRoute = computed(() => route.name === 'app')
|
||||
const showActiveCallReturn = computed(
|
||||
() => Boolean(calls.activeCall) && route.params.appId !== 'phone',
|
||||
)
|
||||
const appTransitionName = computed(() =>
|
||||
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
|
||||
)
|
||||
@@ -407,6 +410,7 @@ async function hydrateDevelopmentPhone(): Promise<void> {
|
||||
sim: {
|
||||
id: 'development-sim',
|
||||
number: '5551234567',
|
||||
removable: true,
|
||||
registered: true,
|
||||
type: 'registered',
|
||||
},
|
||||
@@ -974,6 +978,12 @@ function lockPhone(): void {
|
||||
isLocked.value = true
|
||||
}
|
||||
|
||||
function returnToActiveCall(): void {
|
||||
if (!calls.activeCall) return
|
||||
controlCenterOpened.value = false
|
||||
void router.push('/apps/phone')
|
||||
}
|
||||
|
||||
function unlockCamera(): void {
|
||||
if (phone.security.enabled) {
|
||||
pendingUnlockRoute.value = '/apps/camera'
|
||||
@@ -1205,8 +1215,10 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<PhoneStatusBar
|
||||
v-if="!isLocked"
|
||||
:active-call-return="showActiveCallReturn"
|
||||
:control-center-opened="controlCenterOpened"
|
||||
lockable
|
||||
@active-call="returnToActiveCall"
|
||||
@control-center="toggleControlCenter"
|
||||
@lock="lockPhone"
|
||||
/>
|
||||
|
||||
@@ -467,6 +467,47 @@ button {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.phone-status-bar__time-button--active-call {
|
||||
isolation: isolate;
|
||||
text-shadow: none;
|
||||
}
|
||||
.phone-status-bar__time-button--active-call::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: 19px;
|
||||
left: calc(50% - 3px);
|
||||
width: 90px;
|
||||
height: 30px;
|
||||
border-radius: 15px;
|
||||
background: #34c759;
|
||||
box-shadow: 0 2px 8px #0b6f2b66;
|
||||
animation: phone-active-call-pill-in 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
content: '';
|
||||
transform-origin: center;
|
||||
transform: translateX(-50%);
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
.phone-status-bar__time-button--active-call:active::before {
|
||||
transform: translateX(-50%) scale(0.94);
|
||||
}
|
||||
.phone-status-bar__active-call-icon {
|
||||
position: absolute;
|
||||
top: 27px;
|
||||
left: calc(50% - 39px);
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
@keyframes phone-active-call-pill-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.82);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) scale(1);
|
||||
}
|
||||
}
|
||||
.phone-status-bar__indicators {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { PhoneCall } from 'lucide-vue-next'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import PhoneStatusIndicators from '@/components/PhoneStatusIndicators.vue'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
withDefaults(
|
||||
defineProps<{ controlCenterOpened?: boolean; lockable?: boolean }>(),
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
activeCallReturn?: boolean
|
||||
controlCenterOpened?: boolean
|
||||
lockable?: boolean
|
||||
}>(),
|
||||
{
|
||||
activeCallReturn: false,
|
||||
controlCenterOpened: false,
|
||||
lockable: false,
|
||||
},
|
||||
)
|
||||
const emit = defineEmits<{ controlCenter: []; lock: [] }>()
|
||||
const emit = defineEmits<{ activeCall: []; controlCenter: []; lock: [] }>()
|
||||
const time = ref('')
|
||||
let intervalId: number | undefined
|
||||
|
||||
@@ -23,6 +29,14 @@ function updateTime(): void {
|
||||
}).format(new Date())
|
||||
}
|
||||
|
||||
function handleTimeClick(): void {
|
||||
if (props.activeCallReturn) {
|
||||
emit('activeCall')
|
||||
return
|
||||
}
|
||||
emit('lock')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateTime()
|
||||
intervalId = window.setInterval(updateTime, 30_000)
|
||||
@@ -38,10 +52,22 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
v-if="lockable"
|
||||
class="phone-status-bar__time phone-status-bar__time-button"
|
||||
:class="{
|
||||
'phone-status-bar__time-button--active-call': activeCallReturn,
|
||||
}"
|
||||
type="button"
|
||||
:aria-label="phone.t('LockScreen.label')"
|
||||
@click.stop="emit('lock')"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
activeCallReturn ? 'Apps.phone.returnToCall' : 'LockScreen.label',
|
||||
)
|
||||
"
|
||||
@click.stop="handleTimeClick"
|
||||
>
|
||||
<PhoneCall
|
||||
v-if="activeCallReturn"
|
||||
class="phone-status-bar__active-call-icon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<time>{{ time }}</time>
|
||||
</button>
|
||||
<time v-else class="phone-status-bar__time">{{ time }}</time>
|
||||
|
||||
@@ -68,4 +68,54 @@ describe('calls store', () => {
|
||||
expect(calls.activeCall).toBeNull()
|
||||
expect(nuiCall).toHaveBeenCalledWith('calls:recents')
|
||||
})
|
||||
|
||||
it('blocks the active caller and closes the call screen', async () => {
|
||||
const calls = useCallsStore()
|
||||
calls.applyCallState({
|
||||
direction: 'incoming',
|
||||
id: 'call-3',
|
||||
otherNumber: '5551110025',
|
||||
startedAt: 1,
|
||||
state: 'ringing',
|
||||
})
|
||||
|
||||
const response = await calls.blockNumber('5551110025')
|
||||
|
||||
expect(response.success).toBe(true)
|
||||
expect(nuiCall).toHaveBeenCalledWith('calls:block', {
|
||||
phoneNumber: '5551110025',
|
||||
})
|
||||
expect(calls.activeCall).toBeNull()
|
||||
expect(nuiCall).toHaveBeenCalledWith('calls:recents')
|
||||
})
|
||||
|
||||
it('updates a contact favorite and refreshes the contact list', async () => {
|
||||
vi.mocked(nuiCall)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: { favorite: true, id: 'contact-alex' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
favorite: true,
|
||||
id: 'contact-alex',
|
||||
name: 'Alex Rivera',
|
||||
phone_number: '5551110001',
|
||||
},
|
||||
],
|
||||
})
|
||||
const calls = useCallsStore()
|
||||
|
||||
const response = await calls.setContactFavorite('contact-alex', true)
|
||||
|
||||
expect(response.success).toBe(true)
|
||||
expect(nuiCall).toHaveBeenNthCalledWith(1, 'contacts:favorite', {
|
||||
favorite: true,
|
||||
id: 'contact-alex',
|
||||
})
|
||||
expect(nuiCall).toHaveBeenNthCalledWith(2, 'contacts:list')
|
||||
expect(calls.contacts[0]?.favorite).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,8 +35,11 @@ export const useCallsStore = defineStore('calls', () => {
|
||||
}
|
||||
|
||||
async function saveContact(contact: {
|
||||
avatarMediaId?: number | null
|
||||
id?: string
|
||||
name: string
|
||||
notes?: string
|
||||
organization?: string
|
||||
phoneNumber: string
|
||||
}): Promise<NuiResponse<PhoneContact>> {
|
||||
const response = await nuiCall<PhoneContact>('contacts:save', contact)
|
||||
@@ -58,17 +61,56 @@ export const useCallsStore = defineStore('calls', () => {
|
||||
|
||||
async function answer(): Promise<NuiResponse> {
|
||||
if (!activeCall.value) return { success: false, error: 'call_not_found' }
|
||||
return nuiCall('calls:answer', { id: activeCall.value.id })
|
||||
const response = await nuiCall('calls:answer', { id: activeCall.value.id })
|
||||
if (response.success && activeCall.value) {
|
||||
activeCall.value = {
|
||||
...activeCall.value,
|
||||
answeredAt: activeCall.value.answeredAt ?? Date.now(),
|
||||
state: 'connected',
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function decline(): Promise<boolean> {
|
||||
if (!activeCall.value) return false
|
||||
return (await nuiCall('calls:decline', { id: activeCall.value.id })).success
|
||||
const response = await nuiCall('calls:decline', { id: activeCall.value.id })
|
||||
if (response.success) activeCall.value = null
|
||||
return response.success
|
||||
}
|
||||
|
||||
async function setContactFavorite(
|
||||
id: string,
|
||||
favorite: boolean,
|
||||
): Promise<NuiResponse<{ favorite: boolean; id: string }>> {
|
||||
const response = await nuiCall<{ favorite: boolean; id: string }>(
|
||||
'contacts:favorite',
|
||||
{ favorite, id },
|
||||
)
|
||||
if (response.success) await loadContacts()
|
||||
return response
|
||||
}
|
||||
|
||||
async function hangup(): Promise<boolean> {
|
||||
if (!activeCall.value) return false
|
||||
return (await nuiCall('calls:hangup', { id: activeCall.value.id })).success
|
||||
const response = await nuiCall('calls:hangup', { id: activeCall.value.id })
|
||||
if (response.success) {
|
||||
activeCall.value = null
|
||||
await loadRecents()
|
||||
}
|
||||
return response.success
|
||||
}
|
||||
|
||||
async function blockNumber(phoneNumber: string): Promise<NuiResponse> {
|
||||
const response = await nuiCall('calls:block', { phoneNumber })
|
||||
if (
|
||||
response.success &&
|
||||
activeCall.value?.otherNumber === phoneNumber
|
||||
) {
|
||||
activeCall.value = null
|
||||
await loadRecents()
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
function applyCallState(call: PhoneCall): void {
|
||||
@@ -95,6 +137,7 @@ export const useCallsStore = defineStore('calls', () => {
|
||||
answer,
|
||||
applyCallState,
|
||||
bootstrap,
|
||||
blockNumber,
|
||||
contacts,
|
||||
decline,
|
||||
deleteContact,
|
||||
@@ -104,5 +147,6 @@ export const useCallsStore = defineStore('calls', () => {
|
||||
loadRecents,
|
||||
recents,
|
||||
saveContact,
|
||||
setContactFavorite,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1312,6 +1312,7 @@ const defaultLocales: LocaleTree = {
|
||||
addContact: 'Add Contact',
|
||||
contactSaved: 'Contact saved',
|
||||
removeContact: 'Remove Contact',
|
||||
returnToCall: 'Return to Call',
|
||||
block: 'Block User',
|
||||
unblock: 'Unblock User',
|
||||
clearChat: 'Clear Chat',
|
||||
@@ -1472,14 +1473,60 @@ const defaultLocales: LocaleTree = {
|
||||
noSim: 'No SIM',
|
||||
noSimBody: 'Insert a SIM card in Settings to make calls.',
|
||||
noRecents: 'No Recent Calls',
|
||||
allCalls: 'All',
|
||||
missedCalls: 'Missed',
|
||||
searchRecents: 'Search Calls',
|
||||
contactDetails: 'Contact Details',
|
||||
unknownCaller: 'Unknown Caller',
|
||||
callHistory: 'Call History',
|
||||
noCallHistory: 'No calls with this number yet.',
|
||||
noContacts: 'No Contacts',
|
||||
favorites: 'Favorites',
|
||||
searchContacts: 'Search Contacts',
|
||||
myCard: 'My Card',
|
||||
myNumber: 'My Number',
|
||||
device: 'Device',
|
||||
contactIndex: 'Contact Index',
|
||||
addContact: 'New Contact',
|
||||
newContact: 'New',
|
||||
editContact: 'Edit Contact',
|
||||
contactName: 'Name',
|
||||
firstName: 'First Name',
|
||||
lastName: 'Last Name',
|
||||
companyOrGroup: 'Company or Group',
|
||||
phoneNumber: 'Phone Number',
|
||||
officialContact: 'Official company contact',
|
||||
choosePhoto: 'Choose Contact Photo',
|
||||
chooseGallery: 'Gallery',
|
||||
takePhoto: 'Camera',
|
||||
removePhoto: 'Remove Photo',
|
||||
call: 'Call',
|
||||
message: 'Message',
|
||||
video: 'Video',
|
||||
mail: 'Mail',
|
||||
defaultLabel: 'Default',
|
||||
privateLabel: 'Private',
|
||||
contactCard: 'Contact Card',
|
||||
removeContact: 'Remove Contact',
|
||||
mobile: 'Mobile',
|
||||
messagesProfile: 'Messages',
|
||||
notes: 'Notes',
|
||||
sendMessage: 'Send Message',
|
||||
shareContact: 'Share Contact',
|
||||
addFavorite: 'Add to Favorites',
|
||||
removeFavorite: 'Remove from Favorites',
|
||||
addEmergency: 'Add to Emergency Contacts',
|
||||
blockContact: 'Block Contact',
|
||||
block: 'Block',
|
||||
blockCaller: 'Block Caller',
|
||||
blockCallerTitle: 'Block this caller?',
|
||||
blockCallerBody: '{number} will no longer be able to call this SIM.',
|
||||
faceTime: 'FaceTime',
|
||||
hideKeypad: 'Hide Keypad',
|
||||
more: 'More',
|
||||
mute: 'Mute',
|
||||
speaker: 'Speaker',
|
||||
viewContact: 'View Contact',
|
||||
calling: 'calling...',
|
||||
incoming: 'Incoming Call',
|
||||
incomingDirection: 'Incoming',
|
||||
@@ -1508,9 +1555,14 @@ const defaultLocales: LocaleTree = {
|
||||
invalid_contact: 'Enter a name and valid phone number.',
|
||||
invalid_number: 'Enter a valid phone number.',
|
||||
invalid_sim: 'The active SIM card is unavailable.',
|
||||
message_unavailable: 'The conversation could not be opened.',
|
||||
contact_remove_failed: 'The contact could not be removed.',
|
||||
contact_favorite_failed: 'The favorite could not be updated.',
|
||||
no_sim: 'This phone has no SIM card.',
|
||||
airplane_mode: 'Turn off Airplane Mode to make calls.',
|
||||
self_call: 'You cannot call your own number.',
|
||||
blocked: 'This number is blocked.',
|
||||
recipient_not_found: 'This number is not known.',
|
||||
busy: 'The line is busy.',
|
||||
company_unavailable: 'This company service line is unavailable.',
|
||||
readonly_contact: 'Official company contacts cannot be changed.',
|
||||
|
||||
@@ -3,18 +3,24 @@ export type SimType = 'registered' | 'anonymous'
|
||||
export type PhoneSim = {
|
||||
id: string
|
||||
number: string
|
||||
removable: boolean
|
||||
registered: boolean
|
||||
type: SimType
|
||||
}
|
||||
|
||||
export type PhoneContact = {
|
||||
avatar_media_id?: number | null
|
||||
avatar_url?: string | null
|
||||
canCall?: boolean
|
||||
canMessage?: boolean
|
||||
companyId?: string
|
||||
created_at?: string
|
||||
favorite?: boolean | number
|
||||
id: string
|
||||
icon?: string
|
||||
name: string
|
||||
notes?: string | null
|
||||
organization?: string | null
|
||||
phone_number: string
|
||||
readonly?: boolean
|
||||
source?: 'personal' | 'company'
|
||||
|
||||
@@ -288,6 +288,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<template v-else-if="billing.detail">
|
||||
<kGlass
|
||||
:highlight="false"
|
||||
class="billing-detail__hero"
|
||||
:class="{
|
||||
'billing-detail__hero--paid': billing.detail.status === 'paid',
|
||||
@@ -441,17 +442,26 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template v-else-if="tab === 'overview' && billing.overview">
|
||||
<div class="billing-summary">
|
||||
<kGlass class="billing-summary__item billing-summary__item--open">
|
||||
<kGlass
|
||||
:highlight="false"
|
||||
class="billing-summary__item billing-summary__item--open"
|
||||
>
|
||||
<ReceiptText :size="19" />
|
||||
<span>{{ t('summary.open') }}</span>
|
||||
<strong>{{ billing.overview.openCount }}</strong>
|
||||
</kGlass>
|
||||
<kGlass class="billing-summary__item billing-summary__item--due">
|
||||
<kGlass
|
||||
:highlight="false"
|
||||
class="billing-summary__item billing-summary__item--due"
|
||||
>
|
||||
<CalendarDays :size="19" />
|
||||
<span>{{ t('summary.due') }}</span>
|
||||
<strong>{{ formatMoney(billing.overview.openTotal) }}</strong>
|
||||
</kGlass>
|
||||
<kGlass class="billing-summary__item billing-summary__item--overdue">
|
||||
<kGlass
|
||||
:highlight="false"
|
||||
class="billing-summary__item billing-summary__item--overdue"
|
||||
>
|
||||
<AlertTriangle :size="19" />
|
||||
<span>{{ t('summary.overdue') }}</span>
|
||||
<strong>{{ billing.overview.overdueCount }}</strong>
|
||||
@@ -743,7 +753,7 @@ onBeforeUnmount(() => {
|
||||
/></span>
|
||||
<h2>{{ t('payment.title') }}</h2>
|
||||
<p>{{ t('payment.body', { issuer: billing.detail.issuerLabel }) }}</p>
|
||||
<kGlass class="billing-payment-total">
|
||||
<kGlass :highlight="false" class="billing-payment-total">
|
||||
<span>{{ billing.detail.title }}</span>
|
||||
<strong>{{
|
||||
formatMoney(billing.detail.amount, billing.detail.currency)
|
||||
|
||||
@@ -118,14 +118,24 @@ function queueCapture(id: string, mediaType: MediaType): void {
|
||||
captures.value = captures.value.slice(0, 6)
|
||||
}
|
||||
|
||||
function devMedia(id: string, mediaType: MediaType): PhoneMedia {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="900" height="1600"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#19354f"/><stop offset="1" stop-color="#d78357"/></linearGradient></defs><rect width="900" height="1600" fill="url(#g)"/><circle cx="680" cy="380" r="210" fill="#ffffff22"/><path d="M0 1200 260 840l180 220 170-170 290 310v400H0z" fill="#102331aa"/></svg>`
|
||||
return {
|
||||
createdAt: Date.now(),
|
||||
id: Number(Date.now()),
|
||||
mediaType,
|
||||
url: `data:image/svg+xml,${encodeURIComponent(svg)}#${id}`,
|
||||
}
|
||||
async function completeDevelopmentCapture(
|
||||
correlationId: string,
|
||||
mediaType: MediaType,
|
||||
): Promise<void> {
|
||||
const response = await nuiCall<PhoneMedia>('media:devCapture', { mediaType })
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: {
|
||||
data: {
|
||||
correlationId,
|
||||
error: response.error,
|
||||
media: response.data,
|
||||
success: response.success,
|
||||
},
|
||||
type: 'media:uploadResult',
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function requestPhoto(): Promise<void> {
|
||||
@@ -138,20 +148,7 @@ async function requestPhoto(): Promise<void> {
|
||||
shutterActive.value = false
|
||||
}, 280)
|
||||
if (isDevelopment) {
|
||||
window.setTimeout(() => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: {
|
||||
data: {
|
||||
correlationId: id,
|
||||
media: devMedia(id, 'photo'),
|
||||
success: true,
|
||||
},
|
||||
type: 'media:uploadResult',
|
||||
},
|
||||
}),
|
||||
)
|
||||
}, 700)
|
||||
window.setTimeout(() => void completeDevelopmentCapture(id, 'photo'), 700)
|
||||
return
|
||||
}
|
||||
await nuiCall('media:requestUpload', {
|
||||
@@ -196,20 +193,7 @@ function stopRecording(): void {
|
||||
savingVideo.value = true
|
||||
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
|
||||
recordingTimer = undefined
|
||||
window.setTimeout(() => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: {
|
||||
data: {
|
||||
correlationId: id,
|
||||
media: devMedia(id, 'video'),
|
||||
success: true,
|
||||
},
|
||||
type: 'media:uploadResult',
|
||||
},
|
||||
}),
|
||||
)
|
||||
}, 900)
|
||||
window.setTimeout(() => void completeDevelopmentCapture(id, 'video'), 900)
|
||||
return
|
||||
}
|
||||
window.postMessage(
|
||||
|
||||
@@ -407,6 +407,8 @@ async function saveContactDetails(): Promise<void> {
|
||||
const response = await calls.saveContact({
|
||||
id: activeContact.value?.id,
|
||||
name: contactNameDraft.value.trim(),
|
||||
notes: activeContact.value?.notes ?? '',
|
||||
organization: activeContact.value?.organization ?? '',
|
||||
phoneNumber: contactNumberDraft.value,
|
||||
})
|
||||
if (!response.success) {
|
||||
|
||||
+3648
-231
File diff suppressed because it is too large
Load Diff
@@ -1337,7 +1337,7 @@ onBeforeUnmount(() => {
|
||||
"
|
||||
/>
|
||||
<k-list-item
|
||||
v-if="phone.device?.sim"
|
||||
v-if="phone.device?.sim?.removable"
|
||||
:title="phone.t('Apps.settings.simType')"
|
||||
:after="
|
||||
phone.t(
|
||||
@@ -1348,7 +1348,7 @@ onBeforeUnmount(() => {
|
||||
"
|
||||
/>
|
||||
<k-list-button
|
||||
v-if="phone.device?.sim"
|
||||
v-if="phone.device?.sim?.removable"
|
||||
@click="simEjectOpened = true"
|
||||
>
|
||||
{{ phone.t('Apps.settings.ejectSim') }}
|
||||
@@ -1610,7 +1610,11 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</k-dialog>
|
||||
|
||||
<k-dialog :opened="simEjectOpened" @backdropclick="simEjectOpened = false">
|
||||
<k-dialog
|
||||
v-if="phone.device?.sim?.removable"
|
||||
:opened="simEjectOpened"
|
||||
@backdropclick="simEjectOpened = false"
|
||||
>
|
||||
<template #title>{{ phone.t('Apps.settings.ejectSim') }}</template>
|
||||
<p>{{ phone.t('Apps.settings.ejectSimBody') }}</p>
|
||||
<template #buttons>
|
||||
|
||||
+544
-76
@@ -1045,8 +1045,60 @@ const mockHousingOverview = {
|
||||
],
|
||||
}
|
||||
|
||||
let contactSequence = 2
|
||||
let contactSequence = 40
|
||||
const contacts = [
|
||||
{
|
||||
avatar_media_id: 1,
|
||||
avatar_url: 'https://picsum.photos/seed/sky-phone-1/600/800',
|
||||
created_at: isoTime(-42 * 86_400_000),
|
||||
favorite: true,
|
||||
id: 'contact-alex',
|
||||
name: 'Alex Rivera',
|
||||
notes: 'Meeting on Friday at 18:00 near the bank.',
|
||||
organization: 'Maze Bank',
|
||||
phone_number: '5551110001',
|
||||
updated_at: isoTime(-6 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-38 * 86_400_000),
|
||||
id: 'contact-alexander',
|
||||
name: 'Alexander Stone',
|
||||
phone_number: '5551110002',
|
||||
updated_at: isoTime(-8 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-21 * 86_400_000),
|
||||
id: 'contact-andre',
|
||||
name: 'Andre Heinicke',
|
||||
phone_number: '5551110003',
|
||||
updated_at: isoTime(-4 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-34 * 86_400_000),
|
||||
favorite: true,
|
||||
id: 'contact-benni',
|
||||
name: 'Benni Parker',
|
||||
notes: 'Call about the Sultan RS repair estimate.',
|
||||
organization: "Benny's Motor Works",
|
||||
phone_number: '5551110004',
|
||||
updated_at: isoTime(-12 * 86_400_000),
|
||||
},
|
||||
{
|
||||
avatar_media_id: 3,
|
||||
avatar_url: 'https://picsum.photos/seed/sky-phone-3/800/600',
|
||||
created_at: isoTime(-18 * 86_400_000),
|
||||
id: 'contact-bryce',
|
||||
name: 'Bryce Walker',
|
||||
phone_number: '5551110005',
|
||||
updated_at: isoTime(-7 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-27 * 86_400_000),
|
||||
id: 'contact-charlie',
|
||||
name: 'Charlie Davis',
|
||||
phone_number: '5551110006',
|
||||
updated_at: isoTime(-11 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: '2026-08-04 12:00:00',
|
||||
id: 'contact-1',
|
||||
@@ -1090,6 +1142,185 @@ const contacts = [
|
||||
source: 'company',
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-14 * 86_400_000),
|
||||
id: 'contact-morgan',
|
||||
name: 'Morgan Reed',
|
||||
phone_number: '5550192847',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-30 * 86_400_000),
|
||||
id: 'contact-jamie',
|
||||
name: 'Jamie Chen',
|
||||
phone_number: '5559876543',
|
||||
updated_at: isoTime(-30 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-5 * 86_400_000),
|
||||
id: 'contact-mechanic',
|
||||
name: 'Downtown Customs',
|
||||
phone_number: '5550100101',
|
||||
updated_at: isoTime(-5 * 86_400_000),
|
||||
},
|
||||
{
|
||||
avatar_media_id: 4,
|
||||
avatar_url: 'https://picsum.photos/seed/sky-phone-4/800/600',
|
||||
created_at: isoTime(-3 * 86_400_000),
|
||||
favorite: true,
|
||||
id: 'contact-taxi',
|
||||
name: 'Los Santos Taxi',
|
||||
organization: 'Los Santos Taxi Co.',
|
||||
phone_number: '5552222222',
|
||||
updated_at: isoTime(-3 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-22 * 86_400_000),
|
||||
id: 'contact-daniel',
|
||||
name: 'Daniel Price',
|
||||
phone_number: '5551110007',
|
||||
updated_at: isoTime(-3 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-20 * 86_400_000),
|
||||
id: 'contact-emily',
|
||||
name: 'Emily Hart',
|
||||
phone_number: '5551110008',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-31 * 86_400_000),
|
||||
id: 'contact-franklin',
|
||||
name: 'Franklin Miles',
|
||||
phone_number: '5551110009',
|
||||
updated_at: isoTime(-9 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-25 * 86_400_000),
|
||||
id: 'contact-grace',
|
||||
name: 'Grace Carter',
|
||||
phone_number: '5551110010',
|
||||
updated_at: isoTime(-4 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-19 * 86_400_000),
|
||||
id: 'contact-hannah',
|
||||
name: 'Hannah Brooks',
|
||||
phone_number: '5551110011',
|
||||
updated_at: isoTime(-5 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-17 * 86_400_000),
|
||||
id: 'contact-ivan',
|
||||
name: 'Ivan Petrov',
|
||||
phone_number: '5551110012',
|
||||
updated_at: isoTime(-6 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-16 * 86_400_000),
|
||||
id: 'contact-kevin',
|
||||
name: 'Kevin Adams',
|
||||
phone_number: '5551110013',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-15 * 86_400_000),
|
||||
id: 'contact-naomi',
|
||||
name: 'Naomi King',
|
||||
phone_number: '5551110014',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-13 * 86_400_000),
|
||||
id: 'contact-olivia',
|
||||
name: 'Olivia Moore',
|
||||
phone_number: '5551110015',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-12 * 86_400_000),
|
||||
id: 'contact-paul',
|
||||
name: 'Paul Walker',
|
||||
phone_number: '5551110016',
|
||||
updated_at: isoTime(-4 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-11 * 86_400_000),
|
||||
id: 'contact-quinn',
|
||||
name: 'Quinn Bailey',
|
||||
phone_number: '5551110017',
|
||||
updated_at: isoTime(-3 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-10 * 86_400_000),
|
||||
id: 'contact-riley',
|
||||
name: 'Riley Cooper',
|
||||
phone_number: '5551110018',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-9 * 86_400_000),
|
||||
id: 'contact-sofia',
|
||||
name: 'Sofia Bennett',
|
||||
phone_number: '5551110019',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-8 * 86_400_000),
|
||||
id: 'contact-thomas',
|
||||
name: 'Thomas Reed',
|
||||
phone_number: '5551110020',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-7 * 86_400_000),
|
||||
id: 'contact-ursula',
|
||||
name: 'Ursula Grant',
|
||||
phone_number: '5551110021',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-6 * 86_400_000),
|
||||
id: 'contact-victor',
|
||||
name: 'Victor Young',
|
||||
phone_number: '5551110022',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-5 * 86_400_000),
|
||||
id: 'contact-wendy',
|
||||
name: 'Wendy Clark',
|
||||
phone_number: '5551110023',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-4 * 86_400_000),
|
||||
id: 'contact-xavier',
|
||||
name: 'Xavier Cole',
|
||||
phone_number: '5551110024',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-3 * 86_400_000),
|
||||
id: 'contact-yakup',
|
||||
name: 'Yakup Broooooo',
|
||||
phone_number: '5551110025',
|
||||
updated_at: isoTime(-86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-2 * 86_400_000),
|
||||
id: 'contact-zoe',
|
||||
name: 'Zoe Martinez',
|
||||
phone_number: '5551110026',
|
||||
updated_at: isoTime(-86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-86_400_000),
|
||||
id: 'contact-market',
|
||||
name: '24/7 Supermarket',
|
||||
phone_number: '5552470000',
|
||||
updated_at: isoTime(-3_600_000),
|
||||
},
|
||||
]
|
||||
const attachmentAssets = {
|
||||
gif: new Set(['celebrate', 'hearts', 'party', 'thumbs_up', 'wow']),
|
||||
@@ -1219,6 +1450,126 @@ const smsMessages = [
|
||||
recipient_number: '5558675309',
|
||||
sender_number: '5551234567',
|
||||
},
|
||||
{
|
||||
body: 'Das Fahrzeug ist fertig. Du kannst es jederzeit abholen.',
|
||||
created_at: isoTime(-26 * 60 * 60_000),
|
||||
direction: 'received',
|
||||
id: 'sms-customs-1',
|
||||
media_duration_ms: null,
|
||||
media_mime: null,
|
||||
media_payload: null,
|
||||
media_waveform: null,
|
||||
message_type: 'text',
|
||||
media_asset_id: null,
|
||||
read_at: isoTime(-25 * 60 * 60_000),
|
||||
recipient_number: '5551234567',
|
||||
sender_number: '5550100101',
|
||||
},
|
||||
{
|
||||
body: 'Perfekt, ich komme heute Abend vorbei.',
|
||||
created_at: isoTime(-25 * 60 * 60_000),
|
||||
direction: 'sent',
|
||||
id: 'sms-customs-2',
|
||||
media_duration_ms: null,
|
||||
media_mime: null,
|
||||
media_payload: null,
|
||||
media_waveform: null,
|
||||
message_type: 'text',
|
||||
media_asset_id: null,
|
||||
read_at: isoTime(-25 * 60 * 60_000),
|
||||
recipient_number: '5550100101',
|
||||
sender_number: '5551234567',
|
||||
},
|
||||
{
|
||||
body: 'Treffen wir uns um 20 Uhr am Casino?',
|
||||
created_at: isoTime(-7 * 60 * 60_000),
|
||||
direction: 'received',
|
||||
id: 'sms-morgan-1',
|
||||
media_duration_ms: null,
|
||||
media_mime: null,
|
||||
media_payload: null,
|
||||
media_waveform: null,
|
||||
message_type: 'text',
|
||||
media_asset_id: null,
|
||||
read_at: isoTime(-6 * 60 * 60_000),
|
||||
recipient_number: '5551234567',
|
||||
sender_number: '5550192847',
|
||||
},
|
||||
{
|
||||
body: 'Ja, passt. Ich bin puenktlich da.',
|
||||
created_at: isoTime(-6 * 60 * 60_000),
|
||||
direction: 'sent',
|
||||
id: 'sms-morgan-2',
|
||||
media_duration_ms: null,
|
||||
media_mime: null,
|
||||
media_payload: null,
|
||||
media_waveform: null,
|
||||
message_type: 'text',
|
||||
media_asset_id: null,
|
||||
read_at: isoTime(-6 * 60 * 60_000),
|
||||
recipient_number: '5550192847',
|
||||
sender_number: '5551234567',
|
||||
},
|
||||
{
|
||||
body: 'Bin in zehn Minuten bei dir.',
|
||||
created_at: isoTime(-95 * 60_000),
|
||||
direction: 'received',
|
||||
id: 'sms-jamie-1',
|
||||
media_duration_ms: null,
|
||||
media_mime: null,
|
||||
media_payload: null,
|
||||
media_waveform: null,
|
||||
message_type: 'text',
|
||||
media_asset_id: null,
|
||||
read_at: null,
|
||||
recipient_number: '5551234567',
|
||||
sender_number: '5559876543',
|
||||
},
|
||||
{
|
||||
body: 'Dein Taxi wartet vor dem Haupteingang.',
|
||||
created_at: isoTime(-38 * 60_000),
|
||||
direction: 'received',
|
||||
id: 'sms-taxi-1',
|
||||
media_duration_ms: null,
|
||||
media_mime: null,
|
||||
media_payload: null,
|
||||
media_waveform: null,
|
||||
message_type: 'text',
|
||||
media_asset_id: null,
|
||||
read_at: null,
|
||||
recipient_number: '5551234567',
|
||||
sender_number: '5552222222',
|
||||
},
|
||||
{
|
||||
body: 'Danke, ich komme sofort raus.',
|
||||
created_at: isoTime(-36 * 60_000),
|
||||
direction: 'sent',
|
||||
id: 'sms-taxi-2',
|
||||
media_duration_ms: null,
|
||||
media_mime: null,
|
||||
media_payload: null,
|
||||
media_waveform: null,
|
||||
message_type: 'text',
|
||||
media_asset_id: null,
|
||||
read_at: isoTime(-35 * 60_000),
|
||||
recipient_number: '5552222222',
|
||||
sender_number: '5551234567',
|
||||
},
|
||||
{
|
||||
body: 'Denk bitte an die Unterlagen fuer morgen.',
|
||||
created_at: isoTime(-12 * 60_000),
|
||||
direction: 'received',
|
||||
id: 'sms-alex-1',
|
||||
media_duration_ms: null,
|
||||
media_mime: null,
|
||||
media_payload: null,
|
||||
media_waveform: null,
|
||||
message_type: 'text',
|
||||
media_asset_id: null,
|
||||
read_at: null,
|
||||
recipient_number: '5551234567',
|
||||
sender_number: '5551110001',
|
||||
},
|
||||
]
|
||||
const darkChatProfile = {
|
||||
id: 1,
|
||||
@@ -1663,73 +2014,143 @@ const deviceData = {
|
||||
}
|
||||
let mockPasscode = ''
|
||||
let mockSecurity = { enabled: false, length: null, lockedUntil: 0 }
|
||||
let mockContacts = [
|
||||
{
|
||||
created_at: isoTime(-14 * 86_400_000),
|
||||
id: 'contact-morgan',
|
||||
name: 'Morgan Reed',
|
||||
phone_number: '5550192847',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-30 * 86_400_000),
|
||||
id: 'contact-jamie',
|
||||
name: 'Jamie Chen',
|
||||
phone_number: '5559876543',
|
||||
updated_at: isoTime(-30 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-5 * 86_400_000),
|
||||
id: 'contact-mechanic',
|
||||
name: 'Downtown Customs',
|
||||
phone_number: '5550100101',
|
||||
updated_at: isoTime(-5 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-3 * 86_400_000),
|
||||
id: 'contact-taxi',
|
||||
name: 'Los Santos Taxi',
|
||||
phone_number: '5552222222',
|
||||
updated_at: isoTime(-3 * 86_400_000),
|
||||
},
|
||||
]
|
||||
const blockedCallNumbers = new Set()
|
||||
let recentCalls = [
|
||||
{
|
||||
call_id: 'call-alex-incoming',
|
||||
created_at: isoTime(-8 * 60_000),
|
||||
direction: 'incoming',
|
||||
duration_seconds: 184,
|
||||
id: 1,
|
||||
other_number: '5551110001',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
call_id: 'call-morgan-incoming',
|
||||
created_at: isoTime(-18 * 60_000),
|
||||
direction: 'incoming',
|
||||
duration_seconds: 246,
|
||||
id: 1,
|
||||
id: 2,
|
||||
other_number: '5550192847',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
call_id: 'call-taxi-outgoing',
|
||||
created_at: isoTime(-42 * 60_000),
|
||||
direction: 'outgoing',
|
||||
duration_seconds: 39,
|
||||
id: 3,
|
||||
other_number: '5552222222',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
call_id: 'call-jamie-missed',
|
||||
created_at: isoTime(-95 * 60_000),
|
||||
direction: 'incoming',
|
||||
duration_seconds: 0,
|
||||
id: 2,
|
||||
id: 4,
|
||||
other_number: '5559876543',
|
||||
status: 'missed',
|
||||
},
|
||||
{
|
||||
call_id: 'call-emily-no-answer',
|
||||
created_at: isoTime(-4 * 60 * 60_000),
|
||||
direction: 'outgoing',
|
||||
duration_seconds: 0,
|
||||
id: 5,
|
||||
other_number: '5551110008',
|
||||
status: 'no_answer',
|
||||
},
|
||||
{
|
||||
call_id: 'call-yakup-incoming',
|
||||
created_at: isoTime(-8 * 60 * 60_000),
|
||||
direction: 'incoming',
|
||||
duration_seconds: 521,
|
||||
id: 6,
|
||||
other_number: '5551110025',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
call_id: 'call-customs-outgoing',
|
||||
created_at: isoTime(-25 * 60 * 60_000),
|
||||
direction: 'outgoing',
|
||||
duration_seconds: 83,
|
||||
id: 3,
|
||||
id: 7,
|
||||
other_number: '5550100101',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
call_id: 'call-unknown-declined',
|
||||
created_at: isoTime(-2 * 86_400_000),
|
||||
created_at: isoTime(-27 * 60 * 60_000),
|
||||
direction: 'incoming',
|
||||
duration_seconds: 0,
|
||||
id: 4,
|
||||
id: 8,
|
||||
other_number: '5554040404',
|
||||
status: 'declined',
|
||||
},
|
||||
{
|
||||
call_id: 'call-morgan-outgoing',
|
||||
created_at: isoTime(-2 * 86_400_000),
|
||||
direction: 'outgoing',
|
||||
duration_seconds: 72,
|
||||
id: 9,
|
||||
other_number: '5550192847',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
call_id: 'call-alex-missed',
|
||||
created_at: isoTime(-3 * 86_400_000),
|
||||
direction: 'incoming',
|
||||
duration_seconds: 0,
|
||||
id: 10,
|
||||
other_number: '5551110001',
|
||||
status: 'missed',
|
||||
},
|
||||
{
|
||||
call_id: 'call-benni-busy',
|
||||
created_at: isoTime(-4 * 86_400_000),
|
||||
direction: 'outgoing',
|
||||
duration_seconds: 0,
|
||||
id: 11,
|
||||
other_number: '5551110004',
|
||||
status: 'busy',
|
||||
},
|
||||
{
|
||||
call_id: 'call-sofia-incoming',
|
||||
created_at: isoTime(-5 * 86_400_000),
|
||||
direction: 'incoming',
|
||||
duration_seconds: 116,
|
||||
id: 12,
|
||||
other_number: '5551110019',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
call_id: 'call-xavier-unavailable',
|
||||
created_at: isoTime(-6 * 86_400_000),
|
||||
direction: 'outgoing',
|
||||
duration_seconds: 0,
|
||||
id: 13,
|
||||
other_number: '5551110024',
|
||||
status: 'unavailable',
|
||||
},
|
||||
{
|
||||
call_id: 'call-market-outgoing',
|
||||
created_at: isoTime(-7 * 86_400_000),
|
||||
direction: 'outgoing',
|
||||
duration_seconds: 51,
|
||||
id: 14,
|
||||
other_number: '5552470000',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
call_id: 'call-unknown-missed',
|
||||
created_at: isoTime(-8 * 86_400_000),
|
||||
direction: 'incoming',
|
||||
duration_seconds: 0,
|
||||
id: 15,
|
||||
other_number: '5559090909',
|
||||
status: 'missed',
|
||||
},
|
||||
]
|
||||
let mockMedia = [
|
||||
{
|
||||
@@ -6044,6 +6465,7 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
sim: {
|
||||
id: 'development-sim',
|
||||
number: '5551234567',
|
||||
removable: true,
|
||||
registered: true,
|
||||
type: 'registered',
|
||||
},
|
||||
@@ -6175,8 +6597,20 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
}
|
||||
if (endpoint === 'contacts:save') {
|
||||
const name = String(request.body.name ?? '').trim()
|
||||
const notes = String(request.body.notes ?? '')
|
||||
.trim()
|
||||
.slice(0, 500)
|
||||
const organization = String(request.body.organization ?? '')
|
||||
.trim()
|
||||
.slice(0, 80)
|
||||
const phoneNumber = String(request.body.phoneNumber ?? '').trim()
|
||||
if (!name || !phoneNumber) {
|
||||
const avatarMediaId = Number(request.body.avatarMediaId) || 0
|
||||
const avatarMedia = avatarMediaId
|
||||
? mockMedia.find(
|
||||
(item) => item.id === avatarMediaId && item.mediaType === 'photo',
|
||||
)
|
||||
: null
|
||||
if (!name || !phoneNumber || (avatarMediaId && !avatarMedia)) {
|
||||
response.json({ success: false, error: 'invalid_contact' })
|
||||
return
|
||||
}
|
||||
@@ -6187,7 +6621,11 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
}
|
||||
if (contact) {
|
||||
contact.name = name
|
||||
contact.notes = notes || null
|
||||
contact.organization = organization || null
|
||||
contact.phone_number = phoneNumber
|
||||
contact.avatar_media_id = avatarMedia?.id ?? null
|
||||
contact.avatar_url = avatarMedia?.url ?? null
|
||||
contact.updated_at = new Date()
|
||||
.toISOString()
|
||||
.slice(0, 19)
|
||||
@@ -6196,9 +6634,14 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
const now = new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||
contact = {
|
||||
created_at: now,
|
||||
favorite: false,
|
||||
id: `contact-${contactSequence++}`,
|
||||
name,
|
||||
notes: notes || null,
|
||||
organization: organization || null,
|
||||
phone_number: phoneNumber,
|
||||
avatar_media_id: avatarMedia?.id ?? null,
|
||||
avatar_url: avatarMedia?.url ?? null,
|
||||
updated_at: now,
|
||||
}
|
||||
contacts.push(contact)
|
||||
@@ -6206,6 +6649,20 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
response.json({ success: true, data: contact })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'contacts:favorite') {
|
||||
const contact = contacts.find((item) => item.id === request.body.id)
|
||||
if (!contact || typeof request.body.favorite !== 'boolean') {
|
||||
response.json({ success: false, error: 'contact_not_found' })
|
||||
return
|
||||
}
|
||||
contact.favorite = request.body.favorite
|
||||
contact.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||
response.json({
|
||||
success: true,
|
||||
data: { favorite: contact.favorite, id: contact.id },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'contacts:delete') {
|
||||
const index = contacts.findIndex((item) => item.id === request.body.id)
|
||||
if (index >= 0 && contacts[index].readonly) {
|
||||
@@ -6220,6 +6677,27 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
response.json({ success: true, data: { videoBitrateKbps: 1500 } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'media:devCapture') {
|
||||
const mediaType = request.body.mediaType === 'video' ? 'video' : 'photo'
|
||||
const id = Math.max(0, ...mockMedia.map((item) => Number(item.id) || 0)) + 1
|
||||
const fallbackVideo = mockMedia.find((item) => item.mediaType === 'video')
|
||||
const media = {
|
||||
createdAt: Date.now(),
|
||||
id,
|
||||
mediaType,
|
||||
url:
|
||||
mediaType === 'photo'
|
||||
? `https://picsum.photos/seed/sky-camera-${id}/900/1600`
|
||||
: fallbackVideo?.url,
|
||||
}
|
||||
if (!media.url) {
|
||||
response.json({ success: false, error: 'unsupported' })
|
||||
return
|
||||
}
|
||||
mockMedia.unshift(media)
|
||||
response.json({ success: true, data: media })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'gallery:list') {
|
||||
if (request.body.mockState === 'error') {
|
||||
response.json({ success: false, error: 'service_unavailable' })
|
||||
@@ -6344,55 +6822,32 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'contacts:list') {
|
||||
response.json({ success: true, data: mockContacts })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'contacts:save') {
|
||||
const now = new Date().toISOString()
|
||||
const existing = mockContacts.find(
|
||||
(contact) => contact.id === request.body.id,
|
||||
)
|
||||
if (existing) {
|
||||
Object.assign(existing, {
|
||||
name: request.body.name,
|
||||
phone_number: request.body.phoneNumber,
|
||||
updated_at: now,
|
||||
})
|
||||
response.json({ success: true, data: existing })
|
||||
return
|
||||
}
|
||||
const contact = {
|
||||
created_at: now,
|
||||
id: `contact-${Date.now()}`,
|
||||
name: request.body.name,
|
||||
phone_number: request.body.phoneNumber,
|
||||
updated_at: now,
|
||||
}
|
||||
mockContacts.push(contact)
|
||||
response.json({ success: true, data: contact })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'contacts:delete') {
|
||||
mockContacts = mockContacts.filter(
|
||||
(contact) => contact.id !== request.body.id,
|
||||
)
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'calls:recents') {
|
||||
response.json({ success: true, data: recentCalls })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'calls:dial') {
|
||||
const phoneNumber = String(request.body.phoneNumber ?? '').replace(
|
||||
/\D/g,
|
||||
'',
|
||||
)
|
||||
if (phoneNumber.length !== 10) {
|
||||
response.json({ success: false, error: 'invalid_number' })
|
||||
return
|
||||
}
|
||||
if (!contacts.some((contact) => contact.phone_number === phoneNumber)) {
|
||||
response.json({ success: false, error: 'recipient_not_found' })
|
||||
return
|
||||
}
|
||||
const id = `call-${Date.now()}`
|
||||
const startedAt = Date.now()
|
||||
recentCalls.unshift({
|
||||
call_id: id,
|
||||
created_at: Date.now(),
|
||||
direction: 'outgoing',
|
||||
duration_seconds: 0,
|
||||
id: recentCalls.length + 1,
|
||||
other_number: request.body.phoneNumber,
|
||||
other_number: phoneNumber,
|
||||
status: 'completed',
|
||||
})
|
||||
response.json({
|
||||
@@ -6400,13 +6855,26 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
data: {
|
||||
direction: 'outgoing',
|
||||
id,
|
||||
otherNumber: request.body.phoneNumber,
|
||||
startedAt: Date.now(),
|
||||
otherNumber: phoneNumber,
|
||||
startedAt,
|
||||
state: 'ringing',
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'calls:block') {
|
||||
const phoneNumber = String(request.body.phoneNumber ?? '').replace(
|
||||
/\D/g,
|
||||
'',
|
||||
)
|
||||
if (!phoneNumber) {
|
||||
response.json({ success: false, error: 'invalid_number' })
|
||||
return
|
||||
}
|
||||
blockedCallNumbers.add(phoneNumber)
|
||||
response.json({ success: true, data: { blocked: true, phoneNumber } })
|
||||
return
|
||||
}
|
||||
if (
|
||||
endpoint === 'calls:answer' ||
|
||||
endpoint === 'calls:decline' ||
|
||||
|
||||
@@ -15,6 +15,7 @@ Config.Command = "phone"
|
||||
|
||||
Config.Phone = {
|
||||
Item = "phone",
|
||||
Unique = true, -- true: data follows each phone item; false: one persistent phone per character
|
||||
DevelopmentCommand = true,
|
||||
DeviceName = "iFruit Phone",
|
||||
}
|
||||
@@ -44,6 +45,7 @@ Config.Security = {
|
||||
}
|
||||
|
||||
Config.Sim = {
|
||||
Enabled = true, -- false: devices without a SIM receive a persistent random number automatically
|
||||
RegisteredItem = "sky_phone_sim_registered",
|
||||
AnonymousItem = "sky_phone_sim_anonymous",
|
||||
NumberLength = 10,
|
||||
@@ -55,6 +57,7 @@ Config.Calls = {
|
||||
VoiceProvider = "pma",
|
||||
RingSeconds = 30,
|
||||
ContactNameMaxLength = 80,
|
||||
ContactNotesMaxLength = 500,
|
||||
RecentPageSize = 100,
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ Locales["en"] = {
|
||||
phone_stacked = "Phones cannot be stacked.",
|
||||
invalid_imei = "This phone has invalid IMEI metadata.",
|
||||
metadata_unsupported = "The configured inventory cannot store unique phone metadata.",
|
||||
player_unavailable = "Your character identity is not available.",
|
||||
sim_slot_missing = "The used SIM card could not be identified.",
|
||||
sim_stacked = "SIM cards cannot be stacked.",
|
||||
invalid_sim = "This SIM card has invalid metadata.",
|
||||
@@ -685,9 +686,17 @@ Locales["en"] = {
|
||||
phone = {
|
||||
name = "Phone", recents = "Recents", contacts = "Contacts", keypad = "Keypad",
|
||||
noSim = "No SIM", noSimBody = "Insert a SIM card in Settings to make calls.",
|
||||
noRecents = "No Recent Calls", noContacts = "No Contacts", searchContacts = "Search Contacts",
|
||||
addContact = "New Contact", editContact = "Edit Contact", contactName = "Name", phoneNumber = "Phone Number",
|
||||
noRecents = "No Recent Calls", noContacts = "No Contacts", searchContacts = "Search Contacts", favorites = "Favorites",
|
||||
addContact = "New Contact", newContact = "New", editContact = "Edit Contact", contactName = "Name", firstName = "First Name", lastName = "Last Name", companyOrGroup = "Company or Group", phoneNumber = "Phone Number",
|
||||
officialContact = "Official company contact",
|
||||
choosePhoto = "Choose Contact Photo", chooseGallery = "Gallery", takePhoto = "Camera", removePhoto = "Remove Photo",
|
||||
message = "Message", video = "Video", mail = "Mail", defaultLabel = "Default", privateLabel = "Private",
|
||||
contactCard = "Contact Card", removeContact = "Remove Contact", returnToCall = "Return to Call", mobile = "Mobile", messagesProfile = "Messages", notes = "Notes",
|
||||
sendMessage = "Send Message", shareContact = "Share Contact", addFavorite = "Add to Favorites", removeFavorite = "Remove from Favorites",
|
||||
addEmergency = "Add to Emergency Contacts", blockContact = "Block Contact",
|
||||
block = "Block", blockCaller = "Block Caller", blockCallerTitle = "Block this caller?",
|
||||
blockCallerBody = "{number} will no longer be able to call this SIM.",
|
||||
faceTime = "FaceTime", hideKeypad = "Hide Keypad", more = "More", mute = "Mute", speaker = "Speaker", viewContact = "View Contact",
|
||||
call = "Call", calling = "calling...", incoming = "Incoming Call", incomingDirection = "Incoming", outgoingDirection = "Outgoing", connected = "Connected",
|
||||
missed = "Missed", declined = "Declined", busy = "Busy", unavailable = "Unavailable",
|
||||
noAnswer = "No Answer", cancelled = "Cancelled", disconnected = "Disconnected", sim_removed = "SIM Removed", completed = "Call Ended",
|
||||
@@ -702,6 +711,8 @@ Locales["en"] = {
|
||||
self_call = "You cannot call your own number.", busy = "The line is busy.",
|
||||
company_unavailable = "This company service line is unavailable.",
|
||||
readonly_contact = "Official company contacts cannot be changed.",
|
||||
message_unavailable = "The conversation could not be opened.", contact_remove_failed = "The contact could not be removed.", contact_favorite_failed = "The favorite could not be updated.",
|
||||
blocked = "This number is blocked.", recipient_not_found = "This number is not known.",
|
||||
rate_limited = "Too many calls. Try again in a minute.", voice_unavailable = "The configured phone voice service is unavailable.",
|
||||
inventory_full = "There is no room for the ejected SIM card.", request_failed = "The phone request failed.",
|
||||
operation_in_progress = "Another phone operation is already in progress.", sim_request_expired = "The SIM selection expired. Use the SIM card again.",
|
||||
|
||||
@@ -9,11 +9,26 @@ end
|
||||
local ESX = exports["es_extended"]:getSharedObject()
|
||||
local metadata_key = "sky_phone_inventory"
|
||||
|
||||
ESX.AddItems({
|
||||
local inventory_items = {
|
||||
{ name = Config.Phone.Item, label = Config.Phone.DeviceName, weight = 100, rare = false, canRemove = true },
|
||||
{ name = Config.Sim.RegisteredItem, label = "Registered SIM", weight = 5, rare = false, canRemove = true },
|
||||
{ name = Config.Sim.AnonymousItem, label = "Anonymous SIM", weight = 5, rare = false, canRemove = true },
|
||||
})
|
||||
}
|
||||
if Config.Sim.Enabled ~= false then
|
||||
inventory_items[#inventory_items + 1] = {
|
||||
name = Config.Sim.RegisteredItem,
|
||||
label = "Registered SIM",
|
||||
weight = 5,
|
||||
rare = false,
|
||||
canRemove = true,
|
||||
}
|
||||
inventory_items[#inventory_items + 1] = {
|
||||
name = Config.Sim.AnonymousItem,
|
||||
label = "Anonymous SIM",
|
||||
weight = 5,
|
||||
rare = false,
|
||||
canRemove = true,
|
||||
}
|
||||
end
|
||||
ESX.AddItems(inventory_items)
|
||||
|
||||
local function get_player(source)
|
||||
return ESX.GetPlayerFromId(source)
|
||||
|
||||
@@ -194,11 +194,13 @@ local server_callbacks = {
|
||||
"contacts:list",
|
||||
"contacts:save",
|
||||
"contacts:delete",
|
||||
"contacts:favorite",
|
||||
"calls:recents",
|
||||
"calls:dial",
|
||||
"calls:answer",
|
||||
"calls:decline",
|
||||
"calls:hangup",
|
||||
"calls:block",
|
||||
"banking:overview",
|
||||
"banking:transfer",
|
||||
"billing:overview",
|
||||
|
||||
@@ -333,8 +333,8 @@ function SkyPhoneCalls.CopyCloudToDevice(account_id, imei)
|
||||
{
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_contacts`
|
||||
(`id`, `contact_id`, `device_imei`, `name`, `phone_number`, `created_at`, `updated_at`)
|
||||
SELECT UUID(), `contact_id`, ?, `name`, `phone_number`, `created_at`, `updated_at`
|
||||
(`id`, `contact_id`, `device_imei`, `name`, `notes`, `organization`, `phone_number`, `avatar_media_id`, `favorite`, `created_at`, `updated_at`)
|
||||
SELECT UUID(), `contact_id`, ?, `name`, `notes`, `organization`, `phone_number`, NULL, `favorite`, `created_at`, `updated_at`
|
||||
FROM `sky_phone_contacts` WHERE `account_id` = ?
|
||||
]],
|
||||
params = { imei, account_id },
|
||||
@@ -358,7 +358,9 @@ Bridge.Callbacks.Register("sky_phone:contacts:list", function(source)
|
||||
end
|
||||
local condition, params = scope_condition(scope)
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT `contact_id` AS `id`, `name`, `phone_number`, `created_at`, `updated_at`
|
||||
SELECT `contact_id` AS `id`, `name`, `notes`, `organization`, `phone_number`, `avatar_media_id`, `favorite`,
|
||||
(SELECT media.`url` FROM `sky_phone_media` media WHERE media.`id` = `avatar_media_id`) AS `avatar_url`,
|
||||
`created_at`, `updated_at`
|
||||
FROM `sky_phone_contacts` WHERE %s ORDER BY LOWER(`name`), `phone_number`
|
||||
]]):format(condition), params)
|
||||
local system_contacts = SkyPhoneCompanies.GetSystemContacts()
|
||||
@@ -393,8 +395,11 @@ Bridge.Callbacks.Register("sky_phone:contacts:save", function(source, data)
|
||||
return error_response
|
||||
end
|
||||
local name = trim(data.name)
|
||||
local notes = trim(data.notes) or ""
|
||||
local organization = trim(data.organization) or ""
|
||||
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
|
||||
if not name or name == "" or #name > Config.Calls.ContactNameMaxLength or not number then
|
||||
local avatar_media_id = tonumber(data.avatarMediaId) or 0
|
||||
if not name or name == "" or #name > Config.Calls.ContactNameMaxLength or #notes > Config.Calls.ContactNotesMaxLength or #organization > Config.Calls.ContactNameMaxLength or not number then
|
||||
return { success = false, error = "invalid_contact" }
|
||||
end
|
||||
if (type(data.id) == "string" and data.id:sub(1, 8) == "company:")
|
||||
@@ -407,6 +412,17 @@ Bridge.Callbacks.Register("sky_phone:contacts:save", function(source, data)
|
||||
)
|
||||
return { success = false, error = "readonly_contact" }
|
||||
end
|
||||
if avatar_media_id < 0 or avatar_media_id ~= math.floor(avatar_media_id) then
|
||||
return { success = false, error = "invalid_contact" }
|
||||
end
|
||||
local avatar_url
|
||||
if avatar_media_id > 0 then
|
||||
local media_error
|
||||
avatar_url, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, tostring(avatar_media_id), "photo")
|
||||
if not avatar_url then
|
||||
return { success = false, error = media_error }
|
||||
end
|
||||
end
|
||||
local condition, condition_params = scope_condition(scope)
|
||||
local id = type(data.id) == "string" and data.id or uuid()
|
||||
if data.id then
|
||||
@@ -418,24 +434,63 @@ Bridge.Callbacks.Register("sky_phone:contacts:save", function(source, data)
|
||||
if not owned[1] then
|
||||
return { success = false, error = "contact_not_found" }
|
||||
end
|
||||
local params = { name, number, id }
|
||||
local params = { name, notes, organization, number, avatar_media_id, id }
|
||||
for _, value in ipairs(condition_params) do
|
||||
params[#params + 1] = value
|
||||
end
|
||||
Bridge.Database.Query(([[
|
||||
UPDATE `sky_phone_contacts` SET `name` = ?, `phone_number` = ?
|
||||
UPDATE `sky_phone_contacts` SET `name` = ?, `notes` = NULLIF(?, ''), `organization` = NULLIF(?, ''), `phone_number` = ?, `avatar_media_id` = NULLIF(?, 0)
|
||||
WHERE `contact_id` = ? AND %s
|
||||
]]):format(condition), params)
|
||||
else
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_contacts` (`id`, `contact_id`, `account_id`, `device_imei`, `name`, `phone_number`)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
]], { uuid(), id, scope.account_id, scope.device_imei, name, number })
|
||||
INSERT INTO `sky_phone_contacts` (`id`, `contact_id`, `account_id`, `device_imei`, `name`, `notes`, `organization`, `phone_number`, `avatar_media_id`)
|
||||
VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), ?, NULLIF(?, 0))
|
||||
]], { uuid(), id, scope.account_id, scope.device_imei, name, notes, organization, number, avatar_media_id })
|
||||
end
|
||||
if scope.account_id then
|
||||
SkyPhone.NotifyAccount(scope.account_id, "sky_phone:contacts:changed", {})
|
||||
end
|
||||
return { success = true, data = { id = id, name = name, phone_number = number } }
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
id = id,
|
||||
name = name,
|
||||
notes = notes ~= "" and notes or nil,
|
||||
organization = organization ~= "" and organization or nil,
|
||||
phone_number = number,
|
||||
avatar_media_id = avatar_media_id > 0 and avatar_media_id or nil,
|
||||
avatar_url = avatar_url,
|
||||
},
|
||||
}
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:contacts:favorite", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "contact_favorite", 60, 60) or type(data) ~= "table" or type(data.id) ~= "string" or type(data.favorite) ~= "boolean" then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local scope, error_response = current_scope(source)
|
||||
if not scope then
|
||||
return error_response
|
||||
end
|
||||
local condition, condition_params = scope_condition(scope)
|
||||
local owned_params = { data.id }
|
||||
for _, value in ipairs(condition_params) do
|
||||
owned_params[#owned_params + 1] = value
|
||||
end
|
||||
local owned = Bridge.Database.Query(("SELECT `id` FROM `sky_phone_contacts` WHERE `contact_id` = ? AND %s LIMIT 1"):format(condition), owned_params)
|
||||
if not owned[1] then
|
||||
return { success = false, error = "contact_not_found" }
|
||||
end
|
||||
local params = { data.favorite and 1 or 0, data.id }
|
||||
for _, value in ipairs(condition_params) do
|
||||
params[#params + 1] = value
|
||||
end
|
||||
Bridge.Database.Query(("UPDATE `sky_phone_contacts` SET `favorite` = ? WHERE `contact_id` = ? AND %s"):format(condition), params)
|
||||
if scope.account_id then
|
||||
SkyPhone.NotifyAccount(scope.account_id, "sky_phone:contacts:changed", {})
|
||||
end
|
||||
return { success = true, data = { id = data.id, favorite = data.favorite } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:contacts:delete", function(source, data)
|
||||
@@ -728,10 +783,19 @@ handle_no_answer = function(call)
|
||||
end
|
||||
end
|
||||
|
||||
local function lock_direct_target(caller_source, target)
|
||||
local function lock_direct_target(caller_source, target, caller_sim_id)
|
||||
if not target or not target.imei then
|
||||
return nil, "unavailable"
|
||||
end
|
||||
if caller_sim_id then
|
||||
local blocks = Bridge.Database.Query([[
|
||||
SELECT 1 FROM `sky_phone_call_blocks`
|
||||
WHERE `blocker_sim_id` = ? AND `blocked_sim_id` = ? LIMIT 1
|
||||
]], { target.id, caller_sim_id })
|
||||
if blocks[1] then
|
||||
return nil, "declined"
|
||||
end
|
||||
end
|
||||
local callee_source = find_device_holder(target.imei)
|
||||
if not callee_source or callee_source == caller_source then
|
||||
return nil, "unavailable"
|
||||
@@ -805,7 +869,7 @@ function SkyPhoneCalls.StartCompanyCall(source, company_id, customer_number)
|
||||
WHERE s.`phone_number` = ? LIMIT 1
|
||||
]], { number })
|
||||
local target = targets[1]
|
||||
local callee_source, target_status = lock_direct_target(source, target)
|
||||
local callee_source, target_status = lock_direct_target(source, target, scope.device.sim_id)
|
||||
if not callee_source then
|
||||
local terminal = create_terminal_call(scope, number, target, target_status, service_line.number)
|
||||
dialing_by_sim[scope.device.sim_id] = nil
|
||||
@@ -928,7 +992,12 @@ Bridge.Callbacks.Register("sky_phone:calls:dial", function(source, data)
|
||||
WHERE s.`phone_number` = ? LIMIT 1
|
||||
]], { number })
|
||||
local target = targets[1]
|
||||
local callee_source, target_status = lock_direct_target(source, target)
|
||||
if not target then
|
||||
dialing_by_sim[scope.device.sim_id] = nil
|
||||
dial_locks[source] = nil
|
||||
return { success = false, error = "recipient_not_found" }
|
||||
end
|
||||
local callee_source, target_status = lock_direct_target(source, target, scope.device.sim_id)
|
||||
if not callee_source then
|
||||
local terminal = create_terminal_call(scope, number, target, target_status)
|
||||
dialing_by_sim[scope.device.sim_id] = nil
|
||||
@@ -1169,6 +1238,51 @@ Bridge.Callbacks.Register("sky_phone:calls:hangup", function(source, data)
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:calls:block", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "call_block", 10, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local scope, error_response = current_scope(source)
|
||||
if not scope then
|
||||
return error_response
|
||||
end
|
||||
if not scope.device.sim_id then
|
||||
return { success = false, error = "no_sim" }
|
||||
end
|
||||
local number = type(data) == "table" and SkyPhoneSimNumber.Normalize(
|
||||
data.phoneNumber,
|
||||
Config.Sim.NumberLength,
|
||||
Config.Sim.NumberPrefix
|
||||
) or nil
|
||||
if not number then
|
||||
return { success = false, error = "invalid_number" }
|
||||
end
|
||||
if number == scope.device.phone_number then
|
||||
return { success = false, error = "self_call" }
|
||||
end
|
||||
local rows = Bridge.Database.Query(
|
||||
"SELECT `id` FROM `sky_phone_sims` WHERE `phone_number` = ? LIMIT 1",
|
||||
{ number }
|
||||
)
|
||||
if not rows[1] then
|
||||
return { success = false, error = "recipient_not_found" }
|
||||
end
|
||||
Bridge.Database.Query([[
|
||||
INSERT IGNORE INTO `sky_phone_call_blocks` (`blocker_sim_id`, `blocked_sim_id`)
|
||||
VALUES (?, ?)
|
||||
]], { scope.device.sim_id, rows[1].id })
|
||||
|
||||
local call_id = active_by_source[source]
|
||||
local call = call_id and calls[call_id] or nil
|
||||
if call then
|
||||
local other_number = source == call.caller_source and call.callee_number or call.caller_number
|
||||
if other_number == number then
|
||||
finish_call(call, call.answered_at and "completed" or "declined")
|
||||
end
|
||||
end
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:payphone:hangup", function(source, data)
|
||||
local call_id = active_by_source[source]
|
||||
local call = call_id and calls[call_id] or nil
|
||||
|
||||
@@ -122,6 +122,7 @@ local schema = {
|
||||
{ name = "contact_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "phone_number", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "sim_type", type = "ENUM('registered', 'anonymous') NOT NULL" },
|
||||
{ name = "is_virtual", type = "TINYINT(1) NOT NULL DEFAULT 0" },
|
||||
{ name = "owner_identifier", type = "VARCHAR(80) NULL" },
|
||||
{ name = "owner_firstname", type = "VARCHAR(80) NULL" },
|
||||
{ name = "owner_lastname", type = "VARCHAR(80) NULL" },
|
||||
@@ -173,6 +174,39 @@ local schema = {
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_character_devices",
|
||||
columns = {
|
||||
{
|
||||
name = "owner_identifier",
|
||||
type = "VARCHAR(80) NOT NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_bin",
|
||||
},
|
||||
{
|
||||
name = "device_imei",
|
||||
type = "CHAR(15) NOT NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_bin",
|
||||
},
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{
|
||||
name = "updated_at",
|
||||
type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
|
||||
},
|
||||
},
|
||||
primaryKey = "owner_identifier",
|
||||
uniqueKeys = {
|
||||
{ name = "uniq_sky_phone_character_devices_device", columns = "(`device_imei`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{
|
||||
column = "device_imei",
|
||||
references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE",
|
||||
},
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_device_data",
|
||||
columns = {
|
||||
@@ -370,7 +404,11 @@ local schema = {
|
||||
{ name = "account_id", type = "BIGINT UNSIGNED NULL" },
|
||||
{ name = "device_imei", type = "CHAR(15) NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "name", type = "VARCHAR(80) NOT NULL" },
|
||||
{ name = "notes", type = "VARCHAR(500) NULL" },
|
||||
{ name = "organization", type = "VARCHAR(80) NULL" },
|
||||
{ name = "phone_number", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "avatar_media_id", type = "BIGINT UNSIGNED NULL" },
|
||||
{ name = "favorite", type = "TINYINT(1) NOT NULL DEFAULT 0" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
|
||||
},
|
||||
@@ -378,10 +416,12 @@ local schema = {
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_contacts_account", columns = "(`account_id`, `name`)" },
|
||||
{ name = "idx_sky_phone_contacts_device", columns = "(`device_imei`, `name`)" },
|
||||
{ name = "idx_sky_phone_contacts_avatar", columns = "(`avatar_media_id`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "device_imei", references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE" },
|
||||
{ column = "avatar_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
|
||||
@@ -433,10 +433,14 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
|
||||
else
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local recipients = Bridge.Database.Query(
|
||||
"SELECT `id`, `phone_number` FROM `sky_phone_sims` WHERE `phone_number` = ? LIMIT 1",
|
||||
{ number }
|
||||
)
|
||||
local recipients = Bridge.Database.Query([[
|
||||
SELECT s.`id`, s.`phone_number`
|
||||
FROM `sky_phone_sims` s
|
||||
LEFT JOIN `sky_phone_devices` d ON d.`sim_id` = s.`id`
|
||||
WHERE s.`phone_number` = ?
|
||||
AND (s.`is_virtual` = 0 OR d.`imei` IS NOT NULL)
|
||||
LIMIT 1
|
||||
]], { number })
|
||||
local recipient = recipients[1]
|
||||
if not recipient then
|
||||
return { success = false, error = "recipient_not_found" }
|
||||
|
||||
@@ -3,9 +3,12 @@ Bridge.Debug("debug", "[sky_phone] Server initialization started after database
|
||||
|
||||
SkyPhone = {}
|
||||
|
||||
local unique_phones = Config.Phone.Unique ~= false
|
||||
local sim_cards_enabled = Config.Sim.Enabled ~= false
|
||||
local sessions = {}
|
||||
local auth_attempts = {}
|
||||
local operation_attempts = {}
|
||||
local character_device_cache = {}
|
||||
local max_device_data_bytes = 100000
|
||||
local passcode_pepper = GetConvar(Config.Security.PasscodePepperConvar, "")
|
||||
if passcode_pepper == "" then
|
||||
@@ -64,9 +67,130 @@ local function reserve_imei()
|
||||
return imei
|
||||
end
|
||||
|
||||
local function character_identifier(source)
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
if identifier == nil then
|
||||
return nil
|
||||
end
|
||||
|
||||
identifier = tostring(identifier)
|
||||
if identifier == "" or #identifier > 80 then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Could not resolve a valid character identifier for source %s.",
|
||||
tostring(source)
|
||||
)
|
||||
return nil
|
||||
end
|
||||
return identifier
|
||||
end
|
||||
|
||||
local function load_character_device(source, identifier)
|
||||
identifier = identifier or character_identifier(source)
|
||||
if not identifier then
|
||||
return nil, "player_unavailable"
|
||||
end
|
||||
|
||||
local cached = character_device_cache[source]
|
||||
if cached and cached.identifier == identifier then
|
||||
return cached.imei, nil, identifier
|
||||
end
|
||||
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `device_imei`
|
||||
FROM `sky_phone_character_devices`
|
||||
WHERE `owner_identifier` = ?
|
||||
LIMIT 1
|
||||
]], { identifier })
|
||||
local imei = rows[1] and rows[1].device_imei or nil
|
||||
if imei then
|
||||
character_device_cache[source] = { identifier = identifier, imei = imei }
|
||||
else
|
||||
character_device_cache[source] = nil
|
||||
end
|
||||
return imei, nil, identifier
|
||||
end
|
||||
|
||||
local function cache_character_device(source, identifier, imei)
|
||||
character_device_cache[source] = { identifier = identifier, imei = imei }
|
||||
return imei
|
||||
end
|
||||
|
||||
local function map_character_device(source, slot)
|
||||
local mapped_imei, error_code, identifier = load_character_device(source)
|
||||
if error_code or mapped_imei then
|
||||
return mapped_imei, error_code
|
||||
end
|
||||
|
||||
-- On the first switch from unique to shared phones, adopt the currently used
|
||||
-- legacy device when it has not already been claimed by another character.
|
||||
local legacy_imei = slot.metadata and slot.metadata.imei or nil
|
||||
if SkyPhoneImei.IsValid(legacy_imei) then
|
||||
Bridge.Database.Query([[
|
||||
INSERT IGNORE INTO `sky_phone_devices` (`imei`, `device_name`)
|
||||
VALUES (?, ?)
|
||||
]], { legacy_imei, Config.Phone.DeviceName })
|
||||
local result = Bridge.Database.Query([[
|
||||
INSERT IGNORE INTO `sky_phone_character_devices` (`owner_identifier`, `device_imei`)
|
||||
VALUES (?, ?)
|
||||
]], { identifier, legacy_imei })
|
||||
if affected_rows(result) == 1 then
|
||||
return cache_character_device(source, identifier, legacy_imei)
|
||||
end
|
||||
|
||||
mapped_imei = load_character_device(source, identifier)
|
||||
if mapped_imei then
|
||||
return mapped_imei
|
||||
end
|
||||
end
|
||||
|
||||
local imei = reserve_imei()
|
||||
local result = Bridge.Database.Query([[
|
||||
INSERT IGNORE INTO `sky_phone_character_devices` (`owner_identifier`, `device_imei`)
|
||||
VALUES (?, ?)
|
||||
]], { identifier, imei })
|
||||
if affected_rows(result) == 1 then
|
||||
return cache_character_device(source, identifier, imei)
|
||||
end
|
||||
|
||||
-- Another simultaneous open may have created the mapping first. Keep its
|
||||
-- winner and remove only the fresh, unmapped device reserved by this call.
|
||||
mapped_imei = load_character_device(source, identifier)
|
||||
Bridge.Database.Query([[
|
||||
DELETE d FROM `sky_phone_devices` d
|
||||
LEFT JOIN `sky_phone_character_devices` c ON c.`device_imei` = d.`imei`
|
||||
WHERE d.`imei` = ? AND c.`device_imei` IS NULL
|
||||
]], { imei })
|
||||
if mapped_imei then
|
||||
return mapped_imei
|
||||
end
|
||||
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Could not create a shared device mapping for source %s.",
|
||||
tostring(source)
|
||||
)
|
||||
return nil, "request_failed"
|
||||
end
|
||||
|
||||
local function find_device_slots(source, imei)
|
||||
local matches = {}
|
||||
for _, item in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
|
||||
local slots = Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)
|
||||
if not unique_phones then
|
||||
if not slots[1] then
|
||||
return matches
|
||||
end
|
||||
local mapped_imei = load_character_device(source)
|
||||
if mapped_imei ~= imei then
|
||||
return matches
|
||||
end
|
||||
for _, item in ipairs(slots) do
|
||||
matches[#matches + 1] = item
|
||||
end
|
||||
return matches
|
||||
end
|
||||
|
||||
for _, item in ipairs(slots) do
|
||||
if item.metadata and item.metadata.imei == imei then
|
||||
matches[#matches + 1] = item
|
||||
end
|
||||
@@ -134,7 +258,7 @@ local function resolve_used_slot(source, used_item)
|
||||
{ always = true }
|
||||
)
|
||||
end
|
||||
if #slots == 1 then
|
||||
if #slots == 1 or (not unique_phones and #slots > 0) then
|
||||
return slots[1]
|
||||
end
|
||||
|
||||
@@ -159,6 +283,13 @@ local function ensure_device(source, slot)
|
||||
tostring(Bridge.Inventory.GetResourceName()),
|
||||
{ always = true }
|
||||
)
|
||||
if not unique_phones then
|
||||
if amount < 1 then
|
||||
return nil, "phone_slot_missing"
|
||||
end
|
||||
return map_character_device(source, slot)
|
||||
end
|
||||
|
||||
if amount ~= 1 then
|
||||
Bridge.Debug("warn", "[sky_phone] Phone item in slot %s is stacked for source %s.", tostring(slot.slot), tostring(source))
|
||||
return nil, "phone_stacked"
|
||||
@@ -217,7 +348,7 @@ end
|
||||
local function load_device(imei)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT d.`imei`, d.`device_name`, d.`account_id`, d.`sim_id`, d.`created_at`, d.`updated_at`,
|
||||
a.`email`, s.`phone_number`, s.`sim_type`, s.`registered_at`
|
||||
a.`email`, s.`phone_number`, s.`sim_type`, s.`registered_at`, s.`is_virtual` AS `sim_is_virtual`
|
||||
FROM `sky_phone_devices` d
|
||||
LEFT JOIN `sky_phone_accounts` a ON a.`id` = d.`account_id`
|
||||
LEFT JOIN `sky_phone_sims` s ON s.`id` = d.`sim_id`
|
||||
@@ -370,6 +501,7 @@ local function bootstrap(source, security, security_loaded)
|
||||
sim = device.sim_id and {
|
||||
id = device.sim_id,
|
||||
number = device.phone_number,
|
||||
removable = sim_cards_enabled and tonumber(device.sim_is_virtual) ~= 1,
|
||||
type = device.sim_type,
|
||||
registered = device.registered_at ~= nil,
|
||||
} or nil,
|
||||
@@ -648,24 +780,38 @@ function SkyPhone.NotifyAccountDevices(account_id, event_name, data)
|
||||
devices[row.imei] = row
|
||||
end
|
||||
|
||||
local function notify_device(source, device)
|
||||
local payload = {}
|
||||
for key, value in pairs(data) do
|
||||
payload[key] = value
|
||||
end
|
||||
payload.device = {
|
||||
imei = device.imei,
|
||||
name = device.device_name,
|
||||
settings = device.settings,
|
||||
}
|
||||
TriggerClientEvent(event_name, source, payload)
|
||||
end
|
||||
|
||||
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
|
||||
local source = tonumber(player_source) or player_source
|
||||
local notified_devices = {}
|
||||
for _, item in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
|
||||
local imei = item.metadata and item.metadata.imei
|
||||
local device = imei and devices[imei]
|
||||
if device and not notified_devices[imei] then
|
||||
local payload = {}
|
||||
for key, value in pairs(data) do
|
||||
payload[key] = value
|
||||
if not unique_phones then
|
||||
if Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)[1] then
|
||||
local imei = load_character_device(source)
|
||||
local device = imei and devices[imei] or nil
|
||||
if device then
|
||||
notify_device(source, device)
|
||||
end
|
||||
end
|
||||
else
|
||||
local notified_devices = {}
|
||||
for _, item in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
|
||||
local imei = item.metadata and item.metadata.imei
|
||||
local device = imei and devices[imei]
|
||||
if device and not notified_devices[imei] then
|
||||
notified_devices[imei] = true
|
||||
notify_device(source, device)
|
||||
end
|
||||
payload.device = {
|
||||
imei = device.imei,
|
||||
name = device.device_name,
|
||||
settings = device.settings,
|
||||
}
|
||||
notified_devices[imei] = true
|
||||
TriggerClientEvent(event_name, source, payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -721,6 +867,18 @@ local function open_phone(source, used_item)
|
||||
TriggerClientEvent("sky_phone:device:error", source, error_code)
|
||||
return false
|
||||
end
|
||||
local prepared, prepare_error = SkyPhoneSim.PrepareDevice(source, slot, imei)
|
||||
if not prepared then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Phone preparation failed for source %s IMEI %s: %s.",
|
||||
tostring(source),
|
||||
imei,
|
||||
tostring(prepare_error)
|
||||
)
|
||||
TriggerClientEvent("sky_phone:device:error", source, prepare_error or "request_failed")
|
||||
return false
|
||||
end
|
||||
|
||||
local security = load_device_security(imei)
|
||||
if sessions[source] and sessions[source].imei ~= imei then
|
||||
@@ -1157,6 +1315,7 @@ AddEventHandler("playerDropped", function()
|
||||
sessions[source] = nil
|
||||
auth_attempts[source] = nil
|
||||
operation_attempts[source] = nil
|
||||
character_device_cache[source] = nil
|
||||
end)
|
||||
|
||||
AddEventHandler("onResourceStop", function(resource_name)
|
||||
@@ -1164,6 +1323,7 @@ AddEventHandler("onResourceStop", function(resource_name)
|
||||
sessions = {}
|
||||
auth_attempts = {}
|
||||
operation_attempts = {}
|
||||
character_device_cache = {}
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
+160
-29
@@ -2,6 +2,8 @@ Bridge.Database.AfterMigration("sky_phone", function()
|
||||
|
||||
SkyPhoneSim = {}
|
||||
|
||||
local unique_phones = Config.Phone.Unique ~= false
|
||||
local sim_cards_enabled = Config.Sim.Enabled ~= false
|
||||
local pending_insertions = {}
|
||||
local operation_locks = {}
|
||||
local sim_types = {
|
||||
@@ -24,7 +26,7 @@ local function uuid()
|
||||
return rows[1].id
|
||||
end
|
||||
|
||||
local function reserve_sim(sim_type)
|
||||
local function reserve_sim(sim_type, is_virtual)
|
||||
local sim_id
|
||||
local number = SkyPhoneSimNumber.Reserve(uuid, function(candidate)
|
||||
if SkyPhoneCompanies.IsServiceNumber(candidate) then
|
||||
@@ -32,9 +34,9 @@ local function reserve_sim(sim_type)
|
||||
end
|
||||
sim_id = uuid()
|
||||
local result = Bridge.Database.Query([[
|
||||
INSERT IGNORE INTO `sky_phone_sims` (`id`, `phone_number`, `sim_type`)
|
||||
VALUES (?, ?, ?)
|
||||
]], { sim_id, candidate, sim_type })
|
||||
INSERT IGNORE INTO `sky_phone_sims` (`id`, `phone_number`, `sim_type`, `is_virtual`)
|
||||
VALUES (?, ?, ?, ?)
|
||||
]], { sim_id, candidate, sim_type, is_virtual and 1 or 0 })
|
||||
return affected_rows(result) == 1
|
||||
end, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
|
||||
if not number then
|
||||
@@ -65,6 +67,91 @@ local function sim_metadata(sim)
|
||||
return metadata
|
||||
end
|
||||
|
||||
local function set_phone_sim_metadata(source, phone_slot, sim)
|
||||
if not unique_phones then
|
||||
return true
|
||||
end
|
||||
if not phone_slot then
|
||||
return false
|
||||
end
|
||||
|
||||
local metadata = phone_slot.metadata or {}
|
||||
metadata.sim_id = sim and sim.id or nil
|
||||
metadata.phone_number = sim and sim.phone_number or nil
|
||||
metadata.formatted_number = sim and SkyPhoneSimNumber.Format(
|
||||
sim.phone_number,
|
||||
Config.Sim.NumberGroups,
|
||||
Config.Sim.NumberLength,
|
||||
Config.Sim.NumberPrefix
|
||||
) or nil
|
||||
return Bridge.Inventory.SetSlotMetadata(source, phone_slot.slot, metadata)
|
||||
end
|
||||
|
||||
local function prepare_device(source, phone_slot, imei)
|
||||
local device = SkyPhone.LoadDevice(imei)
|
||||
if not device then
|
||||
return false, "request_failed"
|
||||
end
|
||||
|
||||
local sim = device.sim_id and load_sim(device.sim_id) or nil
|
||||
if sim_cards_enabled then
|
||||
if sim and tonumber(sim.is_virtual) == 1 then
|
||||
local result = Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_devices`
|
||||
SET `sim_id` = NULL
|
||||
WHERE `imei` = ? AND `sim_id` = ?
|
||||
]], { imei, sim.id })
|
||||
if affected_rows(result) ~= 1 then
|
||||
return false, "request_failed"
|
||||
end
|
||||
sim = nil
|
||||
end
|
||||
|
||||
if not set_phone_sim_metadata(source, phone_slot, sim) then
|
||||
return false, "metadata_unsupported"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
if sim then
|
||||
if not set_phone_sim_metadata(source, phone_slot, sim) then
|
||||
return false, "metadata_unsupported"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local automatic_sim = reserve_sim("anonymous", true)
|
||||
local result = Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_devices`
|
||||
SET `sim_id` = ?
|
||||
WHERE `imei` = ? AND `sim_id` IS NULL
|
||||
]], { automatic_sim.id, imei })
|
||||
if affected_rows(result) ~= 1 then
|
||||
Bridge.Database.Query("DELETE FROM `sky_phone_sims` WHERE `id` = ?", { automatic_sim.id })
|
||||
device = SkyPhone.LoadDevice(imei)
|
||||
sim = device and device.sim_id and load_sim(device.sim_id) or nil
|
||||
if not sim then
|
||||
return false, "request_failed"
|
||||
end
|
||||
if not set_phone_sim_metadata(source, phone_slot, sim) then
|
||||
return false, "metadata_unsupported"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
if not set_phone_sim_metadata(source, phone_slot, automatic_sim) then
|
||||
Bridge.Database.Query(
|
||||
"UPDATE `sky_phone_devices` SET `sim_id` = NULL WHERE `imei` = ? AND `sim_id` = ?",
|
||||
{ imei, automatic_sim.id }
|
||||
)
|
||||
Bridge.Database.Query("DELETE FROM `sky_phone_sims` WHERE `id` = ?", { automatic_sim.id })
|
||||
return false, "metadata_unsupported"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
SkyPhoneSim.PrepareDevice = prepare_device
|
||||
|
||||
local function resolve_used_sim(source, used_item, item_name)
|
||||
local slot_id = used_item and (used_item.slot or used_item.id)
|
||||
local slot = slot_id and Bridge.Inventory.GetSlot(source, slot_id) or nil
|
||||
@@ -85,7 +172,12 @@ local function ensure_sim(source, slot, sim_type)
|
||||
end
|
||||
local metadata = slot.metadata or {}
|
||||
local sim = metadata.sim_id and load_sim(metadata.sim_id) or nil
|
||||
if metadata.sim_id and (not sim or sim.sim_type ~= sim_type or sim.phone_number ~= metadata.phone_number) then
|
||||
if metadata.sim_id and (
|
||||
not sim
|
||||
or tonumber(sim.is_virtual) == 1
|
||||
or sim.sim_type ~= sim_type
|
||||
or sim.phone_number ~= metadata.phone_number
|
||||
) then
|
||||
return nil, "invalid_sim"
|
||||
end
|
||||
if sim and SkyPhoneCompanies.IsServiceNumber(sim.phone_number) then
|
||||
@@ -109,30 +201,46 @@ end
|
||||
|
||||
local function list_phone_choices(source)
|
||||
local choices = {}
|
||||
local seen = {}
|
||||
local choice_error
|
||||
for _, slot in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
|
||||
local imei = SkyPhone.EnsureDevice(source, slot)
|
||||
if imei then
|
||||
local device = SkyPhone.LoadDevice(imei)
|
||||
choices[#choices + 1] = {
|
||||
imei = imei,
|
||||
name = device.device_name,
|
||||
occupied = device.sim_id ~= nil,
|
||||
number = device.phone_number,
|
||||
}
|
||||
local imei, error_code = SkyPhone.EnsureDevice(source, slot)
|
||||
if imei and not seen[imei] then
|
||||
local prepared, prepare_error = prepare_device(source, slot, imei)
|
||||
if prepared then
|
||||
local device = SkyPhone.LoadDevice(imei)
|
||||
choices[#choices + 1] = {
|
||||
imei = imei,
|
||||
name = device.device_name,
|
||||
occupied = device.sim_id ~= nil,
|
||||
number = device.phone_number,
|
||||
}
|
||||
seen[imei] = true
|
||||
else
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Could not prepare SIM target %s for source %s: %s.",
|
||||
imei,
|
||||
tostring(source),
|
||||
tostring(prepare_error)
|
||||
)
|
||||
choice_error = choice_error or prepare_error
|
||||
end
|
||||
elseif not imei then
|
||||
choice_error = choice_error or error_code
|
||||
end
|
||||
end
|
||||
return choices
|
||||
return choices, choice_error
|
||||
end
|
||||
|
||||
local function rollback_phone_metadata(source, phone_slot, old_sim)
|
||||
local metadata = phone_slot.metadata or {}
|
||||
metadata.sim_id = old_sim and old_sim.id or nil
|
||||
metadata.phone_number = old_sim and old_sim.phone_number or nil
|
||||
metadata.formatted_number = old_sim and SkyPhoneSimNumber.Format(old_sim.phone_number, Config.Sim.NumberGroups, Config.Sim.NumberLength, Config.Sim.NumberPrefix) or nil
|
||||
Bridge.Inventory.SetSlotMetadata(source, phone_slot.slot, metadata)
|
||||
set_phone_sim_metadata(source, phone_slot, old_sim)
|
||||
end
|
||||
|
||||
local function insert_sim(source, phone_imei, confirmed)
|
||||
if not sim_cards_enabled then
|
||||
return { success = false, error = "disabled" }
|
||||
end
|
||||
if operation_locks[source] then
|
||||
return { success = false, error = "operation_in_progress" }
|
||||
end
|
||||
@@ -161,6 +269,10 @@ local function insert_sim(source, phone_imei, confirmed)
|
||||
return { success = false, error = "phone_not_owned" }
|
||||
end
|
||||
local phone_slot = phone_matches[1]
|
||||
local prepared, prepare_error = prepare_device(source, phone_slot, phone_imei)
|
||||
if not prepared then
|
||||
return { success = false, error = prepare_error }
|
||||
end
|
||||
local device = SkyPhone.LoadDevice(phone_imei)
|
||||
local old_sim = device.sim_id and load_sim(device.sim_id) or nil
|
||||
if old_sim and not confirmed then
|
||||
@@ -168,11 +280,7 @@ local function insert_sim(source, phone_imei, confirmed)
|
||||
end
|
||||
|
||||
operation_locks[source] = true
|
||||
local phone_metadata = phone_slot.metadata or {}
|
||||
phone_metadata.sim_id = pending.sim.id
|
||||
phone_metadata.phone_number = pending.sim.phone_number
|
||||
phone_metadata.formatted_number = SkyPhoneSimNumber.Format(pending.sim.phone_number, Config.Sim.NumberGroups, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
|
||||
if not Bridge.Inventory.SetSlotMetadata(source, phone_slot.slot, phone_metadata) then
|
||||
if not set_phone_sim_metadata(source, phone_slot, pending.sim) then
|
||||
operation_locks[source] = nil
|
||||
return { success = false, error = "metadata_unsupported" }
|
||||
end
|
||||
@@ -234,6 +342,9 @@ local function insert_sim(source, phone_imei, confirmed)
|
||||
end
|
||||
|
||||
local function use_sim(source, used_item)
|
||||
if not sim_cards_enabled then
|
||||
return false
|
||||
end
|
||||
local item_name = used_item and used_item.name
|
||||
local sim_type = item_name and sim_types[item_name]
|
||||
if not sim_type then
|
||||
@@ -257,10 +368,10 @@ local function use_sim(source, used_item)
|
||||
TriggerClientEvent("sky_phone:device:error", source, error_code)
|
||||
return false
|
||||
end
|
||||
local choices = list_phone_choices(source)
|
||||
local choices, choice_error = list_phone_choices(source)
|
||||
if #choices == 0 then
|
||||
operation_locks[source] = nil
|
||||
TriggerClientEvent("sky_phone:device:error", source, "phone_required")
|
||||
TriggerClientEvent("sky_phone:device:error", source, choice_error or "phone_required")
|
||||
return false
|
||||
end
|
||||
pending_insertions[source] = {
|
||||
@@ -280,10 +391,15 @@ local function use_sim(source, used_item)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Register the guarded callbacks in both modes so a resource-only restart can
|
||||
-- replace registrations left behind in framework-owned usable-item tables.
|
||||
Bridge.Inventory.RegisterUsableItem(Config.Sim.RegisteredItem, use_sim)
|
||||
Bridge.Inventory.RegisterUsableItem(Config.Sim.AnonymousItem, use_sim)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:sim:insert", function(source, data)
|
||||
if not sim_cards_enabled then
|
||||
return { success = false, error = "disabled" }
|
||||
end
|
||||
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
@@ -291,6 +407,9 @@ Bridge.Callbacks.Register("sky_phone:sim:insert", function(source, data)
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:sim:eject", function(source)
|
||||
if not sim_cards_enabled then
|
||||
return { success = false, error = "disabled" }
|
||||
end
|
||||
if operation_locks[source] then
|
||||
return { success = false, error = "operation_in_progress" }
|
||||
end
|
||||
@@ -300,7 +419,7 @@ Bridge.Callbacks.Register("sky_phone:sim:eject", function(source)
|
||||
end
|
||||
local device = SkyPhone.LoadDevice(session.imei)
|
||||
local sim = device and device.sim_id and load_sim(device.sim_id) or nil
|
||||
if not sim then
|
||||
if not sim or tonumber(sim.is_virtual) == 1 then
|
||||
return { success = false, error = "no_sim" }
|
||||
end
|
||||
local phone_slot = Bridge.Inventory.GetSlot(source, session.slot)
|
||||
@@ -311,7 +430,10 @@ Bridge.Callbacks.Register("sky_phone:sim:eject", function(source)
|
||||
end
|
||||
|
||||
operation_locks[source] = true
|
||||
rollback_phone_metadata(source, phone_slot, nil)
|
||||
if not set_phone_sim_metadata(source, phone_slot, nil) then
|
||||
operation_locks[source] = nil
|
||||
return { success = false, error = "metadata_unsupported" }
|
||||
end
|
||||
if not Bridge.Inventory.AddItem(source, item_name, 1, nil, metadata) then
|
||||
rollback_phone_metadata(source, phone_slot, sim)
|
||||
operation_locks[source] = nil
|
||||
@@ -337,4 +459,13 @@ AddEventHandler("playerDropped", function()
|
||||
pending_insertions[source] = nil
|
||||
operation_locks[source] = nil
|
||||
end)
|
||||
|
||||
if sim_cards_enabled then
|
||||
Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_devices` d
|
||||
INNER JOIN `sky_phone_sims` s ON s.`id` = d.`sim_id`
|
||||
SET d.`sim_id` = NULL
|
||||
WHERE s.`is_virtual` = 1
|
||||
]], {})
|
||||
end
|
||||
end)
|
||||
|
||||
@@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS `sky_phone_sims` (
|
||||
`contact_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
`phone_number` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`sim_type` ENUM('registered', 'anonymous') NOT NULL,
|
||||
`is_virtual` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`owner_identifier` VARCHAR(80) NULL,
|
||||
`owner_firstname` VARCHAR(80) NULL,
|
||||
`owner_lastname` VARCHAR(80) NULL,
|
||||
@@ -80,6 +81,16 @@ CREATE TABLE IF NOT EXISTS `sky_phone_devices` (
|
||||
FOREIGN KEY (`sim_id`) REFERENCES `sky_phone_sims` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_character_devices` (
|
||||
`owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`owner_identifier`),
|
||||
UNIQUE KEY `uniq_sky_phone_character_devices_device` (`device_imei`),
|
||||
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_device_data` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
@@ -156,7 +167,11 @@ CREATE TABLE IF NOT EXISTS `sky_phone_contacts` (
|
||||
`account_id` BIGINT UNSIGNED NULL,
|
||||
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
`name` VARCHAR(80) NOT NULL,
|
||||
`notes` VARCHAR(500) NULL,
|
||||
`organization` VARCHAR(80) NULL,
|
||||
`phone_number` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`avatar_media_id` BIGINT UNSIGNED NULL,
|
||||
`favorite` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
@@ -164,8 +179,10 @@ CREATE TABLE IF NOT EXISTS `sky_phone_contacts` (
|
||||
UNIQUE KEY `uniq_sky_phone_contacts_device_contact` (`device_imei`, `contact_id`),
|
||||
KEY `idx_sky_phone_contacts_account` (`account_id`, `name`),
|
||||
KEY `idx_sky_phone_contacts_device` (`device_imei`, `name`),
|
||||
KEY `idx_sky_phone_contacts_avatar` (`avatar_media_id`),
|
||||
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
|
||||
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`avatar_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_calls` (
|
||||
@@ -186,6 +203,16 @@ CREATE TABLE IF NOT EXISTS `sky_phone_calls` (
|
||||
FOREIGN KEY (`callee_sim_id`) REFERENCES `sky_phone_sims` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_call_blocks` (
|
||||
`blocker_sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`blocked_sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`blocker_sim_id`, `blocked_sim_id`),
|
||||
KEY `idx_sky_phone_call_blocks_blocked` (`blocked_sim_id`),
|
||||
FOREIGN KEY (`blocker_sim_id`) REFERENCES `sky_phone_sims` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`blocked_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_call_entries` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`call_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
|
||||
Reference in New Issue
Block a user