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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arnacon-webrtc-service",
3
- "version": "0.1.183",
3
+ "version": "0.1.185",
4
4
  "description": "Arnacon WebRTC core runtime and service modules",
5
5
  "main": "./webRTCservice/core.js",
6
6
  "type": "commonjs",
@@ -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
- // The inbound flow creates the session + PC1 and FCM-invites the secnum callee.
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