merge: codex/outline-call-request-workflow into main
This commit is contained in:
commit
5d4d451943
75
DOC/Протокол звонков (MVP).md
Normal file
75
DOC/Протокол звонков (MVP).md
Normal file
@ -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с.
|
||||
@ -38,3 +38,7 @@
|
||||
- Моки: `js/mock-data.js`
|
||||
- Компоненты: `js/components/*`
|
||||
- Стили: `styles/*`
|
||||
|
||||
## Язык пояснений
|
||||
- Пояснения к коммитам, PR и merge-запросам всегда писать на русском языке.
|
||||
- Комментарии в коде, встроенные справки и документацию писать по возможности на русском языке.
|
||||
|
||||
@ -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,
|
||||
addAppLogEntry,
|
||||
@ -381,6 +382,15 @@ async function init() {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
authService.onEvent('IncomingCallInvite', async (evt) => {
|
||||
try { await handleIncomingCallInvite(evt); } catch {}
|
||||
});
|
||||
|
||||
authService.onEvent('IncomingCallSignal', async (evt) => {
|
||||
try { await handleIncomingCallSignal(evt); } catch {}
|
||||
});
|
||||
|
||||
await tryAutoLogin();
|
||||
await ensureSessionRuntimeStarted();
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { directMessages } from '../mock-data.js';
|
||||
import { addAppLogEntry, addChatMessage, getChatMessages, authService, state } 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);
|
||||
}
|
||||
},
|
||||
}],
|
||||
})
|
||||
|
||||
@ -1170,6 +1170,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);
|
||||
|
||||
302
shine-UI/js/services/call-service.js
Normal file
302
shine-UI/js/services/call-service.js
Normal file
@ -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);
|
||||
}
|
||||
@ -59,9 +59,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;
|
||||
|
||||
@ -121,6 +125,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()),
|
||||
@ -167,6 +173,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),
|
||||
|
||||
@ -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<ConnectionContext> activeSessions = ActiveConnectionsRegistry.getInstance().getByLogin(to);
|
||||
List<PushTokenEntry> tokens = PushTokensDAO.getInstance().listByLogin(to);
|
||||
|
||||
int wsDelivered = 0;
|
||||
int fcmDelivered = 0;
|
||||
Set<String> 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user