diff --git a/DOC/api/PWA_FCM_SETUP.md b/DOC/api/PWA_FCM_SETUP.md new file mode 100644 index 0000000..3c0848f --- /dev/null +++ b/DOC/api/PWA_FCM_SETUP.md @@ -0,0 +1,52 @@ +# Настройка PWA + FCM для веб-клиента (Chrome/Edge/Firefox/Safari iOS) + +## 1) Что нужно создать в Firebase +1. Создать проект Firebase. +2. Включить Cloud Messaging. +3. Создать Web App и получить конфиг: + - apiKey + - authDomain + - projectId + - messagingSenderId + - appId +4. В Cloud Messaging -> Web Push certificates сгенерировать VAPID key. +5. Для серверной отправки взять **Server key** (legacy) или настроить HTTP v1 (service account). + +## 2) Куда вставить токены в клиенте +Файл: `shine-UI/index.html` и `shine-UI/firebase-messaging-sw.js`. + +Заполнить: +- `window.__SHINE_FIREBASE_CONFIG__` +- `window.__SHINE_FIREBASE_VAPID_KEY__` +- `FIREBASE_CONFIG` (в service worker) + +## 3) Куда вставить серверный ключ FCM +Файл: `src/main/resources/application.properties` + +Добавить: +``` +fcm.server.key=YOUR_FCM_SERVER_KEY +``` + +## 4) PWA требования +1. Открывать сайт только по HTTPS (или localhost). +2. Разрешить уведомления в браузере. +3. Убедиться, что `manifest.webmanifest` доступен. +4. Убедиться, что `firebase-messaging-sw.js` зарегистрирован. + +## 5) Safari / iPhone (iOS) +- Нужен iOS 16.4+. +- Пользователь должен добавить сайт на Home Screen. +- После запуска PWA с Home Screen дать разрешение на уведомления. +- Без Home Screen web push в Safari iOS не работает. + +## 6) Проверка +1. Логин в приложении. +2. Клиент вызывает `UpsertPushToken` и отправляет FCM токен на сервер. +3. Вызов `SendDirectMessage` пользователю без активной WS доставки. +4. Сервер шлет push через FCM. + +## 7) Поддержка разных браузеров +- Chrome/Edge/Opera/Android Browser: FCM web push поддерживается нативно. +- Firefox: поддержка web push есть, но тестировать отдельно (поведение токенов отличается). +- Safari macOS/iOS: web push есть, но требуется PWA режим и Apple-ограничения. diff --git a/TASKS/CHAT_PWA_HANDOFF.md b/TASKS/CHAT_PWA_HANDOFF.md new file mode 100644 index 0000000..a732835 --- /dev/null +++ b/TASKS/CHAT_PWA_HANDOFF.md @@ -0,0 +1,181 @@ +# Анонс для исполнителя (кратко) + +Ниже описан текущий статус ветки по функциям: +- PWA + FCM уведомления +- личные сообщения (вторая слева вкладка, "Личные сообщения") +- связи / близкие друзья (центральная вкладка) +- server-push события (в том числе закрытие сессии) + +## Что нужно проверить и довести до рабочего состояния +1. **PWA/FCM**: регистрация service worker, получение FCM token, отправка token на сервер, получение foreground/background уведомлений. +2. **Личные сообщения**: отправка первого сообщения любому пользователю, входящие по WS, ACK, fallback в FCM, дедупликация на клиенте. +3. **Связи / близкие друзья**: поиск пользователя, добавление в близкие, обновление графа связей сразу после добавления. +4. **SessionRevoked**: если сессию закрыли с другого устройства, клиент должен корректно завершить локальную сессию и показать понятное состояние. + +## Главная цель handoff +Сделать так, чтобы все описанные сценарии стабильно работали end-to-end без ручных "подпинок". + +--- + +# Подробное ТЗ и технические детали + +## 1) Архитектура протокола и общие принципы + +### 1.1 Формат обмена +Используется JSON-over-WebSocket. +- Для запрос-ответ: `op + requestId + payload` +- Для server-push событий: те же поля, но `event=true`, `requestId` используется как eventId. + +### 1.2 ID сообщений/событий +ID генерируются в формате: +- `prefix-yyyyMMdd-HHmmss-SSS-random10` + +Реализация: `NetIdGenerator`. + +### 1.3 Роли каналов доставки +- **WS** — приоритетная доставка в активные сессии. +- **FCM** — fallback, если ACK по WS не получен или WS-сессия отсутствует. + +--- + +## 2) PWA + FCM + +## 2.1 Что уже добавлено +### Клиент +- `shine-UI/manifest.webmanifest` +- `shine-UI/firebase-messaging-sw.js` +- `shine-UI/js/services/pwa-push-service.js` +- `shine-UI/index.html` содержит placeholders: + - `window.__SHINE_FIREBASE_CONFIG__` + - `window.__SHINE_FIREBASE_VAPID_KEY__` + +### Сервер +- API `UpsertPushToken` +- Таблица `user_push_tokens` +- Серверный FCM sender (legacy HTTP): `FcmPushSender` +- Конфиг: `fcm.server.key` в `application.properties` + +## 2.2 Как должно работать (целевое поведение) +1. Клиент после авторизации регистрирует SW. +2. Просит permission на notifications. +3. Получает FCM token. +4. Если token новый/изменился — отправляет `UpsertPushToken`. +5. Сервер сохраняет token за конкретной сессией. + +## 2.3 Что проверить/доделать +- Синхронизация Firebase config между `index.html` и `firebase-messaging-sw.js`. +- Работа iOS Safari (PWA через Home Screen). +- Поведение при смене token. +- Надёжность `FcmPushSender` (таймауты, логирование ошибок ответа). + +--- + +## 3) Вторая слева вкладка: Личные сообщения / Чаты + +## 3.1 Продуктовая логика (как должно быть) +1. Открытие списка диалогов на вкладке "Личные сообщения". +2. Кнопка `+` открывает поиск пользователя по префиксу логина (`SearchUsers`). +3. **Первое сообщение можно отправить любому пользователю**, даже если он не контакт. +4. Если пользователь не в контактах — в чате показывается действие "Добавить в контакты". +5. Входящее сообщение может прийти: + - напрямую по WS (`IncomingDirectMessage`) + - через FCM (fallback) +6. На клиенте дедупликация должна быть по `messageId`. + +## 3.2 Что уже сделано технически +### Сервер +- `SendDirectMessage` +- `AckIncomingMessage` +- `direct_messages` таблица + DAO +- доставка по активным WS-сессиям + ожидание ACK + fallback в FCM + +### Клиент +- `authService.sendDirectMessage(...)` +- `authService.ackIncomingMessage(...)` +- WS client поддерживает server events (`onEvent`) +- В `app.js` обработка `IncomingDirectMessage` +- В `state.js` добавлены `incomingDedup`, `addIncomingMessage` + +## 3.3 Что глючит/что проверить +- Стабильность отображения новых чатов, если сообщение пришло от неизвестного пользователя. +- Согласованность списка диалогов и фактических сообщений. +- Поведение при быстрых дубликатах WS + FCM. +- Поведение при сетевых обрывах/повторном коннекте. + +--- + +## 4) Центральная вкладка: Связи / Близкие друзья + +## 4.1 Целевое поведение +1. Экран "Связи" загружает граф друзей (`GetUserConnectionsGraph`). +2. Кнопка "Добавить близкого друга" открывает модалку: + - поле логина/префикса + - кнопка "Поиск" + - кнопка "Назад" +3. Поиск идёт через `SearchUsers`. +4. По клику на найденного — подтверждение "Добавить? Да/Нет". +5. При "Да" вызывается `AddCloseFriend`, после успеха: + - закрыть модалку + - вернуться на экран связей + - обновить граф. + +## 4.2 Что уже сделано +- UI-flow модалки реализован на `network-view.js`. +- API `AddCloseFriend` добавлен на сервере. +- `ConnectionsStateDAO.upsertRelation(...)` добавлен для upsert связи FRIEND. + +## 4.3 Что проверить/доделать +- Валидация edge-cases (добавление самого себя, несуществующий логин). +- Корректность отрисовки графа после добавления. +- Согласованность данных с будущей "настоящей" записью в blockchain (сейчас MVP upsert в `connections_state`). + +--- + +## 5) SessionRevoked и мультисессии + +## 5.1 Целевое поведение +Если с другой сессии закрыли текущую сессию: +1. сервер шлёт событие `SessionRevoked` +2. клиент чистит локальную авторизацию +3. клиент возвращается в состояние неавторизованного входа. + +## 5.2 Что проверить +- Что событие приходит до закрытия socket. +- Что UX не "зависает" на промежуточном экране. + +--- + +## 6) API-операции (быстрый список) + +### Уже используемые/добавленные в ветке +- `SearchUsers` +- `ListContacts` +- `GetUserConnectionsGraph` +- `AddCloseFriend` +- `UpsertPushToken` +- `SendDirectMessage` +- `AckIncomingMessage` +- `CloseActiveSession` (с server event `SessionRevoked`) + +--- + +## 7) Минимальный чек-лист тестирования для нового исполнителя + +1. Авторизация пользователя A и B в разных браузерах/устройствах. +2. A отправляет первое сообщение B (без контактов) — должно уйти. +3. B получает по WS, отправляется ACK, fallback FCM не должен дублировать. +4. Выключить WS у B и проверить fallback FCM. +5. Вкладка "Связи": добавить близкого друга через поиск, проверить обновление графа. +6. Закрыть сессию B с другого устройства и проверить `SessionRevoked` UX. +7. Перезапуск клиента: проверка повторной регистрации push-токена только при изменении. + +--- + +## 8) Важное ограничение текущей реализации + +Некоторые части реализованы как MVP и требуют стабилизации: +- местами UI/состояние могут рассинхронизироваться; +- fallback WS->FCM может требовать донастройки таймингов и ретраев; +- `AddCloseFriend` сейчас пишет прямое состояние связи (upsert), а не полный blockchain-поток создания блоков. + +Это ожидаемо для handoff: задача следующего исполнителя — довести до production-стабильности. diff --git a/shine-UI/firebase-messaging-sw.js b/shine-UI/firebase-messaging-sw.js new file mode 100644 index 0000000..28ce1aa --- /dev/null +++ b/shine-UI/firebase-messaging-sw.js @@ -0,0 +1,30 @@ +/* global importScripts, firebase */ +importScripts('https://www.gstatic.com/firebasejs/10.12.2/firebase-app-compat.js'); +importScripts('https://www.gstatic.com/firebasejs/10.12.2/firebase-messaging-compat.js'); + +self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim())); + +// Заполните теми же значениями, что и в shine-UI/index.html +const FIREBASE_CONFIG = { + apiKey: '', + authDomain: '', + projectId: '', + messagingSenderId: '', + appId: '', +}; + +if (FIREBASE_CONFIG.apiKey && firebase && firebase.messaging) { + if (!firebase.apps.length) { + firebase.initializeApp(FIREBASE_CONFIG); + } + const messaging = firebase.messaging(); + messaging.onBackgroundMessage((payload) => { + const title = payload?.notification?.title || 'Новое сообщение'; + const options = { + body: payload?.notification?.body || '', + data: payload?.data || {}, + }; + self.registration.showNotification(title, options); + }); +} diff --git a/shine-UI/index.html b/shine-UI/index.html index 4c71aaf..5011458 100644 --- a/shine-UI/index.html +++ b/shine-UI/index.html @@ -3,6 +3,7 @@ + Shine UI Demo @@ -11,10 +12,21 @@
-
+ + + diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 7f59545..33c6abe 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -1,7 +1,7 @@ import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js?v=20260403081123'; import { renderToolbar } from './components/toolbar.js?v=20260403081123'; -import { renderPageLabel } from './components/page-label.js?v=20260403081123'; import { captureClientError, setClientErrorTransport } from './services/client-error-reporter.js?v=20260403081123'; +import { initPwaPush } from './services/pwa-push-service.js?v=20260403081123'; import { authService, authorizeSession, @@ -10,7 +10,8 @@ import { setSessionResetHandler, state, terminateCurrentSession, - togglePageLabel, + addIncomingMessage, + setContacts, } from './state.js?v=20260403081123'; import * as startView from './pages/start-view.js?v=20260403081123'; @@ -77,7 +78,6 @@ const routes = { }; const screenEl = document.getElementById('app-screen'); -const labelEl = document.getElementById('page-label-slot'); const toolbarEl = document.getElementById('toolbar-slot'); let currentCleanup = null; @@ -140,16 +140,9 @@ function renderApp() { const showAppChrome = page.pageMeta?.showAppChrome !== false; screenEl.classList.toggle('no-app-chrome', !showAppChrome); - labelEl.innerHTML = ''; toolbarEl.innerHTML = ''; if (showAppChrome) { - labelEl.append( - renderPageLabel(page.pageMeta.title, page.pageMeta.id, state.pageLabelCollapsed, () => { - togglePageLabel(); - renderApp(); - }), - ); toolbarEl.append(renderToolbar(page.pageMeta.id, navigate)); } } @@ -161,6 +154,10 @@ async function tryAutoLogin() { const resumed = await authService.resumeSession(state.session.login, state.session.sessionId); authorizeSession(resumed); await refreshSessions(); + try { + const contacts = await authService.listContacts(); + setContacts(contacts.contacts || []); + } catch {} } catch (error) { if (isSessionInvalidError(error)) { await terminateCurrentSession({ @@ -174,7 +171,30 @@ async function init() { setSessionResetHandler(() => { navigate('start-view'); }); + + authService.onEvent('SessionRevoked', async () => { + await terminateCurrentSession({ infoMessage: 'Сессия закрыта с другого устройства.' }); + }); + + authService.onEvent('IncomingDirectMessage', async (evt) => { + const payload = evt?.payload || {}; + const fromLogin = payload.fromLogin || 'unknown'; + const messageId = payload.messageId || ''; + const eventId = payload.eventId || evt?.requestId || ''; + const added = addIncomingMessage(fromLogin, payload.text || '', messageId); + if (added && Notification.permission === 'granted') { + try { + new Notification(`Сообщение от ${fromLogin}`, { body: payload.text || '' }); + } catch {} + } + if (eventId) { + try { await authService.ackIncomingMessage(eventId, messageId); } catch {} + } + }); await tryAutoLogin(); + if (state.session.isAuthorized) { + await initPwaPush({ authService }); + } if (!window.location.hash) { navigate(state.session.isAuthorized ? 'profile-view' : 'start-view'); diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index 4dff8f2..76404a1 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -1,6 +1,6 @@ import { renderHeader } from '../components/header.js?v=20260403081123'; import { directMessages } from '../mock-data.js?v=20260403081123'; -import { addChatMessage, getChatMessages } from '../state.js?v=20260403081123'; +import { addChatMessage, getChatMessages, authService, state } from '../state.js?v=20260403081123'; export const pageMeta = { id: 'chat-view', title: 'Чат' }; @@ -18,7 +18,11 @@ function renderLog(list, chatId) { export function render({ navigate, route }) { const chatId = route.params.chatId || 'u1'; - const contact = directMessages.find((d) => d.id === chatId) || directMessages[0]; + const contact = directMessages.find((d) => d.id === chatId) || { + id: chatId, + name: chatId, + initials: (chatId[0] || '?').toUpperCase(), + }; const screen = document.createElement('section'); screen.className = 'stack'; @@ -30,6 +34,23 @@ export function render({ navigate, route }) { }) ); + const isContact = state.contacts.includes(chatId); + if (!isContact) { + const warning = document.createElement('div'); + warning.className = 'card stack'; + warning.innerHTML = '

Пользователь не в контактах. Можно писать ему сразу (MVP).

'; + const btn = document.createElement('button'); + btn.className = 'primary-btn'; + btn.type = 'button'; + btn.textContent = 'Добавить в контакты'; + btn.addEventListener('click', () => { + state.contacts = [...state.contacts, chatId]; + warning.remove(); + }); + warning.append(btn); + screen.append(warning); + } + const wrap = document.createElement('div'); wrap.className = 'chat-wrap'; @@ -43,12 +64,22 @@ export function render({ navigate, route }) { `; - form.addEventListener('submit', (event) => { + form.addEventListener('submit', async (event) => { event.preventDefault(); const input = form.elements.message; - addChatMessage(chatId, input.value); + const text = input.value.trim(); + if (!text) return; + + addChatMessage(chatId, text); input.value = ''; renderLog(log, chatId); + + try { + await authService.sendDirectMessage(chatId, text); + } catch (e) { + addChatMessage(chatId, `Ошибка отправки: ${e.message || 'unknown'}`); + renderLog(log, chatId); + } }); renderLog(log, chatId); diff --git a/shine-UI/js/pages/contact-search-view.js b/shine-UI/js/pages/contact-search-view.js index 5c3c6a2..4b87e4c 100644 --- a/shine-UI/js/pages/contact-search-view.js +++ b/shine-UI/js/pages/contact-search-view.js @@ -1,18 +1,9 @@ import { renderHeader } from '../components/header.js?v=20260403081123'; -import { contactDirectory, directMessages } from '../mock-data.js?v=20260403081123'; -import { ensureChat } from '../state.js?v=20260403081123'; +import { directMessages } from '../mock-data.js?v=20260403081123'; +import { authService, ensureChat, setContacts, state } from '../state.js?v=20260403081123'; export const pageMeta = { id: 'contact-search-view', title: 'Поиск контактов' }; -function getMatches(query) { - const normalized = query.trim().toLowerCase(); - if (!normalized) return []; - - return contactDirectory - .filter((contact) => contact.name.toLowerCase().startsWith(normalized)) - .slice(0, 5); -} - export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; @@ -21,7 +12,7 @@ export function render({ navigate }) { input.className = 'input'; input.type = 'text'; input.name = 'contact'; - input.placeholder = 'Введите имя контакта'; + input.placeholder = 'Введите начало логина'; input.autocomplete = 'off'; input.maxLength = 80; @@ -43,7 +34,7 @@ export function render({ navigate }) { resultsCard.hidden = false; if (!query.trim()) { - status.textContent = 'Введите первые буквы имени, чтобы найти контакт.'; + status.textContent = 'Введите начало логина пользователя.'; return; } @@ -54,16 +45,16 @@ export function render({ navigate }) { status.textContent = `Найдено пользователей: ${matches.length}`; - matches.forEach((contact) => { + matches.forEach((login) => { const row = document.createElement('article'); row.className = 'list-item'; row.innerHTML = ` -
${contact.initials}
+
${(login[0] || '?').toUpperCase()}
- ${contact.name} -

${contact.about}

+ ${login} +

Пользователь сервера

-
Контакт
+
Профиль
`; resultsList.append(row); }); @@ -72,38 +63,48 @@ export function render({ navigate }) { const searchButton = document.createElement('button'); searchButton.className = 'primary-btn'; searchButton.type = 'button'; - searchButton.textContent = 'Найти'; - searchButton.addEventListener('click', () => { - renderResults(getMatches(input.value), input.value); + searchButton.textContent = 'Поиск'; + searchButton.addEventListener('click', async () => { + try { + const logins = await authService.searchUsers(input.value.trim()); + renderResults(logins, input.value); + } catch (e) { + status.textContent = `Ошибка поиска: ${e.message || 'unknown'}`; + resultsCard.hidden = false; + } }); const addButton = document.createElement('button'); addButton.className = 'ghost-btn'; addButton.type = 'button'; - addButton.textContent = 'Добавить'; + addButton.textContent = 'Открыть чат'; addButton.addEventListener('click', () => { if (!latestMatches.length) { - status.textContent = 'Сначала выполните поиск, чтобы добавить контакт.'; + status.textContent = 'Сначала выполните поиск.'; resultsCard.hidden = false; return; } - const contact = latestMatches[0]; - const exists = directMessages.some((item) => item.id === contact.id); + const login = latestMatches[0]; + const exists = directMessages.some((item) => item.id === login); if (!exists) { directMessages.unshift({ - id: contact.id, - name: contact.name, - initials: contact.initials, - lastMessage: 'Новый контакт добавлен. Можно начинать диалог.', + id: login, + name: login, + initials: (login[0] || '?').toUpperCase(), + lastMessage: 'Диалог создан. Пользователь пока не в контактах.', time: 'сейчас', unread: 0, }); } - ensureChat(contact.id); - navigate(`chat-view/${contact.id}`); + if (!state.contacts.includes(login)) { + setContacts([...state.contacts, login]); + } + + ensureChat(login); + navigate(`chat-view/${login}`); }); const controls = document.createElement('div'); diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js index ec56683..fd30c3d 100644 --- a/shine-UI/js/pages/network-view.js +++ b/shine-UI/js/pages/network-view.js @@ -1,32 +1,71 @@ import { renderHeader } from '../components/header.js?v=20260403081123'; -import { networkGraph } from '../mock-data.js?v=20260403081123'; +import { authService, state } from '../state.js?v=20260403081123'; export const pageMeta = { id: 'network-view', title: 'Связи' }; -function toPoint(v) { - return `${v.x}%`; +function makeNode(name, cls = '') { + const n = document.createElement('div'); + n.className = `node ${cls}`.trim(); + n.innerHTML = `
${(name[0] || '?').toUpperCase()}
${name}
`; + return n; } -function showHelpModal() { +function showAddCloseFriendModal({ onAdded }) { const root = document.getElementById('modal-root'); root.innerHTML = ` -

${profile.name}

-

${profile.login}

+

${state.session.login || profile.login}

Телефон: ${profile.phone}
diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index f8dc4ef..bd2afb5 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -235,6 +235,54 @@ export class AuthService { return response.payload || {}; } + + onEvent(op, handler) { + return this.ws.onEvent(op, handler); + } + + async upsertPushToken({ tokenId, token, provider = 'fcm', platform = 'web', userAgent = navigator.userAgent || '' }) { + const response = await this.ws.request('UpsertPushToken', { tokenId, token, provider, platform, userAgent }); + if (response.status !== 200) throw opError('UpsertPushToken', response); + return response.payload || {}; + } + + async sendDirectMessage(toLogin, text) { + const response = await this.ws.request('SendDirectMessage', { toLogin, text }); + if (response.status !== 200) throw opError('SendDirectMessage', response); + return response.payload || {}; + } + + async ackIncomingMessage(eventId, messageId) { + const response = await this.ws.request('AckIncomingMessage', { eventId, messageId }); + if (response.status !== 200) throw opError('AckIncomingMessage', response); + return response.payload || {}; + } + + async listContacts() { + const response = await this.ws.request('ListContacts', {}); + if (response.status !== 200) throw opError('ListContacts', response); + return response.payload || {}; + } + + + async addCloseFriend(toLogin) { + const response = await this.ws.request('AddCloseFriend', { toLogin }); + if (response.status !== 200) throw opError('AddCloseFriend', response); + return response.payload || {}; + } + + async getUserConnectionsGraph(login) { + const response = await this.ws.request('GetUserConnectionsGraph', { login }); + if (response.status !== 200) throw opError('GetUserConnectionsGraph', response); + return response.payload || {}; + } + + async searchUsers(prefix) { + const response = await this.ws.request('SearchUsers', { prefix }); + if (response.status !== 200) throw opError('SearchUsers', response); + return response.payload?.logins || []; + } + async reportClientError(details) { try { const response = await this.ws.request('ClientErrorLog', details || {}, 3000); diff --git a/shine-UI/js/services/pwa-push-service.js b/shine-UI/js/services/pwa-push-service.js new file mode 100644 index 0000000..e449db3 --- /dev/null +++ b/shine-UI/js/services/pwa-push-service.js @@ -0,0 +1,51 @@ +const LS_KEY = 'shine-ui-fcm-token-v1'; + +export async function initPwaPush({ authService }) { + if (!('serviceWorker' in navigator)) return; + try { + await navigator.serviceWorker.register('./firebase-messaging-sw.js'); + } catch { + return; + } + + if (!window.firebase || !window.firebase.messaging) return; + + try { + const config = window.__SHINE_FIREBASE_CONFIG__ || null; + if (!config) return; + if (!window.firebase.apps.length) { + window.firebase.initializeApp(config); + } + const messaging = window.firebase.messaging(); + + const permission = await Notification.requestPermission(); + if (permission !== 'granted') return; + + const vapidKey = window.__SHINE_FIREBASE_VAPID_KEY__ || ''; + const token = await messaging.getToken({ vapidKey }); + if (!token) return; + + const prev = localStorage.getItem(LS_KEY); + if (prev === token) return; + + localStorage.setItem(LS_KEY, token); + const tokenId = `tok-${new Date().toISOString().replace(/[-:.TZ]/g, '')}-${Math.random().toString(36).slice(2, 12)}`; + await authService.upsertPushToken({ + tokenId, + token, + provider: 'fcm', + platform: 'web', + userAgent: navigator.userAgent || '', + }); + + messaging.onMessage((payload) => { + const title = payload?.notification?.title || 'Новое сообщение'; + const body = payload?.notification?.body || ''; + try { + new Notification(title, { body }); + } catch {} + }); + } catch { + // silent for MVP + } +} diff --git a/shine-UI/js/services/ws-client.js b/shine-UI/js/services/ws-client.js index 5f9e98c..559a6ff 100644 --- a/shine-UI/js/services/ws-client.js +++ b/shine-UI/js/services/ws-client.js @@ -21,6 +21,7 @@ export class WsJsonClient { this.ws = null; this.pending = new Map(); this.openPromise = null; + this.eventListeners = new Map(); } async open() { @@ -53,6 +54,7 @@ export class WsJsonClient { }); }).finally(() => { this.openPromise = null; + this.eventListeners = new Map(); }); return this.openPromise; @@ -116,14 +118,40 @@ export class WsJsonClient { } const requestId = data?.requestId; - if (!requestId) return; + const isEvent = data?.event === true || (requestId && !this.pending.has(requestId)); + if (isEvent) { + this.emitEvent(data?.op || '', data); + return; + } + if (!requestId) return; const slot = this.pending.get(requestId); if (!slot) return; this.pending.delete(requestId); slot.resolve(data); } + onEvent(op, callback) { + if (!op || typeof callback !== 'function') return () => {}; + if (!this.eventListeners.has(op)) { + this.eventListeners.set(op, new Set()); + } + const set = this.eventListeners.get(op); + set.add(callback); + return () => { + set.delete(callback); + if (!set.size) this.eventListeners.delete(op); + }; + } + + emitEvent(op, data) { + const listeners = this.eventListeners.get(op); + if (!listeners) return; + listeners.forEach((cb) => { + try { cb(data); } catch {} + }); + } + failPending(message) { const pendingOps = [...this.pending.values()] .map((slot) => slot.op) diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index d397dfc..84a874a 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -41,6 +41,8 @@ function createInitialState({ withStoredSession = true } = {}) { const storedSession = withStoredSession ? loadStoredSession() : null; return { chats: clone(chatMessages), + contacts: [], + incomingDedup: {}, notificationsTab: 'replies', pageLabelCollapsed: false, session: { @@ -121,6 +123,20 @@ export function addChatMessage(chatId, text) { getChatMessages(chatId).push({ from: 'out', text: message }); } + +export function addIncomingMessage(chatId, text, messageId = '') { + const msg = text?.trim(); + if (!msg) return false; + if (messageId && state.incomingDedup[messageId]) return false; + if (messageId) state.incomingDedup[messageId] = true; + getChatMessages(chatId).push({ from: 'in', text: msg, messageId }); + return true; +} + +export function setContacts(list) { + state.contacts = Array.isArray(list) ? [...list] : []; +} + export function togglePageLabel() { state.pageLabelCollapsed = !state.pageLabelCollapsed; } diff --git a/shine-UI/manifest.webmanifest b/shine-UI/manifest.webmanifest new file mode 100644 index 0000000..71976c6 --- /dev/null +++ b/shine-UI/manifest.webmanifest @@ -0,0 +1,20 @@ +{ + "name": "Shine UI", + "short_name": "Shine", + "start_url": "./index.html", + "display": "standalone", + "background_color": "#0b1020", + "theme_color": "#0b1020", + "icons": [ + { + "src": "./img/logo.jpg", + "sizes": "192x192", + "type": "image/jpeg" + }, + { + "src": "./img/logo.jpg", + "sizes": "512x512", + "type": "image/jpeg" + } + ] +} diff --git a/shine-UI/styles/layout.css b/shine-UI/styles/layout.css index e8184b5..1b888a4 100644 --- a/shine-UI/styles/layout.css +++ b/shine-UI/styles/layout.css @@ -19,7 +19,7 @@ body { top: 0; left: 0; right: 0; - bottom: 118px; + bottom: 74px; overflow-y: auto; padding: 14px 14px 24px; } @@ -29,13 +29,6 @@ body { padding-bottom: calc(24px + env(safe-area-inset-bottom)); } -.page-label-slot { - position: absolute; - left: 0; - right: 0; - bottom: 99px; - padding: 0 14px; -} .toolbar-slot { position: absolute; diff --git a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index 1e37d0f..88a7851 100644 --- a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -328,6 +328,54 @@ public final class DatabaseInitializer { ON message_stats (to_login); """); + // 9) direct_messages + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS direct_messages ( + message_id TEXT NOT NULL PRIMARY KEY, + from_login TEXT NOT NULL, + to_login TEXT NOT NULL, + text TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + FOREIGN KEY (from_login) REFERENCES solana_users(login), + FOREIGN KEY (to_login) REFERENCES solana_users(login) + ); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_direct_messages_to_login + ON direct_messages (to_login, created_at_ms); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_direct_messages_from_login + ON direct_messages (from_login, created_at_ms); + """); + + // 10) user_push_tokens + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS user_push_tokens ( + token_id TEXT NOT NULL PRIMARY KEY, + login TEXT NOT NULL, + session_id TEXT NOT NULL, + provider TEXT NOT NULL, + token TEXT NOT NULL, + platform TEXT, + user_agent TEXT, + updated_at_ms INTEGER NOT NULL, + FOREIGN KEY (login) REFERENCES solana_users(login) + ); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_user_push_tokens_login + ON user_push_tokens (login); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_user_push_tokens_login_session + ON user_push_tokens (login, session_id); + """); + DatabaseTriggersInstaller.createAllTriggers(st); } } diff --git a/shine-server-db/src/main/java/shine/db/dao/ConnectionsStateDAO.java b/shine-server-db/src/main/java/shine/db/dao/ConnectionsStateDAO.java index 3d03346..1e910bf 100644 --- a/shine-server-db/src/main/java/shine/db/dao/ConnectionsStateDAO.java +++ b/shine-server-db/src/main/java/shine/db/dao/ConnectionsStateDAO.java @@ -125,4 +125,32 @@ public final class ConnectionsStateDAO { } return out; } -} \ No newline at end of file + + public void upsertRelation(Connection c, + String login, + int relType, + String toLogin, + String toBchName, + Integer toBlockNumber, + byte[] toBlockHash) throws SQLException { + String sql = """ + INSERT INTO connections_state (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(login, rel_type, to_login) DO UPDATE SET + to_bch_name=excluded.to_bch_name, + to_block_number=excluded.to_block_number, + to_block_hash=excluded.to_block_hash + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + ps.setInt(2, relType); + ps.setString(3, toLogin); + ps.setString(4, toBchName); + if (toBlockNumber == null) ps.setNull(5, java.sql.Types.INTEGER); else ps.setInt(5, toBlockNumber); + ps.setBytes(6, toBlockHash); + ps.executeUpdate(); + } + } + +} diff --git a/shine-server-db/src/main/java/shine/db/dao/DirectMessagesDAO.java b/shine-server-db/src/main/java/shine/db/dao/DirectMessagesDAO.java new file mode 100644 index 0000000..5d2b4c5 --- /dev/null +++ b/shine-server-db/src/main/java/shine/db/dao/DirectMessagesDAO.java @@ -0,0 +1,53 @@ +package shine.db.dao; + +import shine.db.SqliteDbController; +import shine.db.entities.DirectMessageEntry; + +import java.sql.Connection; +import java.sql.PreparedStatement; + +public final class DirectMessagesDAO { + private static volatile DirectMessagesDAO instance; + private final SqliteDbController db = SqliteDbController.getInstance(); + + private DirectMessagesDAO() {} + + public static DirectMessagesDAO getInstance() { + if (instance == null) { + synchronized (DirectMessagesDAO.class) { + if (instance == null) instance = new DirectMessagesDAO(); + } + } + return instance; + } + + public void insert(DirectMessageEntry entry) throws Exception { + try (Connection c = db.getConnection()) { + String sql = """ + INSERT INTO direct_messages ( + message_id, from_login, to_login, text, created_at_ms + ) VALUES (?, ?, ?, ?, ?) + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, entry.getMessageId()); + ps.setString(2, entry.getFromLogin()); + ps.setString(3, entry.getToLogin()); + ps.setString(4, entry.getText()); + ps.setLong(5, entry.getCreatedAtMs()); + ps.executeUpdate(); + } + } + } + + public boolean existsFromTo(String fromLogin, String toLogin) throws Exception { + try (Connection c = db.getConnection()) { + String sql = "SELECT 1 FROM direct_messages WHERE from_login = ? AND to_login = ? LIMIT 1"; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, fromLogin); + ps.setString(2, toLogin); + return ps.executeQuery().next(); + } + } + } + +} diff --git a/shine-server-db/src/main/java/shine/db/dao/PushTokensDAO.java b/shine-server-db/src/main/java/shine/db/dao/PushTokensDAO.java new file mode 100644 index 0000000..1fc3ef3 --- /dev/null +++ b/shine-server-db/src/main/java/shine/db/dao/PushTokensDAO.java @@ -0,0 +1,116 @@ +package shine.db.dao; + +import shine.db.SqliteDbController; +import shine.db.entities.PushTokenEntry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; + +public final class PushTokensDAO { + private static volatile PushTokensDAO instance; + private final SqliteDbController db = SqliteDbController.getInstance(); + + private PushTokensDAO() {} + + public static PushTokensDAO getInstance() { + if (instance == null) { + synchronized (PushTokensDAO.class) { + if (instance == null) instance = new PushTokensDAO(); + } + } + return instance; + } + + public void upsert(PushTokenEntry entry) throws Exception { + try (Connection c = db.getConnection()) { + String sql = """ + INSERT INTO user_push_tokens ( + token_id, login, session_id, provider, token, platform, user_agent, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(token_id) DO UPDATE SET + login=excluded.login, + session_id=excluded.session_id, + provider=excluded.provider, + token=excluded.token, + platform=excluded.platform, + user_agent=excluded.user_agent, + updated_at_ms=excluded.updated_at_ms + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, entry.getTokenId()); + ps.setString(2, entry.getLogin()); + ps.setString(3, entry.getSessionId()); + ps.setString(4, entry.getProvider()); + ps.setString(5, entry.getToken()); + ps.setString(6, entry.getPlatform()); + ps.setString(7, entry.getUserAgent()); + ps.setLong(8, entry.getUpdatedAtMs()); + ps.executeUpdate(); + } + } + } + + public List listByLogin(String login) throws Exception { + try (Connection c = db.getConnection()) { + String sql = """ + SELECT token_id, login, session_id, provider, token, platform, user_agent, updated_at_ms + FROM user_push_tokens + WHERE login = ? + ORDER BY updated_at_ms DESC + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + try (ResultSet rs = ps.executeQuery()) { + List out = new ArrayList<>(); + while (rs.next()) { + PushTokenEntry e = new PushTokenEntry(); + e.setTokenId(rs.getString("token_id")); + e.setLogin(rs.getString("login")); + e.setSessionId(rs.getString("session_id")); + e.setProvider(rs.getString("provider")); + e.setToken(rs.getString("token")); + e.setPlatform(rs.getString("platform")); + e.setUserAgent(rs.getString("user_agent")); + e.setUpdatedAtMs(rs.getLong("updated_at_ms")); + out.add(e); + } + return out; + } + } + } + } + + public List listByLoginAndSession(String login, String sessionId) throws Exception { + try (Connection c = db.getConnection()) { + String sql = """ + SELECT token_id, login, session_id, provider, token, platform, user_agent, updated_at_ms + FROM user_push_tokens + WHERE login = ? AND session_id = ? + ORDER BY updated_at_ms DESC + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + ps.setString(2, sessionId); + try (ResultSet rs = ps.executeQuery()) { + List out = new ArrayList<>(); + while (rs.next()) { + PushTokenEntry e = new PushTokenEntry(); + e.setTokenId(rs.getString("token_id")); + e.setLogin(rs.getString("login")); + e.setSessionId(rs.getString("session_id")); + e.setProvider(rs.getString("provider")); + e.setToken(rs.getString("token")); + e.setPlatform(rs.getString("platform")); + e.setUserAgent(rs.getString("user_agent")); + e.setUpdatedAtMs(rs.getLong("updated_at_ms")); + out.add(e); + } + return out; + } + } + } + } +} diff --git a/shine-server-db/src/main/java/shine/db/entities/DirectMessageEntry.java b/shine-server-db/src/main/java/shine/db/entities/DirectMessageEntry.java new file mode 100644 index 0000000..d916e6f --- /dev/null +++ b/shine-server-db/src/main/java/shine/db/entities/DirectMessageEntry.java @@ -0,0 +1,24 @@ +package shine.db.entities; + +public class DirectMessageEntry { + private String messageId; + private String fromLogin; + private String toLogin; + private String text; + private long createdAtMs; + + public String getMessageId() { return messageId; } + public void setMessageId(String messageId) { this.messageId = messageId; } + + public String getFromLogin() { return fromLogin; } + public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; } + + public String getToLogin() { return toLogin; } + public void setToLogin(String toLogin) { this.toLogin = toLogin; } + + public String getText() { return text; } + public void setText(String text) { this.text = text; } + + public long getCreatedAtMs() { return createdAtMs; } + public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; } +} diff --git a/shine-server-db/src/main/java/shine/db/entities/PushTokenEntry.java b/shine-server-db/src/main/java/shine/db/entities/PushTokenEntry.java new file mode 100644 index 0000000..85d03b2 --- /dev/null +++ b/shine-server-db/src/main/java/shine/db/entities/PushTokenEntry.java @@ -0,0 +1,36 @@ +package shine.db.entities; + +public class PushTokenEntry { + private String tokenId; + private String login; + private String sessionId; + private String provider; + private String token; + private String platform; + private String userAgent; + private long updatedAtMs; + + public String getTokenId() { return tokenId; } + public void setTokenId(String tokenId) { this.tokenId = tokenId; } + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public String getSessionId() { return sessionId; } + public void setSessionId(String sessionId) { this.sessionId = sessionId; } + + public String getProvider() { return provider; } + public void setProvider(String provider) { this.provider = provider; } + + public String getToken() { return token; } + public void setToken(String token) { this.token = token; } + + public String getPlatform() { return platform; } + public void setPlatform(String platform) { this.platform = platform; } + + public String getUserAgent() { return userAgent; } + public void setUserAgent(String userAgent) { this.userAgent = userAgent; } + + public long getUpdatedAtMs() { return updatedAtMs; } + public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 3d41a40..5862635 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -51,6 +51,18 @@ import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request; +import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler; +import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler; +import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler; +import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request; +import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request; +import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request; +import server.logic.ws_protocol.JSON.messages.Net_AckIncomingMessage_Handler; +import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler; +import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler; +import server.logic.ws_protocol.JSON.messages.entyties.Net_AckIncomingMessage_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Request; // --- NEW: Ping --- import server.logic.ws_protocol.JSON.handlers.system.Net_GetServerInfo_Handler; @@ -96,6 +108,14 @@ public final class JsonHandlerRegistry { Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()), Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()), Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()), + Map.entry("ListContacts", new Net_ListContacts_Handler()), + Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()), + Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()), + + // --- direct messages / push --- + Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()), + Map.entry("SendDirectMessage", new Net_SendDirectMessage_Handler()), + Map.entry("AckIncomingMessage", new Net_AckIncomingMessage_Handler()), // --- system --- Map.entry("Ping", new Net_Ping_Handler()), @@ -134,6 +154,14 @@ public final class JsonHandlerRegistry { Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class), Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class), Map.entry("GetMessageThread", Net_GetMessageThread_Request.class), + Map.entry("ListContacts", Net_ListContacts_Request.class), + Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class), + Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class), + + // --- direct messages / push --- + Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class), + Map.entry("SendDirectMessage", Net_SendDirectMessage_Request.class), + Map.entry("AckIncomingMessage", Net_AckIncomingMessage_Request.class), // --- system --- Map.entry("Ping", Net_Ping_Request.class), diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CloseActiveSession_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CloseActiveSession_Handler.java index f3218d1..e5ceb22 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CloseActiveSession_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CloseActiveSession_Handler.java @@ -1,5 +1,7 @@ package server.logic.ws_protocol.JSON.handlers.auth; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; @@ -10,6 +12,8 @@ import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.JSON.push.WsEventSender; +import server.logic.ws_protocol.JSON.utils.NetIdGenerator; import server.logic.ws_protocol.WireCodes; import server.ws.WsConnectionUtils; import shine.db.dao.ActiveSessionsDAO; @@ -32,6 +36,7 @@ import java.sql.SQLException; public class Net_CloseActiveSession_Handler implements JsonMessageHandler { private static final Logger log = LoggerFactory.getLogger(Net_CloseActiveSession_Handler.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); @Override public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { @@ -122,6 +127,14 @@ public class Net_CloseActiveSession_Handler implements JsonMessageHandler { if (ctxToClose == null) return; + String eventId = NetIdGenerator.eventId("evt"); + ObjectNode payload = MAPPER.createObjectNode(); + payload.put("eventId", eventId); + payload.put("sessionId", targetSessionId); + payload.put("reason", "closed_from_another_device"); + payload.put("timeMs", System.currentTimeMillis()); + WsEventSender.sendEvent(ctxToClose, "SessionRevoked", eventId, payload); + if (isCurrentSession && ctxToClose == currentCtx) { new Thread(() -> { try { Thread.sleep(50); } catch (InterruptedException ignored) {} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_AddCloseFriend_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_AddCloseFriend_Handler.java new file mode 100644 index 0000000..3cfe34f --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_AddCloseFriend_Handler.java @@ -0,0 +1,85 @@ +package server.logic.ws_protocol.JSON.handlers.connections; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request; +import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.MsgSubType; +import shine.db.dao.ConnectionsStateDAO; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +public class Net_AddCloseFriend_Handler implements JsonMessageHandler { + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { + Net_AddCloseFriend_Request req = (Net_AddCloseFriend_Request) baseRequest; + if (ctx == null || !ctx.isAuthenticatedUser()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация"); + } + if (req.getToLogin() == null || req.getToLogin().isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin обязателен"); + } + + String from = ctx.getLogin(); + String toLogin = req.getToLogin().trim(); + if (from.equalsIgnoreCase(toLogin)) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Нельзя добавить себя"); + } + + try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) { + String canonicalTo = findCanonicalLogin(c, toLogin); + if (canonicalTo == null) { + return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден"); + } + String targetBch = findPrimaryBlockchain(c, canonicalTo); + if (targetBch == null) { + return NetExceptionResponseFactory.error(req, 404, "BLOCKCHAIN_NOT_FOUND", "У пользователя нет blockchain"); + } + + ConnectionsStateDAO.getInstance().upsertRelation( + c, + from, + MsgSubType.CONNECTION_FRIEND, + canonicalTo, + targetBch, + 0, + new byte[32] + ); + + Net_AddCloseFriend_Response resp = new Net_AddCloseFriend_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setLogin(from); + resp.setToLogin(canonicalTo); + resp.setRelation("close_friend"); + return resp; + } + } + + private String findCanonicalLogin(Connection c, String login) throws Exception { + String sql = "SELECT login FROM solana_users WHERE login = ? COLLATE NOCASE LIMIT 1"; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getString("login") : null; + } + } + } + + private String findPrimaryBlockchain(Connection c, String login) throws Exception { + String sql = "SELECT blockchain_name FROM blockchain_state WHERE login=? ORDER BY blockchain_name LIMIT 1"; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getString("blockchain_name") : null; + } + } + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java new file mode 100644 index 0000000..837de68 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java @@ -0,0 +1,40 @@ +package server.logic.ws_protocol.JSON.handlers.connections; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request; +import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.MsgSubType; +import shine.db.dao.ConnectionsStateDAO; + +import java.sql.Connection; +import java.util.List; + +public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler { + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { + Net_GetUserConnectionsGraph_Request req = (Net_GetUserConnectionsGraph_Request) baseRequest; + if (ctx == null || !ctx.isAuthenticatedUser()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация"); + } + String login = (req.getLogin() == null || req.getLogin().isBlank()) ? ctx.getLogin() : req.getLogin().trim(); + + try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) { + List out = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, login, MsgSubType.CONNECTION_FRIEND); + List in = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, login, MsgSubType.CONNECTION_FRIEND); + + Net_GetUserConnectionsGraph_Response resp = new Net_GetUserConnectionsGraph_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setLogin(login); + resp.setOutFriends(out); + resp.setInFriends(in); + return resp; + } + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_ListContacts_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_ListContacts_Handler.java new file mode 100644 index 0000000..7275ed7 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_ListContacts_Handler.java @@ -0,0 +1,36 @@ +package server.logic.ws_protocol.JSON.handlers.connections; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request; +import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.MsgSubType; +import shine.db.dao.ConnectionsStateDAO; + +import java.sql.Connection; +import java.util.List; + +public class Net_ListContacts_Handler implements JsonMessageHandler { + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { + Net_ListContacts_Request req = (Net_ListContacts_Request) baseRequest; + if (ctx == null || !ctx.isAuthenticatedUser()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация"); + } + + try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) { + List contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, ctx.getLogin(), MsgSubType.CONNECTION_CONTACT); + Net_ListContacts_Response resp = new Net_ListContacts_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setLogin(ctx.getLogin()); + resp.setContacts(contacts); + return resp; + } + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_AddCloseFriend_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_AddCloseFriend_Request.java new file mode 100644 index 0000000..76caef3 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_AddCloseFriend_Request.java @@ -0,0 +1,10 @@ +package server.logic.ws_protocol.JSON.handlers.connections.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_AddCloseFriend_Request extends Net_Request { + private String toLogin; + + public String getToLogin() { return toLogin; } + public void setToLogin(String toLogin) { this.toLogin = toLogin; } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_AddCloseFriend_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_AddCloseFriend_Response.java new file mode 100644 index 0000000..5e5b07e --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_AddCloseFriend_Response.java @@ -0,0 +1,16 @@ +package server.logic.ws_protocol.JSON.handlers.connections.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_AddCloseFriend_Response extends Net_Response { + private String login; + private String toLogin; + private String relation; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + public String getToLogin() { return toLogin; } + public void setToLogin(String toLogin) { this.toLogin = toLogin; } + public String getRelation() { return relation; } + public void setRelation(String relation) { this.relation = relation; } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_GetUserConnectionsGraph_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_GetUserConnectionsGraph_Request.java new file mode 100644 index 0000000..10142d6 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_GetUserConnectionsGraph_Request.java @@ -0,0 +1,10 @@ +package server.logic.ws_protocol.JSON.handlers.connections.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_GetUserConnectionsGraph_Request extends Net_Request { + private String login; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_GetUserConnectionsGraph_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_GetUserConnectionsGraph_Response.java new file mode 100644 index 0000000..86d4ce5 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_GetUserConnectionsGraph_Response.java @@ -0,0 +1,19 @@ +package server.logic.ws_protocol.JSON.handlers.connections.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +import java.util.ArrayList; +import java.util.List; + +public class Net_GetUserConnectionsGraph_Response extends Net_Response { + private String login; + private List outFriends = new ArrayList<>(); + private List inFriends = new ArrayList<>(); + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + public List getOutFriends() { return outFriends; } + public void setOutFriends(List outFriends) { this.outFriends = outFriends; } + public List getInFriends() { return inFriends; } + public void setInFriends(List inFriends) { this.inFriends = inFriends; } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_ListContacts_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_ListContacts_Request.java new file mode 100644 index 0000000..c8f8116 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_ListContacts_Request.java @@ -0,0 +1,6 @@ +package server.logic.ws_protocol.JSON.handlers.connections.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_ListContacts_Request extends Net_Request { +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_ListContacts_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_ListContacts_Response.java new file mode 100644 index 0000000..cdb2f5b --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_ListContacts_Response.java @@ -0,0 +1,16 @@ +package server.logic.ws_protocol.JSON.handlers.connections.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +import java.util.ArrayList; +import java.util.List; + +public class Net_ListContacts_Response extends Net_Response { + private String login; + private List contacts = new ArrayList<>(); + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + public List getContacts() { return contacts; } + public void setContacts(List contacts) { this.contacts = contacts; } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DeliveryTracker.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DeliveryTracker.java new file mode 100644 index 0000000..0bb2bd4 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DeliveryTracker.java @@ -0,0 +1,34 @@ +package server.logic.ws_protocol.JSON.messages; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +public final class DeliveryTracker { + private static final DeliveryTracker INSTANCE = new DeliveryTracker(); + + public static DeliveryTracker getInstance() { return INSTANCE; } + + private final ConcurrentHashMap> waiters = new ConcurrentHashMap<>(); + + private DeliveryTracker() {} + + public CompletableFuture register(String eventId) { + CompletableFuture f = new CompletableFuture<>(); + waiters.put(eventId, f); + return f; + } + + public void ack(String eventId) { + CompletableFuture f = waiters.remove(eventId); + if (f != null) f.complete(true); + } + + public void fail(String eventId) { + CompletableFuture f = waiters.remove(eventId); + if (f != null) f.complete(false); + } + + public void remove(String eventId) { + waiters.remove(eventId); + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_AckIncomingMessage_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_AckIncomingMessage_Handler.java new file mode 100644 index 0000000..90b9abc --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_AckIncomingMessage_Handler.java @@ -0,0 +1,29 @@ +package server.logic.ws_protocol.JSON.messages; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.messages.entyties.Net_AckIncomingMessage_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_AckIncomingMessage_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; + +public class Net_AckIncomingMessage_Handler implements JsonMessageHandler { + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_AckIncomingMessage_Request req = (Net_AckIncomingMessage_Request) baseRequest; + if (ctx == null || !ctx.isAuthenticatedUser()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация"); + } + if (req.getEventId() != null && !req.getEventId().isBlank()) { + DeliveryTracker.getInstance().ack(req.getEventId()); + } + + Net_AckIncomingMessage_Response resp = new Net_AckIncomingMessage_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + return resp; + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendDirectMessage_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendDirectMessage_Handler.java new file mode 100644 index 0000000..12c1bb8 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendDirectMessage_Handler.java @@ -0,0 +1,123 @@ +package server.logic.ws_protocol.JSON.messages; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Response; +import server.logic.ws_protocol.JSON.push.FcmPushSender; +import server.logic.ws_protocol.JSON.push.WsEventSender; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.JSON.utils.NetIdGenerator; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.DirectMessagesDAO; +import shine.db.dao.PushTokensDAO; +import shine.db.entities.DirectMessageEntry; +import shine.db.entities.PushTokenEntry; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +public class Net_SendDirectMessage_Handler implements JsonMessageHandler { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { + Net_SendDirectMessage_Request req = (Net_SendDirectMessage_Request) baseRequest; + if (ctx == null || !ctx.isAuthenticatedUser()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация"); + } + if (req.getToLogin() == null || req.getToLogin().isBlank() || req.getText() == null || req.getText().isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin и text обязательны"); + } + + String from = ctx.getLogin(); + String to = req.getToLogin().trim(); + String text = req.getText().trim(); + + if (!canSend(from, to)) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NO_PERMISSION", "Можно писать только контактам или тем, кто уже писал вам"); + } + + String messageId = NetIdGenerator.eventId("msg"); + DirectMessageEntry entry = new DirectMessageEntry(); + entry.setMessageId(messageId); + entry.setFromLogin(from); + entry.setToLogin(to); + entry.setText(text); + entry.setCreatedAtMs(System.currentTimeMillis()); + DirectMessagesDAO.getInstance().insert(entry); + + Set activeSessions = ActiveConnectionsRegistry.getInstance().getByLogin(to); + List tokens = PushTokensDAO.getInstance().listByLogin(to); + + int wsDelivered = 0; + int fcmDelivered = 0; + + Set activeSessionIds = new HashSet<>(); + for (ConnectionContext targetCtx : activeSessions) { + activeSessionIds.add(targetCtx.getSessionId()); + String eventId = NetIdGenerator.eventId("evt"); + CompletableFuture waiter = DeliveryTracker.getInstance().register(eventId); + + ObjectNode payload = MAPPER.createObjectNode(); + payload.put("eventId", eventId); + payload.put("messageId", messageId); + payload.put("fromLogin", from); + payload.put("toLogin", to); + payload.put("text", text); + payload.put("timeMs", entry.getCreatedAtMs()); + + boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingDirectMessage", eventId, payload); + boolean acked = false; + if (sent) { + try { + acked = waiter.get(1200, TimeUnit.MILLISECONDS); + } catch (Exception ignored) { + acked = false; + } + } + DeliveryTracker.getInstance().remove(eventId); + if (acked) { + wsDelivered++; + continue; + } + + for (PushTokenEntry token : tokens) { + if (!targetCtx.getSessionId().equals(token.getSessionId())) continue; + boolean pushed = FcmPushSender.sendNotification(token.getToken(), "Новое сообщение", text, messageId); + if (pushed) { + fcmDelivered++; + break; + } + } + } + + for (PushTokenEntry token : tokens) { + if (activeSessionIds.contains(token.getSessionId())) continue; + boolean pushed = FcmPushSender.sendNotification(token.getToken(), "Новое сообщение", text, messageId); + if (pushed) fcmDelivered++; + } + + Net_SendDirectMessage_Response resp = new Net_SendDirectMessage_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setMessageId(messageId); + resp.setDeliveredWsSessions(wsDelivered); + resp.setDeliveredFcmSessions(fcmDelivered); + return resp; + } + + private boolean canSend(String from, String to) { + return from != null && !from.isBlank() && to != null && !to.isBlank(); + } +} + diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_UpsertPushToken_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_UpsertPushToken_Handler.java new file mode 100644 index 0000000..7fefe7a --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_UpsertPushToken_Handler.java @@ -0,0 +1,44 @@ +package server.logic.ws_protocol.JSON.messages; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.PushTokensDAO; +import shine.db.entities.PushTokenEntry; + +public class Net_UpsertPushToken_Handler implements JsonMessageHandler { + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { + Net_UpsertPushToken_Request req = (Net_UpsertPushToken_Request) baseRequest; + if (ctx == null || !ctx.isAuthenticatedUser()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация"); + } + if (req.getTokenId() == null || req.getTokenId().isBlank() || req.getToken() == null || req.getToken().isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "tokenId и token обязательны"); + } + + PushTokenEntry e = new PushTokenEntry(); + e.setTokenId(req.getTokenId().trim()); + e.setLogin(ctx.getLogin()); + e.setSessionId((req.getSessionId() == null || req.getSessionId().isBlank()) ? ctx.getSessionId() : req.getSessionId().trim()); + e.setProvider(req.getProvider() == null || req.getProvider().isBlank() ? "fcm" : req.getProvider().trim()); + e.setToken(req.getToken().trim()); + e.setPlatform(req.getPlatform()); + e.setUserAgent(req.getUserAgent()); + e.setUpdatedAtMs(System.currentTimeMillis()); + PushTokensDAO.getInstance().upsert(e); + + Net_UpsertPushToken_Response resp = new Net_UpsertPushToken_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setTokenId(e.getTokenId()); + resp.setUpdatedAtMs(e.getUpdatedAtMs()); + return resp; + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_AckIncomingMessage_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_AckIncomingMessage_Request.java new file mode 100644 index 0000000..4ceec01 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_AckIncomingMessage_Request.java @@ -0,0 +1,13 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_AckIncomingMessage_Request extends Net_Request { + private String eventId; + private String messageId; + + public String getEventId() { return eventId; } + public void setEventId(String eventId) { this.eventId = eventId; } + public String getMessageId() { return messageId; } + public void setMessageId(String messageId) { this.messageId = messageId; } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_AckIncomingMessage_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_AckIncomingMessage_Response.java new file mode 100644 index 0000000..c8b8906 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_AckIncomingMessage_Response.java @@ -0,0 +1,6 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_AckIncomingMessage_Response extends Net_Response { +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendDirectMessage_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendDirectMessage_Request.java new file mode 100644 index 0000000..45dda9c --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendDirectMessage_Request.java @@ -0,0 +1,13 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_SendDirectMessage_Request extends Net_Request { + private String toLogin; + private String text; + + public String getToLogin() { return toLogin; } + public void setToLogin(String toLogin) { this.toLogin = toLogin; } + public String getText() { return text; } + public void setText(String text) { this.text = text; } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendDirectMessage_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendDirectMessage_Response.java new file mode 100644 index 0000000..bc83bc4 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendDirectMessage_Response.java @@ -0,0 +1,16 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_SendDirectMessage_Response extends Net_Response { + private String messageId; + private int deliveredWsSessions; + private int deliveredFcmSessions; + + public String getMessageId() { return messageId; } + public void setMessageId(String messageId) { this.messageId = messageId; } + public int getDeliveredWsSessions() { return deliveredWsSessions; } + public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; } + public int getDeliveredFcmSessions() { return deliveredFcmSessions; } + public void setDeliveredFcmSessions(int deliveredFcmSessions) { this.deliveredFcmSessions = deliveredFcmSessions; } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_UpsertPushToken_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_UpsertPushToken_Request.java new file mode 100644 index 0000000..9e87e88 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_UpsertPushToken_Request.java @@ -0,0 +1,25 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_UpsertPushToken_Request extends Net_Request { + private String tokenId; + private String sessionId; + private String provider; + private String token; + private String platform; + private String userAgent; + + public String getTokenId() { return tokenId; } + public void setTokenId(String tokenId) { this.tokenId = tokenId; } + public String getSessionId() { return sessionId; } + public void setSessionId(String sessionId) { this.sessionId = sessionId; } + public String getProvider() { return provider; } + public void setProvider(String provider) { this.provider = provider; } + public String getToken() { return token; } + public void setToken(String token) { this.token = token; } + public String getPlatform() { return platform; } + public void setPlatform(String platform) { this.platform = platform; } + public String getUserAgent() { return userAgent; } + public void setUserAgent(String userAgent) { this.userAgent = userAgent; } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_UpsertPushToken_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_UpsertPushToken_Response.java new file mode 100644 index 0000000..4218a34 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_UpsertPushToken_Response.java @@ -0,0 +1,13 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_UpsertPushToken_Response extends Net_Response { + private String tokenId; + private long updatedAtMs; + + public String getTokenId() { return tokenId; } + public void setTokenId(String tokenId) { this.tokenId = tokenId; } + public long getUpdatedAtMs() { return updatedAtMs; } + public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/FcmPushSender.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/FcmPushSender.java new file mode 100644 index 0000000..2ddfd52 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/FcmPushSender.java @@ -0,0 +1,52 @@ +package server.logic.ws_protocol.JSON.push; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import utils.config.AppConfig; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +public final class FcmPushSender { + private static final Logger log = LoggerFactory.getLogger(FcmPushSender.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final HttpClient HTTP = HttpClient.newHttpClient(); + + private FcmPushSender() {} + + public static boolean sendNotification(String token, String title, String body, String messageId) { + try { + String serverKey = AppConfig.getInstance().getStringOrEmpty("fcm.server.key"); + if (serverKey.isBlank()) { + log.warn("fcm.server.key is empty, skip FCM send"); + return false; + } + + ObjectNode root = MAPPER.createObjectNode(); + root.put("to", token); + ObjectNode notif = root.putObject("notification"); + notif.put("title", title); + notif.put("body", body); + ObjectNode data = root.putObject("data"); + data.put("messageId", messageId); + + HttpRequest req = HttpRequest.newBuilder(URI.create("https://fcm.googleapis.com/fcm/send")) + .timeout(Duration.ofSeconds(5)) + .header("Authorization", "key=" + serverKey) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(root.toString())) + .build(); + + HttpResponse resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString()); + return resp.statusCode() >= 200 && resp.statusCode() < 300; + } catch (Exception e) { + log.warn("FCM send failed", e); + return false; + } + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/WsEventSender.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/WsEventSender.java new file mode 100644 index 0000000..c09639b --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/WsEventSender.java @@ -0,0 +1,35 @@ +package server.logic.ws_protocol.JSON.push; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.eclipse.jetty.websocket.api.Session; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.ConnectionContext; + +public final class WsEventSender { + private static final Logger log = LoggerFactory.getLogger(WsEventSender.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private WsEventSender() {} + + public static boolean sendEvent(ConnectionContext ctx, String op, String eventId, ObjectNode payload) { + if (ctx == null) return false; + Session session = ctx.getWsSession(); + if (session == null || !session.isOpen()) return false; + try { + ObjectNode root = MAPPER.createObjectNode(); + root.put("op", op); + root.put("requestId", eventId); + root.put("status", 200); + root.put("ok", true); + root.put("event", true); + root.set("payload", payload == null ? MAPPER.createObjectNode() : payload); + session.getRemote().sendString(root.toString()); + return true; + } catch (Exception e) { + log.warn("Failed to send ws event op={} sessionId={}", op, ctx.getSessionId(), e); + return false; + } + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/utils/NetIdGenerator.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/utils/NetIdGenerator.java new file mode 100644 index 0000000..999d612 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/utils/NetIdGenerator.java @@ -0,0 +1,28 @@ +package server.logic.ws_protocol.JSON.utils; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.ThreadLocalRandom; + +public final class NetIdGenerator { + private static final String ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss-SSS"); + + private NetIdGenerator() {} + + public static String eventId(String prefix) { + LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); + return (prefix == null || prefix.isBlank() ? "evt" : prefix) + + "-" + now.format(FMT) + + "-" + randomSuffix(10); + } + + private static String randomSuffix(int len) { + StringBuilder sb = new StringBuilder(len); + for (int i = 0; i < len; i++) { + sb.append(ALPHABET.charAt(ThreadLocalRandom.current().nextInt(ALPHABET.length()))); + } + return sb.toString(); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 0173e02..6492c61 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -12,3 +12,6 @@ server.info.physicalRegion= server.info.description= server.info.origin= server.info.extraInfo= + +# FCM (legacy HTTP) +fcm.server.key=