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 = `
-
+
-
Справка по схеме связей
-
В центре находишься ты.
-
Рядом показаны друзья первого уровня.
-
Далее могут существовать друзья второго уровня.
-
При одном нажатии на узел можно показать его связи.
-
При двойном нажатии узел может переместиться в центр.
-
При долгом удержании может открываться меню действий.
-
Логика схемы строится на одном запросе связей пользователя, дальше дерево достраивается на его основе.
-
Понятно
+
Добавить близкого друга
+
+
+ Поиск
+ Назад
+
+
`;
- root.querySelector('#close-network-help').addEventListener('click', () => {
- root.innerHTML = '';
+ const close = () => { root.innerHTML = ''; };
+ root.querySelector('#close-friend-back').addEventListener('click', close);
+
+ root.querySelector('#close-friend-search').addEventListener('click', async () => {
+ const query = root.querySelector('#close-friend-query').value.trim();
+ const holder = root.querySelector('#close-friend-results');
+ holder.innerHTML = '
Поиск...
';
+
+ try {
+ const logins = await authService.searchUsers(query);
+ holder.innerHTML = '';
+ if (!logins.length) {
+ holder.innerHTML = '
Пользователи не найдены.
';
+ return;
+ }
+
+ logins.forEach((login) => {
+ const row = document.createElement('article');
+ row.className = 'list-item';
+ row.innerHTML = `
+
${(login[0] || '?').toUpperCase()}
+
+
Добавить
+ `;
+ row.addEventListener('click', async () => {
+ const yes = window.confirm(`Добавить ${login} в близкие друзья?`);
+ if (!yes) return;
+ try {
+ await authService.addCloseFriend(login);
+ close();
+ if (typeof onAdded === 'function') await onAdded();
+ } catch (e) {
+ window.alert(`Ошибка добавления: ${e.message || 'unknown'}`);
+ }
+ });
+ holder.append(row);
+ });
+ } catch (e) {
+ holder.innerHTML = `
Ошибка поиска: ${e.message || 'unknown'}
`;
+ }
});
}
@@ -34,44 +73,68 @@ export function render() {
const screen = document.createElement('section');
screen.className = 'stack';
- const header = renderHeader({
- title: 'Связи',
- rightActions: [{ label: 'Справка', onClick: showHelpModal }],
- });
-
const board = document.createElement('div');
board.className = 'network-board';
-
- const lines = networkGraph.peers
- .map(
- (peer) =>
- `
`
- )
- .join('');
-
- board.innerHTML = `
${lines} `;
-
- const centerNode = document.createElement('div');
- centerNode.className = 'node center';
- centerNode.style.left = `${networkGraph.center.x}%`;
- centerNode.style.top = `${networkGraph.center.y}%`;
- centerNode.innerHTML = `
${networkGraph.center.initials}
${networkGraph.center.name}
`;
-
- board.append(centerNode);
-
- networkGraph.peers.forEach((peer) => {
- const node = document.createElement('div');
- node.className = 'node';
- node.style.left = `${peer.x}%`;
- node.style.top = `${peer.y}%`;
- node.innerHTML = `
${peer.initials}
${peer.name}
`;
- board.append(node);
- });
+ board.style.height = 'calc(100dvh - 170px)';
const note = document.createElement('p');
note.className = 'meta-muted';
- note.textContent = 'Схема статичная для демо, архитектура подготовлена под дальнейшую интерактивность.';
+ note.textContent = 'Загрузка связей...';
- screen.append(header, board, note);
+ const load = async (centerLogin) => {
+ try {
+ const graph = await authService.getUserConnectionsGraph(centerLogin || state.session.login);
+ board.innerHTML = '';
+ const center = makeNode(graph.login || state.session.login, 'center');
+ center.style.left = '50%';
+ center.style.top = '50%';
+ board.append(center);
+
+ const all = [...new Set([...(graph.outFriends || []), ...(graph.inFriends || [])])];
+ const left = all.slice(0, Math.ceil(all.length / 2));
+ const right = all.slice(Math.ceil(all.length / 2));
+
+ const mk = (name, side, idx, total) => {
+ const node = makeNode(name);
+ const y = 15 + ((idx + 1) * 70) / (Math.max(total, 1) + 1);
+ node.style.left = side === 'left' ? '20%' : '80%';
+ node.style.top = `${y}%`;
+ node.addEventListener('click', () => load(name));
+ board.append(node);
+
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
+ line.setAttribute('x1', '50');
+ line.setAttribute('y1', '50');
+ line.setAttribute('x2', side === 'left' ? '20' : '80');
+ line.setAttribute('y2', String(y));
+ line.setAttribute('stroke', 'rgba(125,170,255,0.6)');
+ line.setAttribute('stroke-width', '1.5');
+ return line;
+ };
+
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg.setAttribute('class', 'network-svg');
+ svg.setAttribute('viewBox', '0 0 100 100');
+ svg.setAttribute('preserveAspectRatio', 'none');
+
+ left.forEach((name, i) => svg.append(mk(name, 'left', i, left.length)));
+ right.forEach((name, i) => svg.append(mk(name, 'right', i, right.length)));
+ board.prepend(svg);
+
+ note.textContent = 'Нажмите на узел, чтобы перестроить связи вокруг выбранного пользователя.';
+ } catch (e) {
+ note.textContent = `Ошибка загрузки связей: ${e.message || 'unknown'}`;
+ }
+ };
+
+ const addBtn = document.createElement('button');
+ addBtn.className = 'primary-btn';
+ addBtn.type = 'button';
+ addBtn.textContent = 'Добавить близкого друга';
+ addBtn.addEventListener('click', () => showAddCloseFriendModal({ onAdded: () => load() }));
+
+ load();
+
+ screen.append(renderHeader({ title: 'Связи' }), addBtn, board, note);
return screen;
}
diff --git a/shine-UI/js/pages/profile-view.js b/shine-UI/js/pages/profile-view.js
index 3a6c998..c51a689 100644
--- a/shine-UI/js/pages/profile-view.js
+++ b/shine-UI/js/pages/profile-view.js
@@ -1,5 +1,6 @@
import { renderHeader } from '../components/header.js?v=20260403081123';
import { profile } from '../mock-data.js?v=20260403081123';
+import { state } from '../state.js?v=20260403081123';
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
@@ -40,7 +41,7 @@ export function render({ navigate }) {
${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=