CallDeliveryReport: универсальный формат type/value и расширенные отчёты по звонкам

This commit is contained in:
AidarKC 2026-05-01 15:09:20 +03:00
parent 3061bf3d1e
commit bff403ea04
7 changed files with 281 additions and 24 deletions

View File

@ -1,2 +1,2 @@
client.version=1.2.21 client.version=1.2.22
server.version=1.2.21 server.version=1.2.22

View File

@ -1171,8 +1171,6 @@ export class AuthService {
return this.runWriteLocked(key, async () => { return this.runWriteLocked(key, async () => {
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd); const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
const userLastGlobalNumber = Number(user?.serverLastGlobalNumber);
const userLastGlobalHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64);
const ownerBlockchainName = owner; const ownerBlockchainName = owner;
const lineCode = root; const lineCode = root;
@ -1182,17 +1180,14 @@ export class AuthService {
if (ownerBlockchainName !== blockchainName) { if (ownerBlockchainName !== blockchainName) {
throw new Error('Posting is allowed only to your own channels'); throw new Error('Posting is allowed only to your own channels');
} }
// Канал 0 оставляем как технический root-поток.
// Контент-публикации в него временно отключены (пишем только в именованные каналы).
if (lineCode === 0) {
throw new Error('Публикации в канал 0 временно отключены. Создайте отдельный канал.');
}
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64); let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
if (lineCode === 0) { if (rootHashHex === ZERO64) {
rootHashHex = (
Number.isFinite(userLastGlobalNumber) &&
userLastGlobalNumber === 0 &&
userLastGlobalHash !== ZERO64
)
? userLastGlobalHash
: await this.resolveHeaderHashForBlockchain(blockchainName);
} else if (rootHashHex === ZERO64) {
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName); const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode); const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
if (!rootChannel) throw new Error('Channel root not found'); if (!rootChannel) throw new Error('Channel root not found');
@ -1431,6 +1426,12 @@ export class AuthService {
return response.payload || {}; return response.payload || {};
} }
async sendCallDeliveryReport(payload = {}) {
const response = await this.ws.request('CallDeliveryReport', payload);
if (response.status !== 200) throw opError('CallDeliveryReport', response);
return response.payload || {};
}
async listContacts() { async listContacts() {
const response = await this.ws.request('ListContacts', {}); const response = await this.ws.request('ListContacts', {});
if (response.status !== 200) throw opError('ListContacts', response); if (response.status !== 200) throw opError('ListContacts', response);

View File

@ -93,12 +93,25 @@ async function resolveIceServers(call) {
const turnUrls = uniqueUrls(parseIceUrls(payload?.turnUrls)); const turnUrls = uniqueUrls(parseIceUrls(payload?.turnUrls));
const turnUsername = String(payload?.turnUsername || '').trim(); const turnUsername = String(payload?.turnUsername || '').trim();
const turnPassword = String(payload?.turnPassword || '').trim(); const turnPassword = String(payload?.turnPassword || '').trim();
const turnServers = Array.isArray(payload?.turnServers) ? payload.turnServers : [];
const iceServers = []; const iceServers = [];
if (stunUrls.length > 0) { if (stunUrls.length > 0) {
iceServers.push({ urls: stunUrls.length === 1 ? stunUrls[0] : stunUrls }); iceServers.push({ urls: stunUrls.length === 1 ? stunUrls[0] : stunUrls });
} }
if (turnUrls.length > 0 && turnUsername && turnPassword) { if (turnServers.length > 0) {
turnServers.forEach((item) => {
const urls = uniqueUrls(parseIceUrls(item?.urls));
const username = String(item?.username || '').trim();
const password = String(item?.password || '').trim();
if (urls.length === 0 || !username || !password) return;
iceServers.push({
urls: urls.length === 1 ? urls[0] : urls,
username,
credential: password,
});
});
} else if (turnUrls.length > 0 && turnUsername && turnPassword) {
iceServers.push({ iceServers.push({
urls: turnUrls.length === 1 ? turnUrls[0] : turnUrls, urls: turnUrls.length === 1 ? turnUrls[0] : turnUrls,
username: turnUsername, username: turnUsername,
@ -110,7 +123,7 @@ async function resolveIceServers(call) {
await emitDebug(call, 'warn', 'call_ice_empty_from_server', 'using_default_stun'); await emitDebug(call, 'warn', 'call_ice_empty_from_server', 'using_default_stun');
return cloneDefaultIceServers(); return cloneDefaultIceServers();
} }
await emitDebug(call, 'info', 'call_ice_loaded_from_server', `stun=${stunUrls.length}; turn=${turnUrls.length}`); await emitDebug(call, 'info', 'call_ice_loaded_from_server', `stun=${stunUrls.length}; turnEntries=${Math.max(0, iceServers.length - (stunUrls.length > 0 ? 1 : 0))}`);
return iceServers; return iceServers;
} catch (error) { } catch (error) {
await emitDebug(call, 'warn', 'call_ice_load_failed', toErrorText(error)); await emitDebug(call, 'warn', 'call_ice_load_failed', toErrorText(error));
@ -236,6 +249,59 @@ function setActiveStatus(call) {
setStatus(call, buildActiveStatusText(call), 'active'); setStatus(call, buildActiveStatusText(call), 'active');
} }
function toIsoTs(ts) {
const n = Number(ts || 0);
if (!Number.isFinite(n) || n <= 0) return '';
try { return new Date(n).toISOString(); } catch { return ''; }
}
function buildCallFactsJson(call, extra = {}) {
const pc = call?.pc || null;
const facts = {
callId: call?.callId || '',
peerLogin: call?.peerLogin || '',
remoteSessionId: call?.remoteSessionId || '',
direction: call?.direction || '',
phase: call?.phase || '',
statusText: call?.statusText || '',
startedAtMs: Number(call?.startedAtMs || 0),
startedAtIso: toIsoTs(call?.startedAtMs),
connectedAtMs: Number(call?.connectedAtMs || 0),
connectedAtIso: toIsoTs(call?.connectedAtMs),
routeLabel: call?.connectionRouteLabel || '',
routeDetails: call?.connectionRouteDetails || '',
pcConnectionState: pc?.connectionState || '',
pcIceConnectionState: pc?.iceConnectionState || '',
pcSignalingState: pc?.signalingState || '',
hasLocalStream: Boolean(call?.localStream),
localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0,
...extra,
};
try {
return JSON.stringify(facts);
} catch {
return JSON.stringify({ callId: call?.callId || '', serializeError: true });
}
}
async function sendCallDeliveryReport(call, eventType, eventCode, reason = '', extraFacts = {}) {
if (!call || !authService || typeof authService.sendCallDeliveryReport !== 'function') return;
try {
const valueJson = buildCallFactsJson(call, {
eventType: String(eventType || '').trim(),
eventCode: String(eventCode || '').trim(),
reason: String(reason || '').trim(),
reportedAtMs: nowMs(),
reportedAtIso: toIsoTs(nowMs()),
...extraFacts,
});
await authService.sendCallDeliveryReport({
type: String(eventType || '').trim(),
value: valueJson,
});
} catch {}
}
function cleanupTimers(call) { function cleanupTimers(call) {
if (call.timers?.ack10s) clearTimeout(call.timers.ack10s); if (call.timers?.ack10s) clearTimeout(call.timers.ack10s);
if (call.timers?.total35s) clearTimeout(call.timers.total35s); if (call.timers?.total35s) clearTimeout(call.timers.total35s);
@ -251,6 +317,7 @@ async function closeMedia(call) {
call.localStream = null; call.localStream = null;
call.audioSenders = []; call.audioSenders = [];
call.connectionRouteLabel = ''; call.connectionRouteLabel = '';
call.connectionRouteDetails = '';
} }
function stopReconnectFlow(call) { function stopReconnectFlow(call) {
@ -269,7 +336,9 @@ function stopReconnectFlow(call) {
async function detectConnectionRoute(call) { async function detectConnectionRoute(call) {
const pc = call?.pc; const pc = call?.pc;
if (!pc || typeof pc.getStats !== 'function') return { label: '', details: '' }; if (!pc || typeof pc.getStats !== 'function') {
return { label: '', details: '', localIp: '', remoteIp: '', turnCandidateAddress: '' };
}
try { try {
const stats = await pc.getStats(); const stats = await pc.getStats();
let selectedPair = null; let selectedPair = null;
@ -298,7 +367,7 @@ async function detectConnectionRoute(call) {
}); });
} }
if (!selectedPair) return { label: '', details: '' }; if (!selectedPair) return { label: '', details: '', localIp: '', remoteIp: '', turnCandidateAddress: '' };
const local = selectedPair.localCandidateId && typeof stats.get === 'function' const local = selectedPair.localCandidateId && typeof stats.get === 'function'
? stats.get(selectedPair.localCandidateId) ? stats.get(selectedPair.localCandidateId)
@ -309,17 +378,39 @@ async function detectConnectionRoute(call) {
const localType = String(local?.candidateType || '').trim().toLowerCase(); const localType = String(local?.candidateType || '').trim().toLowerCase();
const remoteType = String(remote?.candidateType || '').trim().toLowerCase(); const remoteType = String(remote?.candidateType || '').trim().toLowerCase();
const details = `local=${localType || '-'}; remote=${remoteType || '-'}`; const localIp = String(local?.ip || local?.address || '');
const remoteIp = String(remote?.ip || remote?.address || '');
const localPort = String(local?.port || '');
const remotePort = String(remote?.port || '');
const relayProto = String(local?.relayProtocol || remote?.relayProtocol || '');
const turnCandidateAddress = localType === 'relay'
? `${localIp}${localPort ? `:${localPort}` : ''}`
: (remoteType === 'relay' ? `${remoteIp}${remotePort ? `:${remotePort}` : ''}` : '');
const details = `local=${localType || '-'}(${localIp || '-'}${localPort ? `:${localPort}` : ''}); remote=${remoteType || '-'}(${remoteIp || '-'}${remotePort ? `:${remotePort}` : ''})`;
if (localType === 'relay' || remoteType === 'relay') { if (localType === 'relay' || remoteType === 'relay') {
return { label: 'через TURN', details }; const label = turnCandidateAddress
? `через TURN (${turnCandidateAddress})`
: (relayProto ? `через TURN (${relayProto})` : 'через TURN');
return { label, details, localIp, remoteIp, turnCandidateAddress };
} }
if (localType || remoteType) { if (localType || remoteType) {
return { label: 'прямое', details }; const sameLan = localIp && remoteIp && (
(localIp.startsWith('10.') && remoteIp.startsWith('10.'))
|| (localIp.startsWith('192.168.') && remoteIp.startsWith('192.168.'))
|| (localIp.startsWith('172.16.') && remoteIp.startsWith('172.16.'))
);
return {
label: sameLan ? 'напрямую в локальной сети' : 'напрямую через интернет',
details,
localIp,
remoteIp,
turnCandidateAddress: '',
};
} }
return { label: '', details }; return { label: '', details, localIp, remoteIp, turnCandidateAddress: '' };
} catch { } catch {
return { label: '', details: '' }; return { label: '', details: '', localIp: '', remoteIp: '', turnCandidateAddress: '' };
} }
} }
@ -336,6 +427,7 @@ function startTransportProbe(call) {
const route = await detectConnectionRoute(call); const route = await detectConnectionRoute(call);
if (!route.label || route.label === call.connectionRouteLabel) return; if (!route.label || route.label === call.connectionRouteLabel) return;
call.connectionRouteLabel = route.label; call.connectionRouteLabel = route.label;
call.connectionRouteDetails = route.details || '';
setActiveStatus(call); setActiveStatus(call);
await emitDebug(call, 'info', 'peer_connection_route', route.details || route.label); await emitDebug(call, 'info', 'peer_connection_route', route.details || route.label);
}; };
@ -475,6 +567,18 @@ async function finalizeCall(call, {
await emitDebug(call, 'info', 'call_finalize', `${localReasonCode}:${debugReason}`); await emitDebug(call, 'info', 'call_finalize', `${localReasonCode}:${debugReason}`);
} }
const reasonText = debugReason || localReasonCode;
if (String(localReasonCode || '') !== 'completed') {
if (call.direction === 'out') {
await sendCallDeliveryReport(call, 'outgoing_failed', `outgoing_${localReasonCode}`, reasonText);
} else if (call.direction === 'in') {
await sendCallDeliveryReport(call, 'incoming_failed', `incoming_${localReasonCode}`, reasonText);
}
if (String(localReasonCode || '') === 'error') {
await sendCallDeliveryReport(call, 'unknown_error', 'call_unknown_error', reasonText);
}
}
pushCallSummary(call, localReasonCode); pushCallSummary(call, localReasonCode);
call.phase = 'ended'; call.phase = 'ended';
@ -565,6 +669,30 @@ async function ensurePeerConnection(call) {
setActiveStatus(call); setActiveStatus(call);
startTransportProbe(call); startTransportProbe(call);
void emitDebug(call, 'info', 'peer_connection_connected', `callId=${call.callId}`); void emitDebug(call, 'info', 'peer_connection_connected', `callId=${call.callId}`);
if (call.direction === 'out' && !call.connectionSuccessReported) {
call.connectionSuccessReported = true;
void (async () => {
const route = await detectConnectionRoute(call);
if (route?.label) {
call.connectionRouteLabel = route.label;
}
call.connectionRouteDetails = route?.details || '';
await sendCallDeliveryReport(
call,
'call_connected',
'call_connected_success',
`connected:${route?.label || 'unknown_route'}`,
{
reportBy: 'initiator',
routeLabel: route?.label || '',
routeDetails: route?.details || '',
localIp: route?.localIp || '',
remoteIp: route?.remoteIp || '',
turnCandidateAddress: route?.turnCandidateAddress || '',
},
);
})();
}
return; return;
} }
if (state === 'failed') { if (state === 'failed') {
@ -599,6 +727,7 @@ async function ensurePeerConnection(call) {
call.localStream = stream; call.localStream = stream;
call.audioSenders = []; call.audioSenders = [];
call.connectionRouteLabel = ''; call.connectionRouteLabel = '';
call.connectionRouteDetails = '';
stream.getTracks().forEach((track) => { stream.getTracks().forEach((track) => {
track.enabled = track.kind === 'audio' ? !call.muted : true; track.enabled = track.kind === 'audio' ? !call.muted : true;
const sender = pc.addTrack(track, stream); const sender = pc.addTrack(track, stream);

View File

@ -84,7 +84,9 @@ import server.logic.ws_protocol.JSON.handlers.system.Net_GetServerInfo_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_GetCallIceConfig_Handler; import server.logic.ws_protocol.JSON.handlers.system.Net_GetCallIceConfig_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_ClientErrorLog_Handler; import server.logic.ws_protocol.JSON.handlers.system.Net_ClientErrorLog_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_ClientDebugLog_Handler; import server.logic.ws_protocol.JSON.handlers.system.Net_ClientDebugLog_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_CallDeliveryReport_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_Ping_Handler; import server.logic.ws_protocol.JSON.handlers.system.Net_Ping_Handler;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_CallDeliveryReport_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientErrorLog_Request; import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientErrorLog_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientDebugLog_Request; import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientDebugLog_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetCallIceConfig_Request; import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetCallIceConfig_Request;
@ -152,7 +154,8 @@ public final class JsonHandlerRegistry {
Map.entry("GetServerInfo", new Net_GetServerInfo_Handler()), Map.entry("GetServerInfo", new Net_GetServerInfo_Handler()),
Map.entry("GetCallIceConfig", new Net_GetCallIceConfig_Handler()), Map.entry("GetCallIceConfig", new Net_GetCallIceConfig_Handler()),
Map.entry("ClientErrorLog", new Net_ClientErrorLog_Handler()), Map.entry("ClientErrorLog", new Net_ClientErrorLog_Handler()),
Map.entry("ClientDebugLog", new Net_ClientDebugLog_Handler()) Map.entry("ClientDebugLog", new Net_ClientDebugLog_Handler()),
Map.entry("CallDeliveryReport", new Net_CallDeliveryReport_Handler())
// --- subscriptions --- // --- subscriptions ---
// Map.entry("ListSubscribedChannels", new Net_GetSubscribedChannels_Handler()) // Map.entry("ListSubscribedChannels", new Net_GetSubscribedChannels_Handler())
@ -207,7 +210,8 @@ public final class JsonHandlerRegistry {
Map.entry("GetServerInfo", Net_GetServerInfo_Request.class), Map.entry("GetServerInfo", Net_GetServerInfo_Request.class),
Map.entry("GetCallIceConfig", Net_GetCallIceConfig_Request.class), Map.entry("GetCallIceConfig", Net_GetCallIceConfig_Request.class),
Map.entry("ClientErrorLog", Net_ClientErrorLog_Request.class), Map.entry("ClientErrorLog", Net_ClientErrorLog_Request.class),
Map.entry("ClientDebugLog", Net_ClientDebugLog_Request.class) Map.entry("ClientDebugLog", Net_ClientDebugLog_Request.class),
Map.entry("CallDeliveryReport", Net_CallDeliveryReport_Request.class)
); );
private JsonHandlerRegistry() { } private JsonHandlerRegistry() { }

View File

@ -0,0 +1,96 @@
package server.logic.ws_protocol.JSON.handlers.system;
import org.eclipse.jetty.websocket.api.Session;
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.system.entyties.Net_CallDeliveryReport_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_CallDeliveryReport_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import java.io.IOException;
import java.net.SocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.util.Locale;
import java.util.concurrent.locks.ReentrantLock;
public class Net_CallDeliveryReport_Handler implements JsonMessageHandler {
private static final ReentrantLock FILE_LOCK = new ReentrantLock(true);
private static final Path LOG_FILE = Path.of("logs", "call-delivery-events.log");
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_CallDeliveryReport_Request req = (Net_CallDeliveryReport_Request) baseRequest;
if (safe(req.getType()).isEmpty()) {
return NetExceptionResponseFactory.error(
req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Поле type обязательно"
);
}
long serverTs = System.currentTimeMillis();
String line = String.format(
Locale.ROOT,
"%s | type=%s | login=%s | remote=%s | value=%s%n",
Instant.ofEpochMilli(serverTs),
clip(req.getType(), 80),
clip(ctx != null ? ctx.getLogin() : "", 80),
clip(remoteAddress(ctx), 200),
clip(req.getValue(), 8000)
);
appendLine(line);
Net_CallDeliveryReport_Response resp = new Net_CallDeliveryReport_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setAccepted(true);
resp.setServerTs(serverTs);
return resp;
}
private static void appendLine(String line) {
FILE_LOCK.lock();
try {
Path parent = LOG_FILE.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Files.writeString(
LOG_FILE,
line,
StandardCharsets.UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.APPEND
);
} catch (IOException e) {
throw new IllegalStateException("Не удалось записать call-delivery-report", e);
} finally {
FILE_LOCK.unlock();
}
}
private static String remoteAddress(ConnectionContext ctx) {
if (ctx == null) return "";
Session ws = ctx.getWsSession();
if (ws == null) return "";
SocketAddress remote = ws.getRemoteAddress();
return remote != null ? remote.toString() : "";
}
private static String safe(String value) {
return value == null ? "" : value.trim();
}
private static String clip(String value, int maxLen) {
String cleaned = safe(value).replace('\n', ' ').replace('\r', ' ');
if (cleaned.length() <= maxLen) return cleaned;
return cleaned.substring(0, Math.max(0, maxLen - 3)) + "...";
}
}

View File

@ -0,0 +1,13 @@
package server.logic.ws_protocol.JSON.handlers.system.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_CallDeliveryReport_Request extends Net_Request {
private String type;
private String value;
public String getType() { return type; }
public void setType(String type) { this.type = type; }
public String getValue() { return value; }
public void setValue(String value) { this.value = value; }
}

View File

@ -0,0 +1,14 @@
package server.logic.ws_protocol.JSON.handlers.system.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_CallDeliveryReport_Response extends Net_Response {
private long serverTs;
private boolean accepted;
public long getServerTs() { return serverTs; }
public void setServerTs(long serverTs) { this.serverTs = serverTs; }
public boolean isAccepted() { return accepted; }
public void setAccepted(boolean accepted) { this.accepted = accepted; }
}