From eaad476bf5dc6fee8c1b906c8bf38727189ce5d95f121a45e1dba303fbedc5e1 Mon Sep 17 00:00:00 2001 From: ai5590 Date: Wed, 15 Apr 2026 09:33:37 +0300 Subject: [PATCH 1/2] Add MVP call signaling API and browser call flow --- DOC/Протокол звонков (MVP).md | 75 +++++ shine-UI/js/app.js | 10 + shine-UI/js/pages/chat-view.js | 22 +- shine-UI/js/services/auth-service.js | 12 + shine-UI/js/services/call-service.js | 302 ++++++++++++++++++ .../ws_protocol/JSON/JsonHandlerRegistry.java | 8 + .../Net_CallInviteBroadcast_Handler.java | 96 ++++++ .../Net_CallSignalToSession_Handler.java | 70 ++++ .../Net_CallInviteBroadcast_Request.java | 18 ++ .../Net_CallInviteBroadcast_Response.java | 18 ++ .../Net_CallSignalToSession_Request.java | 26 ++ .../Net_CallSignalToSession_Response.java | 10 + 12 files changed, 665 insertions(+), 2 deletions(-) create mode 100644 DOC/Протокол звонков (MVP).md create mode 100644 shine-UI/js/services/call-service.js create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallInviteBroadcast_Handler.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallSignalToSession_Handler.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallInviteBroadcast_Request.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallInviteBroadcast_Response.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallSignalToSession_Request.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallSignalToSession_Response.java diff --git a/DOC/Протокол звонков (MVP).md b/DOC/Протокол звонков (MVP).md new file mode 100644 index 0000000..d826d95 --- /dev/null +++ b/DOC/Протокол звонков (MVP).md @@ -0,0 +1,75 @@ +# Протокол звонков (MVP) + +Версия: browser-to-browser, runtime-only signaling. + +## Цели +- Технические сообщения звонка не сохраняются в БД direct_messages. +- Первый INVITE рассылается всем активным сессиям получателя и дублируется web push. +- Последующие сигналы идут только в конкретную sessionId и не дублируются в push. + +## Операции API + +### 1) CallInviteBroadcast +Отправляет общий вызов пользователю. + +Запрос payload: +- `toLogin: string` +- `callId: string` +- `type: 100` (INVITE) + +Поведение сервера: +- Рассылает `IncomingCallInvite` во все активные WS-сессии `toLogin`. +- В payload события передаёт: + - `fromLogin` + - `fromSessionId` (session инициатора) + - `toLogin` + - `callId` + - `type=100` + - `timeMs` +- Отправляет web push уведомление о входящем вызове. + +Ответ payload: +- `callId` +- `deliveredWsSessions` +- `deliveredFcmSessions` + +### 2) CallSignalToSession +Отправляет технический сигнал в конкретную сессию. + +Запрос payload: +- `toLogin: string` +- `targetSessionId: string` +- `callId: string` +- `type: int` +- `data: string` (для SDP/ICE/служебных строк) + +Поведение сервера: +- Ищет только `targetSessionId`. +- Проверяет, что сессия принадлежит `toLogin`. +- Отправляет `IncomingCallSignal` только в эту сессию. +- В БД ничего не сохраняет. +- Push не отправляет. + +Ответ payload: +- `delivered: boolean` + +## Коды type +- `100` INVITE +- `110` RINGING +- `120` ACCEPT +- `130` DECLINE_BUSY +- `140` TIMEOUT +- `150` HANGUP +- `200` OFFER +- `210` ANSWER +- `220` ICE + +## Правила UI/логики +- Если уже есть активный звонок и пришел новый INVITE -> автоответ `DECLINE_BUSY` без UI. +- После ACCEPT `callId` остаётся во всех OFFER/ANSWER/ICE сообщениях до конца звонка. +- При параллельных звонках A<->B допускается детерминированное правило, кто создаёт OFFER. + +## Тайминги MVP +- Ожидание подтверждения/реакции после INVITE: до 5с (у инициатора). +- Ожидание принятия у входящего звонка: 20с. +- Общий лимит ожидания до соединения: 22с. diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 6063c91..9ed0cfe 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -2,6 +2,7 @@ import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js'; import { renderToolbar } from './components/toolbar.js'; import { captureClientError, setClientErrorTransport } from './services/client-error-reporter.js'; import { initPwaPush } from './services/pwa-push-service.js'; +import { handleIncomingCallInvite, handleIncomingCallSignal } from './services/call-service.js'; import { authService, authorizeSession, @@ -223,6 +224,15 @@ async function init() { try { await authService.ackIncomingMessage(eventId, messageId); } catch {} } }); + + authService.onEvent('IncomingCallInvite', async (evt) => { + try { await handleIncomingCallInvite(evt); } catch {} + }); + + authService.onEvent('IncomingCallSignal', async (evt) => { + try { await handleIncomingCallSignal(evt); } catch {} + }); + await tryAutoLogin(); if (state.session.isAuthorized) { await initPwaPush({ authService }); diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index fa8b0b0..317b4a9 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -1,6 +1,7 @@ import { renderHeader } from '../components/header.js'; import { directMessages } from '../mock-data.js'; import { addChatMessage, getChatMessages, authService } from '../state.js'; +import { startOutgoingCall, hangupActiveCall } from '../services/call-service.js'; export const pageMeta = { id: 'chat-view', title: 'Чат' }; @@ -33,10 +34,27 @@ export function render({ navigate, route }) { leftAction: { label: '←', onClick: () => navigate('messages-list') }, rightActions: [{ label: 'Позвонить', - onClick: () => { + onClick: async () => { const confirmed = window.confirm('Позвонить этому пользователю?'); if (!confirmed) return; - window.alert('Функция пока не реализована'); + try { + await startOutgoingCall(chatId); + renderLog(log, chatId); + } catch (e) { + addChatMessage(chatId, `[call] Ошибка звонка: ${e.message || 'unknown'}`); + renderLog(log, chatId); + } + }, + }, { + label: 'Сброс', + onClick: async () => { + try { + await hangupActiveCall(); + renderLog(log, chatId); + } catch (e) { + addChatMessage(chatId, `[call] Ошибка сброса: ${e.message || 'unknown'}`); + renderLog(log, chatId); + } }, }], }) diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 8f3d344..411811e 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -372,6 +372,18 @@ export class AuthService { return response.payload || {}; } + + async callInviteBroadcast({ toLogin, callId, type = 100 }) { + const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type }); + if (response.status !== 200) throw opError('CallInviteBroadcast', response); + return response.payload || {}; + } + + async callSignalToSession({ toLogin, targetSessionId, callId, type, data = '' }) { + const response = await this.ws.request('CallSignalToSession', { toLogin, targetSessionId, callId, type, data }); + if (response.status !== 200) throw opError('CallSignalToSession', response); + return response.payload || {}; + } async listContacts() { const response = await this.ws.request('ListContacts', {}); if (response.status !== 200) throw opError('ListContacts', response); diff --git a/shine-UI/js/services/call-service.js b/shine-UI/js/services/call-service.js new file mode 100644 index 0000000..4182596 --- /dev/null +++ b/shine-UI/js/services/call-service.js @@ -0,0 +1,302 @@ +import { addChatMessage, state, authService } from '../state.js'; + +const TYPES = { + INVITE: 100, + RINGING: 110, + ACCEPT: 120, + DECLINE_BUSY: 130, + TIMEOUT: 140, + HANGUP: 150, + OFFER: 200, + ANSWER: 210, + ICE: 220, +}; + +const calls = new Map(); +let activeCallId = ''; + +function nowMs() { + return Date.now(); +} + +function makeCallId() { + return `${Date.now()}${Math.floor(Math.random() * 1_000_000_000)}`; +} + +function getCall(callId) { + return calls.get(callId) || null; +} + +function setStatus(call, text) { + call.status = text; + addChatMessage(call.peerLogin, `[call] ${text}`); +} + +function cleanupTimers(call) { + if (call.timers?.ack5s) clearTimeout(call.timers.ack5s); + if (call.timers?.total22s) clearTimeout(call.timers.total22s); + if (call.timers?.incoming20s) clearTimeout(call.timers.incoming20s); +} + +async function closeMedia(call) { + try { call.pc?.close(); } catch {} + try { call.localStream?.getTracks()?.forEach((t) => t.stop()); } catch {} + call.pc = null; + call.localStream = null; +} + +async function finishCall(call, reason, notifyRemote = false) { + if (!call) return; + cleanupTimers(call); + if (notifyRemote && call.remoteSessionId) { + try { + await authService.callSignalToSession({ + toLogin: call.peerLogin, + targetSessionId: call.remoteSessionId, + callId: call.callId, + type: TYPES.HANGUP, + data: '', + }); + } catch {} + } + await closeMedia(call); + setStatus(call, `завершен: ${reason}`); + calls.delete(call.callId); + if (activeCallId === call.callId) activeCallId = ''; +} + +async function ensurePeerConnection(call) { + if (call.pc) return call.pc; + + const pc = new RTCPeerConnection({ + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], + }); + + pc.onicecandidate = async (event) => { + if (!event.candidate || !call.remoteSessionId) return; + try { + await authService.callSignalToSession({ + toLogin: call.peerLogin, + targetSessionId: call.remoteSessionId, + callId: call.callId, + type: TYPES.ICE, + data: JSON.stringify(event.candidate), + }); + } catch {} + }; + + pc.onconnectionstatechange = () => { + if (pc.connectionState === 'connected') { + setStatus(call, 'соединение установлено'); + } + if (pc.connectionState === 'failed' || pc.connectionState === 'closed' || pc.connectionState === 'disconnected') { + finishCall(call, `state=${pc.connectionState}`, false); + } + }; + + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); + call.localStream = stream; + stream.getTracks().forEach((track) => pc.addTrack(track, stream)); + } catch (e) { + setStatus(call, `нет доступа к микрофону: ${e?.message || 'unknown'}`); + } + + pc.ontrack = (evt) => { + const audio = new Audio(); + audio.autoplay = true; + audio.srcObject = evt.streams[0]; + call.remoteAudio = audio; + }; + + call.pc = pc; + return pc; +} + +async function sendSignal(call, type, data = '') { + if (!call.remoteSessionId) return; + await authService.callSignalToSession({ + toLogin: call.peerLogin, + targetSessionId: call.remoteSessionId, + callId: call.callId, + type, + data, + }); +} + +async function onAccept(call) { + cleanupTimers(call); + const pc = await ensurePeerConnection(call); + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + await sendSignal(call, TYPES.OFFER, JSON.stringify(offer)); + setStatus(call, 'отправлен offer'); +} + +export async function startOutgoingCall(peerLogin) { + const cleanPeer = String(peerLogin || '').trim(); + if (!cleanPeer) return; + + if (activeCallId) { + addChatMessage(cleanPeer, '[call] уже есть активный звонок'); + return; + } + + const callId = makeCallId(); + const call = { + callId, + peerLogin: cleanPeer, + direction: 'out', + state: 'dialing', + remoteSessionId: '', + timers: {}, + startedAtMs: nowMs(), + pc: null, + localStream: null, + }; + calls.set(callId, call); + activeCallId = callId; + setStatus(call, 'набираем...'); + + call.timers.ack5s = setTimeout(() => { + if (call.state === 'dialing') { + finishCall(call, 'нет ответа 5с', false); + } + }, 5000); + + call.timers.total22s = setTimeout(() => { + finishCall(call, 'таймаут 22с', false); + }, 22000); + + await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE }); +} + +export async function handleIncomingCallInvite(evt) { + const payload = evt?.payload || {}; + const callId = String(payload.callId || '').trim(); + const fromLogin = String(payload.fromLogin || '').trim(); + const fromSessionId = String(payload.fromSessionId || '').trim(); + if (!callId || !fromLogin || !fromSessionId) return; + + if (activeCallId && activeCallId !== callId) { + try { + await authService.callSignalToSession({ + toLogin: fromLogin, + targetSessionId: fromSessionId, + callId, + type: TYPES.DECLINE_BUSY, + data: 'busy', + }); + } catch {} + return; + } + + let call = getCall(callId); + if (!call) { + call = { + callId, + peerLogin: fromLogin, + direction: 'in', + state: 'incoming', + remoteSessionId: fromSessionId, + timers: {}, + startedAtMs: nowMs(), + pc: null, + localStream: null, + }; + calls.set(callId, call); + } + + activeCallId = callId; + setStatus(call, `входящий звонок от ${fromLogin}`); + + try { + await sendSignal(call, TYPES.RINGING, 'ringing'); + } catch {} + + call.timers.incoming20s = setTimeout(async () => { + await sendSignal(call, TYPES.TIMEOUT, 'timeout_20s'); + await finishCall(call, 'не ответили 20с', false); + }, 20000); + + const accepted = window.confirm(`Вам звонит ${fromLogin}. Принять звонок?`); + if (!accepted) { + await sendSignal(call, TYPES.DECLINE_BUSY, 'decline'); + await finishCall(call, 'отклонен', false); + return; + } + + call.state = 'accepted'; + await sendSignal(call, TYPES.ACCEPT, 'accept'); + setStatus(call, 'принят, ждём offer'); +} + +export async function handleIncomingCallSignal(evt) { + const payload = evt?.payload || {}; + const callId = String(payload.callId || '').trim(); + const fromLogin = String(payload.fromLogin || '').trim(); + const fromSessionId = String(payload.fromSessionId || '').trim(); + const type = Number(payload.type); + const data = String(payload.data || ''); + if (!callId || !fromLogin || !Number.isFinite(type)) return; + + const call = getCall(callId); + if (!call) return; + if (!call.remoteSessionId) call.remoteSessionId = fromSessionId; + + if (type === TYPES.RINGING) { + call.state = 'ringing'; + setStatus(call, 'идут гудки'); + return; + } + + if (type === TYPES.ACCEPT) { + call.state = 'accepted'; + setStatus(call, 'звонок принят'); + await onAccept(call); + return; + } + + if (type === TYPES.DECLINE_BUSY) { + await finishCall(call, 'занят/отклонено', false); + return; + } + + if (type === TYPES.TIMEOUT) { + await finishCall(call, 'таймаут на стороне собеседника', false); + return; + } + + if (type === TYPES.HANGUP) { + await finishCall(call, 'собеседник завершил звонок', false); + return; + } + + if (type === TYPES.OFFER) { + const pc = await ensurePeerConnection(call); + await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data))); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + await sendSignal(call, TYPES.ANSWER, JSON.stringify(answer)); + setStatus(call, 'получен offer, отправлен answer'); + return; + } + + if (type === TYPES.ANSWER) { + const pc = await ensurePeerConnection(call); + await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data))); + setStatus(call, 'получен answer'); + return; + } + + if (type === TYPES.ICE) { + const pc = await ensurePeerConnection(call); + await pc.addIceCandidate(new RTCIceCandidate(JSON.parse(data))); + } +} + +export async function hangupActiveCall() { + if (!activeCallId) return; + const call = getCall(activeCallId); + await finishCall(call, 'сброшен пользователем', true); +} 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 5862635..7064308 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 @@ -58,9 +58,13 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserCo 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_CallInviteBroadcast_Handler; +import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_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_CallInviteBroadcast_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Request; @@ -116,6 +120,8 @@ public final class JsonHandlerRegistry { Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()), Map.entry("SendDirectMessage", new Net_SendDirectMessage_Handler()), Map.entry("AckIncomingMessage", new Net_AckIncomingMessage_Handler()), + Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()), + Map.entry("CallSignalToSession", new Net_CallSignalToSession_Handler()), // --- system --- Map.entry("Ping", new Net_Ping_Handler()), @@ -162,6 +168,8 @@ public final class JsonHandlerRegistry { Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class), Map.entry("SendDirectMessage", Net_SendDirectMessage_Request.class), Map.entry("AckIncomingMessage", Net_AckIncomingMessage_Request.class), + Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class), + Map.entry("CallSignalToSession", Net_CallSignalToSession_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/messages/Net_CallInviteBroadcast_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallInviteBroadcast_Handler.java new file mode 100644 index 0000000..5717315 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallInviteBroadcast_Handler.java @@ -0,0 +1,96 @@ +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_CallInviteBroadcast_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_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.PushTokensDAO; +import shine.db.dao.SolanaUsersDAO; +import shine.db.entities.PushTokenEntry; +import shine.db.entities.SolanaUserEntry; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final int TYPE_INVITE = 100; + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { + Net_CallInviteBroadcast_Request req = (Net_CallInviteBroadcast_Request) baseRequest; + if (ctx == null || !ctx.isAuthenticatedUser()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация"); + } + + String toRequest = req.getToLogin() == null ? "" : req.getToLogin().trim(); + String callId = req.getCallId() == null ? "" : req.getCallId().trim(); + int type = req.getType() == null ? TYPE_INVITE : req.getType(); + if (toRequest.isBlank() || callId.isBlank() || type != TYPE_INVITE) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/callId/type=100 обязательны"); + } + + SolanaUserEntry targetUser = SolanaUsersDAO.getInstance().getByLogin(toRequest); + if (targetUser == null) { + return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден"); + } + + String from = ctx.getLogin(); + String to = targetUser.getLogin(); + long timeMs = System.currentTimeMillis(); + + 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"); + ObjectNode payload = MAPPER.createObjectNode(); + payload.put("eventId", eventId); + payload.put("fromLogin", from); + payload.put("fromSessionId", ctx.getSessionId()); + payload.put("toLogin", to); + payload.put("callId", callId); + payload.put("type", TYPE_INVITE); + payload.put("timeMs", timeMs); + + boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingCallInvite", eventId, payload); + if (sent) wsDelivered++; + } + + for (PushTokenEntry token : tokens) { + boolean pushed = FcmPushSender.sendNotification( + token.getToken(), + "Входящий звонок", + from + " пытается дозвониться", + callId + ); + if (pushed) fcmDelivered++; + } + + Net_CallInviteBroadcast_Response resp = new Net_CallInviteBroadcast_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setCallId(callId); + resp.setDeliveredWsSessions(wsDelivered); + resp.setDeliveredFcmSessions(fcmDelivered); + return resp; + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallSignalToSession_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallSignalToSession_Handler.java new file mode 100644 index 0000000..ce31026 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallSignalToSession_Handler.java @@ -0,0 +1,70 @@ +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_CallSignalToSession_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Response; +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.SolanaUsersDAO; +import shine.db.entities.SolanaUserEntry; + +public class Net_CallSignalToSession_Handler implements JsonMessageHandler { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { + Net_CallSignalToSession_Request req = (Net_CallSignalToSession_Request) baseRequest; + if (ctx == null || !ctx.isAuthenticatedUser()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация"); + } + + String toRequest = req.getToLogin() == null ? "" : req.getToLogin().trim(); + String targetSessionId = req.getTargetSessionId() == null ? "" : req.getTargetSessionId().trim(); + String callId = req.getCallId() == null ? "" : req.getCallId().trim(); + Integer type = req.getType(); + String data = req.getData() == null ? "" : req.getData(); + + if (toRequest.isBlank() || targetSessionId.isBlank() || callId.isBlank() || type == null) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/targetSessionId/callId/type обязательны"); + } + + SolanaUserEntry targetUser = SolanaUsersDAO.getInstance().getByLogin(toRequest); + if (targetUser == null) { + return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден"); + } + String to = targetUser.getLogin(); + + ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(targetSessionId); + if (targetCtx == null || !to.equalsIgnoreCase(targetCtx.getLogin())) { + return NetExceptionResponseFactory.error(req, 404, "SESSION_NOT_FOUND", "Целевая сессия не найдена"); + } + + String eventId = NetIdGenerator.eventId("evt"); + ObjectNode payload = MAPPER.createObjectNode(); + payload.put("eventId", eventId); + payload.put("fromLogin", ctx.getLogin()); + payload.put("fromSessionId", ctx.getSessionId()); + payload.put("toLogin", to); + payload.put("callId", callId); + payload.put("type", type); + payload.put("data", data); + payload.put("timeMs", System.currentTimeMillis()); + + boolean delivered = WsEventSender.sendEvent(targetCtx, "IncomingCallSignal", eventId, payload); + + Net_CallSignalToSession_Response resp = new Net_CallSignalToSession_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(delivered ? WireCodes.Status.OK : 404); + resp.setDelivered(delivered); + return resp; + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallInviteBroadcast_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallInviteBroadcast_Request.java new file mode 100644 index 0000000..139c5fa --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallInviteBroadcast_Request.java @@ -0,0 +1,18 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_CallInviteBroadcast_Request extends Net_Request { + private String toLogin; + private String callId; + private Integer type; + + public String getToLogin() { return toLogin; } + public void setToLogin(String toLogin) { this.toLogin = toLogin; } + + public String getCallId() { return callId; } + public void setCallId(String callId) { this.callId = callId; } + + public Integer getType() { return type; } + public void setType(Integer type) { this.type = type; } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallInviteBroadcast_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallInviteBroadcast_Response.java new file mode 100644 index 0000000..dcfafa9 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallInviteBroadcast_Response.java @@ -0,0 +1,18 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_CallInviteBroadcast_Response extends Net_Response { + private String callId; + private int deliveredWsSessions; + private int deliveredFcmSessions; + + public String getCallId() { return callId; } + public void setCallId(String callId) { this.callId = callId; } + + 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_CallSignalToSession_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallSignalToSession_Request.java new file mode 100644 index 0000000..da750c0 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallSignalToSession_Request.java @@ -0,0 +1,26 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_CallSignalToSession_Request extends Net_Request { + private String toLogin; + private String targetSessionId; + private String callId; + private Integer type; + private String data; + + public String getToLogin() { return toLogin; } + public void setToLogin(String toLogin) { this.toLogin = toLogin; } + + public String getTargetSessionId() { return targetSessionId; } + public void setTargetSessionId(String targetSessionId) { this.targetSessionId = targetSessionId; } + + public String getCallId() { return callId; } + public void setCallId(String callId) { this.callId = callId; } + + public Integer getType() { return type; } + public void setType(Integer type) { this.type = type; } + + public String getData() { return data; } + public void setData(String data) { this.data = data; } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallSignalToSession_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallSignalToSession_Response.java new file mode 100644 index 0000000..4a6a6f2 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallSignalToSession_Response.java @@ -0,0 +1,10 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_CallSignalToSession_Response extends Net_Response { + private boolean delivered; + + public boolean isDelivered() { return delivered; } + public void setDelivered(boolean delivered) { this.delivered = delivered; } +} From 828820b6e4e9c0f65e2fcc21b1e654b313807ffde264203083bebf0c9fe29f78 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 15 Apr 2026 22:38:43 +0300 Subject: [PATCH 2/2] =?UTF-8?q?14-04-2026=20=D0=92=D0=B5=D0=B1=20=D0=BF?= =?UTF-8?q?=D1=83=D1=88=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=B5=D1=82?= =?UTF-8?q?.=20=D0=94=D0=B0=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BF=D0=BE=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B1=D1=83=D1=8E=20=D0=B7=D0=B2=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shine-UI/AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/shine-UI/AGENTS.md b/shine-UI/AGENTS.md index 3848596..fb37071 100644 --- a/shine-UI/AGENTS.md +++ b/shine-UI/AGENTS.md @@ -38,3 +38,7 @@ - Моки: `js/mock-data.js` - Компоненты: `js/components/*` - Стили: `styles/*` + +## Язык пояснений +- Пояснения к коммитам, PR и merge-запросам всегда писать на русском языке. +- Комментарии в коде, встроенные справки и документацию писать по возможности на русском языке.