arnacon-webrtc-service 0.1.183 → 0.1.185
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -35,6 +35,16 @@ class PolySession {
|
|
|
35
35
|
// billing). Kept as a list so callers compose without subclassing.
|
|
36
36
|
this._teardownHooks = teardownHooks.filter((h) => typeof h === "function");
|
|
37
37
|
this._teardownRan = false;
|
|
38
|
+
// Call-lifecycle hooks, fired on the active<->inactive edge derived from leg
|
|
39
|
+
// states (a call is "active" while either leg isActiveCall). This is the
|
|
40
|
+
// single robust seam for per-call side-effects that must bracket the call
|
|
41
|
+
// itself -- NOT the poly's disposal -- so they fire once per call on EVERY
|
|
42
|
+
// end path (hangup either side, mid-call failure, transport drop, reject)
|
|
43
|
+
// and again on a reused poly's next call. Used for minute-counter
|
|
44
|
+
// start/finish so billing always stops regardless of how the call ends.
|
|
45
|
+
this._onCallStart = null;
|
|
46
|
+
this._onCallEnd = null;
|
|
47
|
+
this._callActive = false;
|
|
38
48
|
this.logger = logger;
|
|
39
49
|
|
|
40
50
|
this.mediaHandle = null;
|
|
@@ -110,11 +120,38 @@ class PolySession {
|
|
|
110
120
|
}
|
|
111
121
|
this._recoverFailedLegs();
|
|
112
122
|
}
|
|
123
|
+
await this._settleCallActivity();
|
|
113
124
|
} finally {
|
|
114
125
|
this._running = false;
|
|
115
126
|
}
|
|
116
127
|
}
|
|
117
128
|
|
|
129
|
+
// Detect the in-call edge from the quiesced leg states and fire the lifecycle
|
|
130
|
+
// hooks once per transition. "In call" = BOTH legs are IN_CALL, i.e. the actual
|
|
131
|
+
// answered conversation (the media-bridged window) -- ring/setup/teardown are
|
|
132
|
+
// deliberately excluded. The falling edge is the reliable "conversation is over"
|
|
133
|
+
// signal that fires on every end path (hangup either side, mid-call failure,
|
|
134
|
+
// transport drop) while the poly itself survives for reuse.
|
|
135
|
+
async _settleCallActivity() {
|
|
136
|
+
const active = this.legs.a.state === LEG_STATES.IN_CALL && this.legs.b.state === LEG_STATES.IN_CALL;
|
|
137
|
+
if (active === this._callActive) return;
|
|
138
|
+
this._callActive = active;
|
|
139
|
+
const hook = active ? this._onCallStart : this._onCallEnd;
|
|
140
|
+
if (typeof hook !== "function") return;
|
|
141
|
+
try {
|
|
142
|
+
await hook(this);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
this.logger.error(`[${this.id}] call-${active ? "start" : "end"} hook failed: ${err.message}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Inject per-call lifecycle side-effects (e.g. minuteCounter start/finish).
|
|
149
|
+
// Idempotent to set; safe to (re)assign on a reused poly before its next call.
|
|
150
|
+
setCallActivityHooks({ onCallStart = null, onCallEnd = null } = {}) {
|
|
151
|
+
if (onCallStart !== undefined) this._onCallStart = typeof onCallStart === "function" ? onCallStart : null;
|
|
152
|
+
if (onCallEnd !== undefined) this._onCallEnd = typeof onCallEnd === "function" ? onCallEnd : null;
|
|
153
|
+
}
|
|
154
|
+
|
|
118
155
|
async _execute(action) {
|
|
119
156
|
if (action.kind === "media") {
|
|
120
157
|
return this._executeMedia(action);
|
|
@@ -139,7 +139,7 @@ const { ParticipantFactory } = require("./modules/participants/ParticipantFactor
|
|
|
139
139
|
const { createPolyCore } = require("./modules/calls/poly/createPolyCore");
|
|
140
140
|
const { WebRtcOutboundLegFactory } = require("./modules/calls/webrtc/WebRtcOutboundLegFactory");
|
|
141
141
|
const { LEG_EVENTS, makeLegEvent } = require("./modules/calls/poly/ports");
|
|
142
|
-
const { LEG_STATES } = require("./modules/calls/poly/states");
|
|
142
|
+
const { LEG_STATES, canBeRung, isActiveCall } = require("./modules/calls/poly/states");
|
|
143
143
|
const { isInactiveOffer } = require("./modules/calls/poly/negotiation/sdp");
|
|
144
144
|
const { routeToCodecPolicy } = require("./modules/media/negotiation/CodecPolicy");
|
|
145
145
|
const { narrowAudioOfferForCodecPolicy } = require("./modules/media/negotiation/SdpCodecNegotiator");
|
|
@@ -730,9 +730,67 @@ console.log("[poly] PolySession core is the live coordinator");
|
|
|
730
730
|
// HTTP SERVER — ENTRY POINT
|
|
731
731
|
// ═════════════════════════════════════════════════════════════
|
|
732
732
|
|
|
733
|
+
// Inbound reuse: if the PSTN callee already has a live, connected webrtc leg that
|
|
734
|
+
// is paired with THIS PSTN caller (idle from a prior call between the two), there
|
|
735
|
+
// is nothing to FCM-wake. Seed the PSTN INVITE as the SIP caller's OFFER into the
|
|
736
|
+
// EXISTING poly and let reconcile ring the connected callee over its open data
|
|
737
|
+
// channel -- byte-for-byte the webrtc<->webrtc redial path. Same S, same P; no
|
|
738
|
+
// fresh session, no notification. Returns the reuse result, or null to fall back
|
|
739
|
+
// to the cold (FCM-wake) flow when the callee isn't reachably connected.
|
|
740
|
+
async function tryInboundReuse(payload) {
|
|
741
|
+
const calleeLabel = pLabel(String(payload.to || "").toLowerCase());
|
|
742
|
+
const callerLabel = pLabel(String(payload.from || "").replace(/^\+/, "").toLowerCase());
|
|
743
|
+
if (!calleeLabel || !callerLabel) return null;
|
|
744
|
+
|
|
745
|
+
const poly = polyRegistry.getByEndpoint(payload.to);
|
|
746
|
+
if (!poly) return null;
|
|
747
|
+
|
|
748
|
+
const sipRef = polySipRef(poly);
|
|
749
|
+
const webRef = poly.legs.a.kind === "webrtc" ? "a" : (poly.legs.b.kind === "webrtc" ? "b" : null);
|
|
750
|
+
if (!sipRef || !webRef) return null; // not a webrtc<->sip poly
|
|
751
|
+
|
|
752
|
+
const webLeg = poly.legs[webRef];
|
|
753
|
+
const sipLeg = poly.legs[sipRef];
|
|
754
|
+
|
|
755
|
+
// The webrtc leg must be the callee (this `to`) and the sip leg this `from`,
|
|
756
|
+
// else the existing poly belongs to a different conversation.
|
|
757
|
+
if (pLabel(String(webLeg.endpoint || "").toLowerCase()) !== calleeLabel) return null;
|
|
758
|
+
if (pLabel(String(sipLeg.endpoint || "").toLowerCase()) !== callerLabel) return null;
|
|
759
|
+
|
|
760
|
+
// Only reuse a callee whose transport is actually rungable, and only if the
|
|
761
|
+
// sip leg is idle (no call already in flight on this poly).
|
|
762
|
+
if (!canBeRung(webLeg.state)) return null;
|
|
763
|
+
if (isActiveCall(sipLeg.state)) return null;
|
|
764
|
+
|
|
765
|
+
const hostSession = webLeg.negotiation?.session;
|
|
766
|
+
if (!hostSession || !hostSession.peerConnection) return null;
|
|
767
|
+
|
|
768
|
+
// Inject the per-call SIP context onto the reused session: the callee's own
|
|
769
|
+
// number to REGISTER as (openInbound pulls the suspended SBC INVITE) and the
|
|
770
|
+
// inbound metadata. Direction is decided by P firing answer() on the sip leg,
|
|
771
|
+
// never stored on the leg.
|
|
772
|
+
hostSession.inboundCall = { fromNumber: payload.from, toNumber: payload.to, callId: payload.callId };
|
|
773
|
+
if (sipLeg.negotiation) sipLeg.negotiation.phoneNumber = payload.to;
|
|
774
|
+
|
|
775
|
+
console.log(`[${hostSession.sessionId}] inbound reuse: ringing connected callee ${calleeLabel} over existing DC (caller ${callerLabel})`);
|
|
776
|
+
try {
|
|
777
|
+
// Seed the PSTN INVITE as the sip caller's OFFER -> CALLING. Reconcile then
|
|
778
|
+
// sees sip CALLING + webrtc CONNECTED -> ring(webrtc) over its DC; on pickup
|
|
779
|
+
// -> answer(sip) (openInbound) + media bridge.
|
|
780
|
+
await poly.onIngress(sipRef, makeLegEvent(LEG_EVENTS.OFFER));
|
|
781
|
+
} catch (err) {
|
|
782
|
+
console.error(`[${hostSession.sessionId}] inbound reuse seed failed: ${err.message}`);
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
return { ok: true, sessionId: hostSession.sessionId, reused: true };
|
|
786
|
+
}
|
|
787
|
+
|
|
733
788
|
async function handleInboundCallRequest(data, serviceContext = null) {
|
|
734
789
|
const payload = serviceContext?.serviceId ? { ...data, serviceId: serviceContext.serviceId } : data;
|
|
735
|
-
//
|
|
790
|
+
// Fast path: reuse a connected callee's existing transport (P rings over DC).
|
|
791
|
+
const reused = await tryInboundReuse(payload);
|
|
792
|
+
if (reused) return reused;
|
|
793
|
+
// Cold path: the inbound flow creates the session + PC1 and FCM-invites the secnum callee.
|
|
736
794
|
const result = await inboundCallFlowApi.handleInboundCallRequest(payload);
|
|
737
795
|
if (result?.ok && result.sessionId) {
|
|
738
796
|
const session = sessions.get(result.sessionId);
|
|
@@ -758,6 +816,14 @@ async function handleInboundCallRequest(data, serviceContext = null) {
|
|
|
758
816
|
const phoneNumber = session.inboundCall?.toNumber || payload.to;
|
|
759
817
|
const callerNumber = session.inboundCall?.fromNumber || session.callerEns;
|
|
760
818
|
try {
|
|
819
|
+
// The registry keys polys by an order-independent pair key, so a
|
|
820
|
+
// stale poly from a prior call between these two parties (e.g. an
|
|
821
|
+
// earlier W->SIP attempt) resolves to the SAME key. Reusing it is
|
|
822
|
+
// wrong here: its leg layout (a/b) and bound session belong to that
|
|
823
|
+
// old call, so the hardcoded onIngress("a") would hit the wrong leg.
|
|
824
|
+
// An inbound call always brings a fresh session + FCM wake, so it
|
|
825
|
+
// must own a fresh poly -> tear any stale one down first.
|
|
826
|
+
await polyRegistry.destroy(polyRegistry.keyForPair(callerNumber, calleeEns), "inbound-fresh-call");
|
|
761
827
|
const { poly } = polyRegistry.resolve({
|
|
762
828
|
a: { endpoint: callerNumber, kind: "sip", phoneNumber, session },
|
|
763
829
|
b: {
|
|
@@ -983,6 +1049,9 @@ async function onDcRing(callerSessionId, payload) {
|
|
|
983
1049
|
|
|
984
1050
|
const a = { endpoint: session.callerEns, kind: "webrtc", session };
|
|
985
1051
|
let b;
|
|
1052
|
+
// Minute metering is SBC/PSTN-outbound only; resolved in the sip branch below.
|
|
1053
|
+
let minuteCounterSettings = null;
|
|
1054
|
+
let minuteCounterIdentity = null;
|
|
986
1055
|
if (destination.route === "webrtc") {
|
|
987
1056
|
b = {
|
|
988
1057
|
endpoint: destination.ensName || destination.wallet,
|
|
@@ -1020,9 +1089,63 @@ async function onDcRing(callerSessionId, payload) {
|
|
|
1020
1089
|
kind: "sip",
|
|
1021
1090
|
session,
|
|
1022
1091
|
};
|
|
1092
|
+
|
|
1093
|
+
// Minute metering for SBC/PSTN outbound. The poly cutover orphaned the
|
|
1094
|
+
// legacy SbcRouteStrategy where start()/assertCanStart() lived, so wire it
|
|
1095
|
+
// here at SIP-leg origination: gate on the per-service monthly cap, then (on
|
|
1096
|
+
// resolve) stamp session.minuteCounter so the registry teardown hook's
|
|
1097
|
+
// finish() records the elapsed seconds. Pure caller-side billing.
|
|
1098
|
+
minuteCounterSettings = minuteCounterPolicy.getSettings(serviceId);
|
|
1099
|
+
minuteCounterIdentity = minuteCounterPolicy.getIdentity(parsedFrom, session);
|
|
1100
|
+
if (minuteCounterApi && minuteCounterSettings?.limitSeconds) {
|
|
1101
|
+
try {
|
|
1102
|
+
minuteCounterApi.assertCanStart({
|
|
1103
|
+
serviceId: minuteCounterSettings.serviceId,
|
|
1104
|
+
identity: minuteCounterIdentity,
|
|
1105
|
+
limitSeconds: minuteCounterSettings.limitSeconds,
|
|
1106
|
+
});
|
|
1107
|
+
} catch (err) {
|
|
1108
|
+
console.log(`[${callerSessionId}] minute limit reached for ${minuteCounterIdentity}: ${err.message}`);
|
|
1109
|
+
sendDataChannelMessage(callerSessionId, { msgType: "call", action: "end", reason: "minute-limit" });
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
// Prepaid cutoff: when the caller is low on monthly balance (< 5 min
|
|
1113
|
+
// left) tell Kamailio how many seconds THIS call is allowed via a "Limit"
|
|
1114
|
+
// header. Kamailio arms a dialog timer, BYEs both legs at expiry, and
|
|
1115
|
+
// strips the header before the SBC. The service decides the budget;
|
|
1116
|
+
// Kamailio enforces it. (remaining > 0 here -- assertCanStart already
|
|
1117
|
+
// rejected an exhausted balance.)
|
|
1118
|
+
const usedSeconds = minuteCounterApi.getUsedSeconds({
|
|
1119
|
+
serviceId: minuteCounterSettings.serviceId,
|
|
1120
|
+
identity: minuteCounterIdentity,
|
|
1121
|
+
});
|
|
1122
|
+
const remainingSeconds = minuteCounterSettings.limitSeconds - usedSeconds;
|
|
1123
|
+
if (remainingSeconds > 0 && remainingSeconds < 300) {
|
|
1124
|
+
session.sipDirective.headers = {
|
|
1125
|
+
...(session.sipDirective.headers || {}),
|
|
1126
|
+
Limit: String(remainingSeconds),
|
|
1127
|
+
};
|
|
1128
|
+
console.log(`[${callerSessionId}] low balance: capping call at ${remainingSeconds}s for ${minuteCounterIdentity}`);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1023
1131
|
}
|
|
1024
1132
|
|
|
1025
1133
|
const { poly } = polyRegistry.resolve({ a, b, target: "a" });
|
|
1134
|
+
if (b.kind === "sip" && minuteCounterApi && minuteCounterSettings?.limitSeconds) {
|
|
1135
|
+
// Bill only the answered conversation: PolySession fires onCallStart when
|
|
1136
|
+
// both legs reach IN_CALL and onCallEnd when they leave it -- so the counter
|
|
1137
|
+
// starts at answer and stops on ANY end (hangup either side, failure,
|
|
1138
|
+
// transport drop), independent of poly disposal/reuse. finish() is
|
|
1139
|
+
// idempotent (the dispose teardown hook remains a backstop).
|
|
1140
|
+
poly.setCallActivityHooks({
|
|
1141
|
+
onCallStart: () => minuteCounterApi.start(session, {
|
|
1142
|
+
serviceId: minuteCounterSettings.serviceId,
|
|
1143
|
+
identity: minuteCounterIdentity,
|
|
1144
|
+
limitSeconds: minuteCounterSettings.limitSeconds,
|
|
1145
|
+
}),
|
|
1146
|
+
onCallEnd: () => minuteCounterApi.finish(session),
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1026
1149
|
// Caller transport is already up (HTTP handshake). The SIP leg has no transport
|
|
1027
1150
|
// to negotiate (openOutbound happens on ring), so mark it usable too. A webrtc
|
|
1028
1151
|
// callee, by contrast, starts disconnected: reconcile will connect() it (FCM
|