mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-30 09:48:57 +00:00
572 lines
17 KiB
JavaScript
572 lines
17 KiB
JavaScript
const cors = require('cors')
|
|
const express = require('express')
|
|
|
|
const app = express()
|
|
const port = Number(process.argv[2]) || 3001
|
|
|
|
app.use(cors())
|
|
app.use(express.json())
|
|
|
|
let authenticated = false
|
|
let draft = null
|
|
let linkedAccount = null
|
|
let mockNotes = []
|
|
const deviceData = {}
|
|
let contactSequence = 2
|
|
const contacts = [
|
|
{
|
|
created_at: '2026-08-04 12:00:00',
|
|
id: 'contact-1',
|
|
name: 'Jenica Chong',
|
|
phone_number: '5558675309',
|
|
updated_at: '2026-08-04 12:00:00',
|
|
},
|
|
]
|
|
const attachmentAssets = {
|
|
gif: new Set(['celebrate', 'hearts', 'party', 'thumbs_up', 'wow']),
|
|
image: new Set([
|
|
'camera-1', 'camera-2', 'camera-3', 'city-lights', 'desert-road',
|
|
'ocean-air', 'sunset-drive',
|
|
]),
|
|
video: new Set(['city-loop', 'ocean-loop', 'sunset-loop']),
|
|
}
|
|
const gifMocks = [
|
|
['ICOgUNjpvO0PC', 'Cat reaction'],
|
|
['MDJ9IbxxvDUQM', 'Happy dog'],
|
|
['l0HlPystfePnAI3G8', 'Celebrate'],
|
|
['26ufdipQqU2lhNA4g', 'Wow'],
|
|
['3o7abKhOpu0NwenH3O', 'Perfect'],
|
|
['xT0xeJpnrWC4XWblEk', 'Party'],
|
|
['111ebonMs90YLu', 'Thumbs up'],
|
|
['5GoVLqeAOo6PK', 'Excited'],
|
|
['TdfyKrN7HGTIY', 'Happy dance'],
|
|
['14udF3WUwwGMaA', 'Surprised'],
|
|
['3o6Zt6ML6BklcajjsA', 'Applause'],
|
|
['13CoXDiaCcCoyk', 'Let us go'],
|
|
['R6gvnAxj2ISzJdbA63', 'Yes'],
|
|
['xUPGcEliCc7bETyfO8', 'Laughing'],
|
|
['26BRuo6sLetdllPAQ', 'Dancing'],
|
|
['l46CyJmS9KUbokzsI', 'Amazing'],
|
|
['g9582DNuQppxC', 'Celebration'],
|
|
['artj92V8o75VPL7AeQ', 'High five'],
|
|
]
|
|
const accountDevices = [
|
|
{
|
|
created_at: '2026-08-04 12:00:00',
|
|
current: true,
|
|
device_name: 'iFruit Phone',
|
|
imei: '356938035643809',
|
|
updated_at: '2026-08-04 12:00:00',
|
|
},
|
|
]
|
|
const messages = [
|
|
{
|
|
body: 'Welcome to iFruit Mail. Your shared mailbox is ready to use.',
|
|
created_at: '2026-08-04 11:30:00',
|
|
folder: 'inbox',
|
|
id: 1,
|
|
is_read: false,
|
|
message_id: 'b73f3872-54cc-4d74-a058-6caa24f0dfff',
|
|
preview: 'Welcome to iFruit Mail. Your shared mailbox is ready to use.',
|
|
recipients: ['demo@ifruit.com'],
|
|
sender: 'support@ifruit.com',
|
|
subject: 'Welcome to iFruit Mail',
|
|
trashed_at: null,
|
|
},
|
|
{
|
|
body: 'The meet is behind the casino at nine. Bring the blue car.',
|
|
created_at: '2026-08-04 10:15:00',
|
|
folder: 'inbox',
|
|
id: 2,
|
|
is_read: true,
|
|
message_id: '79042d13-f86d-4418-a490-8b558539c20b',
|
|
preview: 'The meet is behind the casino at nine. Bring the blue car.',
|
|
recipients: ['demo@ifruit.com', 'jamie@ifruit.com'],
|
|
sender: 'morgan@ifruit.com',
|
|
subject: 'Tonight',
|
|
trashed_at: null,
|
|
},
|
|
{
|
|
body: 'I will be there.',
|
|
created_at: '2026-08-03 18:05:00',
|
|
folder: 'sent',
|
|
id: 3,
|
|
is_read: true,
|
|
message_id: 'c4414c1e-5fe3-46ea-bc0b-b5b9785c91bd',
|
|
preview: 'I will be there.',
|
|
recipients: ['morgan@ifruit.com'],
|
|
sender: 'demo@ifruit.com',
|
|
subject: 'Re: Tonight',
|
|
trashed_at: null,
|
|
},
|
|
{
|
|
body: 'This is an old message.',
|
|
created_at: '2026-08-02 09:20:00',
|
|
folder: 'inbox',
|
|
id: 4,
|
|
is_read: true,
|
|
message_id: 'd99669ab-c1ac-42d4-8ed4-3c4fe7fa37fb',
|
|
preview: 'This is an old message.',
|
|
recipients: ['demo@ifruit.com'],
|
|
sender: 'news@ifruit.com',
|
|
subject: 'Old mail',
|
|
trashed_at: '2026-08-04 08:00:00',
|
|
},
|
|
]
|
|
const smsMessages = [
|
|
{
|
|
body: 'Bin gleich am Würfelpark. Kommst du auch?',
|
|
created_at: '2026-08-06 13:04:00',
|
|
direction: 'received',
|
|
id: 'sms-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: '5558675309',
|
|
},
|
|
{
|
|
body: 'Ja, gib mir fünf Minuten.',
|
|
created_at: '2026-08-06 13:05:00',
|
|
direction: 'sent',
|
|
id: 'sms-2',
|
|
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: '5558675309',
|
|
sender_number: '5551234567',
|
|
},
|
|
]
|
|
|
|
function counts() {
|
|
return {
|
|
drafts: draft ? 1 : 0,
|
|
inbox: messages.filter(
|
|
(item) => item.folder === 'inbox' && !item.trashed_at,
|
|
).length,
|
|
sent: messages.filter((item) => item.folder === 'sent' && !item.trashed_at)
|
|
.length,
|
|
trash: messages.filter((item) => item.trashed_at).length,
|
|
unread: messages.filter(
|
|
(item) => item.folder === 'inbox' && !item.trashed_at && !item.is_read,
|
|
).length,
|
|
}
|
|
}
|
|
|
|
app.post('/api/:endpoint', (request, response) => {
|
|
console.log(`[NUI] ${request.params.endpoint}`, request.body)
|
|
const endpoint = request.params.endpoint
|
|
if (endpoint === 'weather:get') {
|
|
response.json({
|
|
success: true,
|
|
data: {
|
|
clock: { year: 2026, month: 8, day: 5, hour: 17, minute: 20 },
|
|
condition: 'partly_cloudy',
|
|
rainLevel: 0.08,
|
|
region: 'los_santos',
|
|
windSpeed: 3.2,
|
|
},
|
|
})
|
|
return
|
|
}
|
|
if (endpoint === 'map:getPlayerCoords') {
|
|
response.json({
|
|
success: true,
|
|
data: { coords: { x: -75.2, y: -818.9, z: 326.2 } },
|
|
})
|
|
return
|
|
}
|
|
if (endpoint === 'messages:conversations') {
|
|
const grouped = new Map()
|
|
for (const message of [...smsMessages].reverse()) {
|
|
const phoneNumber =
|
|
message.direction === 'sent'
|
|
? message.recipient_number
|
|
: message.sender_number
|
|
const conversation = grouped.get(phoneNumber)
|
|
if (conversation) {
|
|
if (message.direction === 'received' && !message.read_at)
|
|
conversation.unread += 1
|
|
continue
|
|
}
|
|
grouped.set(phoneNumber, {
|
|
lastMessage: message.body,
|
|
lastMessageAt: message.created_at,
|
|
lastMessageType: message.message_type,
|
|
phoneNumber,
|
|
unread: message.direction === 'received' && !message.read_at ? 1 : 0,
|
|
})
|
|
}
|
|
response.json({ success: true, data: [...grouped.values()] })
|
|
return
|
|
}
|
|
if (endpoint === 'messages:gifs') {
|
|
const offset = Math.max(0, Number(request.body.offset ?? 0))
|
|
const pageSize = 6
|
|
const results = gifMocks.slice(offset, offset + pageSize).map(([id, title]) => ({
|
|
height: 200,
|
|
id,
|
|
previewUrl: `https://media.giphy.com/media/${id}/200w.gif`,
|
|
title,
|
|
url: `https://media.giphy.com/media/${id}/giphy.gif`,
|
|
width: 200,
|
|
}))
|
|
response.json({
|
|
success: true,
|
|
data: {
|
|
hasMore: offset + results.length < gifMocks.length,
|
|
nextOffset: offset + results.length,
|
|
results,
|
|
},
|
|
})
|
|
return
|
|
}
|
|
if (endpoint === 'messages:thread') {
|
|
const number = String(request.body.phoneNumber)
|
|
const thread = smsMessages.filter(
|
|
(message) =>
|
|
message.sender_number === number || message.recipient_number === number,
|
|
)
|
|
for (const message of thread) {
|
|
if (message.direction === 'received') message.read_at = message.read_at ?? '2026-08-06 13:06:00'
|
|
}
|
|
response.json({
|
|
success: true,
|
|
data: thread.map(({ media_payload, ...message }) => ({
|
|
...message,
|
|
media_asset_id: ['image', 'gif', 'video'].includes(message.message_type)
|
|
? media_payload
|
|
: null,
|
|
})),
|
|
})
|
|
return
|
|
}
|
|
if (endpoint === 'messages:media') {
|
|
const message = smsMessages.find(
|
|
(item) => item.id === request.body.id && item.message_type === 'voice',
|
|
)
|
|
response.json(
|
|
message
|
|
? {
|
|
success: true,
|
|
data: {
|
|
mime: message.media_mime,
|
|
payload: message.media_payload,
|
|
},
|
|
}
|
|
: { success: false, error: 'message_not_found' },
|
|
)
|
|
return
|
|
}
|
|
if (endpoint === 'messages:send') {
|
|
const body = String(request.body.body ?? '').trim()
|
|
const phoneNumber = String(request.body.phoneNumber ?? '')
|
|
const messageType = request.body.messageType ?? 'text'
|
|
const isAttachment = ['image', 'gif', 'video'].includes(messageType)
|
|
const attachmentId = String(request.body.mediaAssetId ?? '')
|
|
if (
|
|
!phoneNumber ||
|
|
(messageType === 'text' && !body) ||
|
|
(messageType === 'voice' && !request.body.mediaPayload) ||
|
|
(isAttachment &&
|
|
!attachmentAssets[messageType].has(attachmentId) &&
|
|
!attachmentId.startsWith('https://'))
|
|
) {
|
|
response.json({ success: false, error: 'invalid_message' })
|
|
return
|
|
}
|
|
const message = {
|
|
body,
|
|
created_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
|
|
direction: 'sent',
|
|
id: `sms-${Date.now()}`,
|
|
media_duration_ms:
|
|
['voice', 'video'].includes(messageType)
|
|
? request.body.mediaDurationMs ?? null
|
|
: null,
|
|
media_mime:
|
|
messageType === 'voice'
|
|
? request.body.mediaMime
|
|
: messageType === 'image'
|
|
? 'image/jpeg'
|
|
: messageType === 'gif'
|
|
? 'image/gif'
|
|
: messageType === 'video'
|
|
? 'video/mp4'
|
|
: null,
|
|
media_payload:
|
|
messageType === 'voice'
|
|
? request.body.mediaPayload
|
|
: isAttachment
|
|
? attachmentId
|
|
: null,
|
|
media_waveform:
|
|
messageType === 'voice' ? request.body.mediaWaveform : null,
|
|
message_type: messageType,
|
|
media_asset_id: isAttachment ? attachmentId : null,
|
|
read_at: null,
|
|
recipient_number: phoneNumber,
|
|
sender_number: '5551234567',
|
|
}
|
|
smsMessages.push(message)
|
|
const { media_payload, ...publicMessage } = message
|
|
response.json({ success: true, data: publicMessage })
|
|
return
|
|
}
|
|
if (endpoint === 'messages:delete') {
|
|
const phoneNumbers = new Set(
|
|
Array.isArray(request.body.phoneNumbers)
|
|
? request.body.phoneNumbers.map(String)
|
|
: [],
|
|
)
|
|
for (let index = smsMessages.length - 1; index >= 0; index -= 1) {
|
|
const message = smsMessages[index]
|
|
const otherNumber =
|
|
message.direction === 'sent'
|
|
? message.recipient_number
|
|
: message.sender_number
|
|
if (phoneNumbers.has(otherNumber)) smsMessages.splice(index, 1)
|
|
}
|
|
response.json({ success: true })
|
|
return
|
|
}
|
|
if (endpoint === 'contacts:list') {
|
|
response.json({ success: true, data: contacts })
|
|
return
|
|
}
|
|
if (endpoint === 'contacts:save') {
|
|
const name = String(request.body.name ?? '').trim()
|
|
const phoneNumber = String(request.body.phoneNumber ?? '').trim()
|
|
if (!name || !phoneNumber) {
|
|
response.json({ success: false, error: 'invalid_contact' })
|
|
return
|
|
}
|
|
let contact = contacts.find((item) => item.id === request.body.id)
|
|
if (contact) {
|
|
contact.name = name
|
|
contact.phone_number = phoneNumber
|
|
contact.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ')
|
|
} else {
|
|
const now = new Date().toISOString().slice(0, 19).replace('T', ' ')
|
|
contact = {
|
|
created_at: now,
|
|
id: `contact-${contactSequence++}`,
|
|
name,
|
|
phone_number: phoneNumber,
|
|
updated_at: now,
|
|
}
|
|
contacts.push(contact)
|
|
}
|
|
response.json({ success: true, data: contact })
|
|
return
|
|
}
|
|
if (endpoint === 'contacts:delete') {
|
|
const index = contacts.findIndex((item) => item.id === request.body.id)
|
|
if (index >= 0) contacts.splice(index, 1)
|
|
response.json({ success: true })
|
|
return
|
|
}
|
|
if (endpoint === 'account:login' || endpoint === 'account:register') {
|
|
authenticated = true
|
|
linkedAccount = {
|
|
devices: accountDevices,
|
|
email: request.body.email.includes('@')
|
|
? request.body.email
|
|
: `${request.body.email}@ifruit.com`,
|
|
}
|
|
response.json({ success: true, data: linkedAccount })
|
|
return
|
|
}
|
|
if (endpoint === 'account:logout') {
|
|
authenticated = false
|
|
linkedAccount = null
|
|
response.json({ success: true })
|
|
return
|
|
}
|
|
if (endpoint === 'account:devices') {
|
|
response.json({ success: true, data: accountDevices })
|
|
return
|
|
}
|
|
if (endpoint === 'account:remove-device') {
|
|
response.json({ success: true, data: accountDevices })
|
|
return
|
|
}
|
|
if (endpoint === 'device:save') {
|
|
const current = deviceData[request.body.namespace]
|
|
const revision = (current?.revision ?? 0) + 1
|
|
deviceData[request.body.namespace] = {
|
|
payload: request.body.payload,
|
|
revision,
|
|
}
|
|
response.json({ success: true, data: { revision } })
|
|
return
|
|
}
|
|
if (endpoint === 'device:factory-reset') {
|
|
authenticated = false
|
|
linkedAccount = null
|
|
mockNotes = []
|
|
for (const key of Object.keys(deviceData)) delete deviceData[key]
|
|
response.json({ success: true })
|
|
return
|
|
}
|
|
if (
|
|
endpoint === 'sim:insert' &&
|
|
request.body.imei === '356938035643810' &&
|
|
!request.body.confirmed
|
|
) {
|
|
response.json({ success: false, error: 'confirmation_required' })
|
|
return
|
|
}
|
|
if (endpoint === 'notes:list') {
|
|
response.json({ success: true, data: mockNotes })
|
|
return
|
|
}
|
|
if (endpoint === 'notes:create') {
|
|
mockNotes.unshift({ ...request.body, revision: 1 })
|
|
response.json({ success: true, data: mockNotes })
|
|
return
|
|
}
|
|
if (endpoint === 'notes:update') {
|
|
const index = mockNotes.findIndex((note) => note.id === request.body.id)
|
|
if (index >= 0) {
|
|
mockNotes[index] = {
|
|
...request.body,
|
|
revision: mockNotes[index].revision + 1,
|
|
updatedAt: Date.now(),
|
|
}
|
|
}
|
|
response.json({ success: true, data: mockNotes })
|
|
return
|
|
}
|
|
if (endpoint === 'notes:delete') {
|
|
mockNotes = mockNotes.filter((note) => note.id !== request.body.id)
|
|
response.json({ success: true, data: mockNotes })
|
|
return
|
|
}
|
|
if (endpoint === 'mail:login' || endpoint === 'mail:register') {
|
|
authenticated = true
|
|
linkedAccount = {
|
|
devices: accountDevices,
|
|
email: 'demo@ifruit.com',
|
|
}
|
|
response.json({ success: true, data: linkedAccount })
|
|
return
|
|
}
|
|
if (endpoint === 'mail:logout') {
|
|
authenticated = false
|
|
response.json({ success: true })
|
|
return
|
|
}
|
|
if (endpoint.startsWith('mail:') && !authenticated) {
|
|
response.json({ success: false, error: 'not_authenticated' })
|
|
return
|
|
}
|
|
if (endpoint === 'mail:counts') {
|
|
response.json({ success: true, data: counts() })
|
|
return
|
|
}
|
|
if (endpoint === 'mail:list') {
|
|
const { folder, search = '' } = request.body
|
|
let items =
|
|
folder === 'drafts'
|
|
? draft
|
|
? [{ ...draft, created_at: draft.updated_at, preview: draft.body }]
|
|
: []
|
|
: messages.filter((item) =>
|
|
folder === 'trash'
|
|
? item.trashed_at
|
|
: item.folder === folder && !item.trashed_at,
|
|
)
|
|
const query = String(search).toLowerCase()
|
|
if (query) {
|
|
items = items.filter((item) =>
|
|
`${item.sender ?? ''} ${item.subject} ${item.preview}`
|
|
.toLowerCase()
|
|
.includes(query),
|
|
)
|
|
}
|
|
response.json({ success: true, data: { hasMore: false, items, offset: 0 } })
|
|
return
|
|
}
|
|
if (endpoint === 'mail:get') {
|
|
const message = messages.find((item) => item.id === Number(request.body.id))
|
|
if (message) message.is_read = true
|
|
response.json(
|
|
message
|
|
? { success: true, data: message }
|
|
: { success: false, error: 'message_not_found' },
|
|
)
|
|
return
|
|
}
|
|
if (endpoint === 'mail:get-draft') {
|
|
response.json(
|
|
draft
|
|
? { success: true, data: draft }
|
|
: { success: false, error: 'draft_not_found' },
|
|
)
|
|
return
|
|
}
|
|
if (endpoint === 'mail:save-draft') {
|
|
draft = {
|
|
...request.body,
|
|
id: request.body.id ?? '748836d2-2308-4c21-a4d8-3af6df6dd7eb',
|
|
created_at: '2026-08-04 11:40:00',
|
|
updated_at: '2026-08-04 11:40:00',
|
|
}
|
|
response.json({ success: true, data: { id: draft.id } })
|
|
return
|
|
}
|
|
if (endpoint === 'mail:delete-draft') {
|
|
draft = null
|
|
response.json({ success: true })
|
|
return
|
|
}
|
|
if (endpoint === 'mail:send') {
|
|
draft = null
|
|
response.json({ success: true, data: { id: 'mock-sent-message' } })
|
|
return
|
|
}
|
|
if (endpoint === 'mail:trash' || endpoint === 'mail:restore') {
|
|
const message = messages.find((item) => item.id === Number(request.body.id))
|
|
if (message)
|
|
message.trashed_at =
|
|
endpoint === 'mail:trash' ? '2026-08-04 12:00:00' : null
|
|
response.json({ success: true })
|
|
return
|
|
}
|
|
if (endpoint === 'mail:delete-forever') {
|
|
const index = messages.findIndex(
|
|
(item) => item.id === Number(request.body.id),
|
|
)
|
|
if (index >= 0) messages.splice(index, 1)
|
|
response.json({ success: true })
|
|
return
|
|
}
|
|
if (endpoint === 'mail:empty-trash') {
|
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
if (messages[index].trashed_at) messages.splice(index, 1)
|
|
}
|
|
response.json({ success: true })
|
|
return
|
|
}
|
|
if (endpoint === 'mail:set-read') {
|
|
const message = messages.find((item) => item.id === Number(request.body.id))
|
|
if (message) message.is_read = request.body.read
|
|
response.json({ success: true })
|
|
return
|
|
}
|
|
response.json({ success: true })
|
|
})
|
|
|
|
app.listen(port, () => {
|
|
console.log(`Mock NUI server listening on http://localhost:${port}`)
|
|
})
|