CallDeliveryReport: универсальный формат type/value и расширенные отчёты по звонкам
This commit is contained in:
parent
3061bf3d1e
commit
bff403ea04
@ -1,2 +1,2 @@
|
||||
client.version=1.2.21
|
||||
server.version=1.2.21
|
||||
client.version=1.2.22
|
||||
server.version=1.2.22
|
||||
|
||||
@ -1171,8 +1171,6 @@ export class AuthService {
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
const userLastGlobalNumber = Number(user?.serverLastGlobalNumber);
|
||||
const userLastGlobalHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64);
|
||||
|
||||
const ownerBlockchainName = owner;
|
||||
const lineCode = root;
|
||||
@ -1182,17 +1180,14 @@ export class AuthService {
|
||||
if (ownerBlockchainName !== blockchainName) {
|
||||
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);
|
||||
if (lineCode === 0) {
|
||||
rootHashHex = (
|
||||
Number.isFinite(userLastGlobalNumber) &&
|
||||
userLastGlobalNumber === 0 &&
|
||||
userLastGlobalHash !== ZERO64
|
||||
)
|
||||
? userLastGlobalHash
|
||||
: await this.resolveHeaderHashForBlockchain(blockchainName);
|
||||
} else if (rootHashHex === ZERO64) {
|
||||
if (rootHashHex === ZERO64) {
|
||||
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
|
||||
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
|
||||
if (!rootChannel) throw new Error('Channel root not found');
|
||||
@ -1431,6 +1426,12 @@ export class AuthService {
|
||||
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() {
|
||||
const response = await this.ws.request('ListContacts', {});
|
||||
if (response.status !== 200) throw opError('ListContacts', response);
|
||||
|
||||
@ -93,12 +93,25 @@ async function resolveIceServers(call) {
|
||||
const turnUrls = uniqueUrls(parseIceUrls(payload?.turnUrls));
|
||||
const turnUsername = String(payload?.turnUsername || '').trim();
|
||||
const turnPassword = String(payload?.turnPassword || '').trim();
|
||||
const turnServers = Array.isArray(payload?.turnServers) ? payload.turnServers : [];
|
||||
|
||||
const iceServers = [];
|
||||
if (stunUrls.length > 0) {
|
||||
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({
|
||||
urls: turnUrls.length === 1 ? turnUrls[0] : turnUrls,
|
||||
username: turnUsername,
|
||||
@ -110,7 +123,7 @@ async function resolveIceServers(call) {
|
||||
await emitDebug(call, 'warn', 'call_ice_empty_from_server', 'using_default_stun');
|
||||
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;
|
||||
} catch (error) {
|
||||
await emitDebug(call, 'warn', 'call_ice_load_failed', toErrorText(error));
|
||||
@ -236,6 +249,59 @@ function setActiveStatus(call) {
|
||||
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) {
|
||||
if (call.timers?.ack10s) clearTimeout(call.timers.ack10s);
|
||||
if (call.timers?.total35s) clearTimeout(call.timers.total35s);
|
||||
@ -251,6 +317,7 @@ async function closeMedia(call) {
|
||||
call.localStream = null;
|
||||
call.audioSenders = [];
|
||||
call.connectionRouteLabel = '';
|
||||
call.connectionRouteDetails = '';
|
||||
}
|
||||
|
||||
function stopReconnectFlow(call) {
|
||||
@ -269,7 +336,9 @@ function stopReconnectFlow(call) {
|
||||
|
||||
async function detectConnectionRoute(call) {
|
||||
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 {
|
||||
const stats = await pc.getStats();
|
||||
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'
|
||||
? stats.get(selectedPair.localCandidateId)
|
||||
@ -309,17 +378,39 @@ async function detectConnectionRoute(call) {
|
||||
|
||||
const localType = String(local?.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') {
|
||||
return { label: 'через TURN', details };
|
||||
const label = turnCandidateAddress
|
||||
? `через TURN (${turnCandidateAddress})`
|
||||
: (relayProto ? `через TURN (${relayProto})` : 'через TURN');
|
||||
return { label, details, localIp, remoteIp, turnCandidateAddress };
|
||||
}
|
||||
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 {
|
||||
return { label: '', details: '' };
|
||||
return { label: '', details: '', localIp: '', remoteIp: '', turnCandidateAddress: '' };
|
||||
}
|
||||
}
|
||||
|
||||
@ -336,6 +427,7 @@ function startTransportProbe(call) {
|
||||
const route = await detectConnectionRoute(call);
|
||||
if (!route.label || route.label === call.connectionRouteLabel) return;
|
||||
call.connectionRouteLabel = route.label;
|
||||
call.connectionRouteDetails = route.details || '';
|
||||
setActiveStatus(call);
|
||||
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}`);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
call.phase = 'ended';
|
||||
@ -565,6 +669,30 @@ async function ensurePeerConnection(call) {
|
||||
setActiveStatus(call);
|
||||
startTransportProbe(call);
|
||||
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;
|
||||
}
|
||||
if (state === 'failed') {
|
||||
@ -599,6 +727,7 @@ async function ensurePeerConnection(call) {
|
||||
call.localStream = stream;
|
||||
call.audioSenders = [];
|
||||
call.connectionRouteLabel = '';
|
||||
call.connectionRouteDetails = '';
|
||||
stream.getTracks().forEach((track) => {
|
||||
track.enabled = track.kind === 'audio' ? !call.muted : true;
|
||||
const sender = pc.addTrack(track, stream);
|
||||
|
||||
@ -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_ClientErrorLog_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.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_ClientDebugLog_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("GetCallIceConfig", new Net_GetCallIceConfig_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 ---
|
||||
// Map.entry("ListSubscribedChannels", new Net_GetSubscribedChannels_Handler())
|
||||
@ -207,7 +210,8 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("GetServerInfo", Net_GetServerInfo_Request.class),
|
||||
Map.entry("GetCallIceConfig", Net_GetCallIceConfig_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() { }
|
||||
|
||||
@ -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)) + "...";
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user