// SPDX-License-Identifier: GPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // lint-disable-file fixed-width-types /* Imsd:Engine — the call state machine as a pure reducer. A CallMachine owns one call's SIP dialog, outgoing or incoming. It never touches a socket, a clock, or a subprocess: each event (the INVITE's responses, an in-dialog request from the network, an accept/hang-up command, the media leg exiting, a setup timeout) is a method that mutates the dialog state and returns a list of Actions for the daemon shell to carry out — send this on the client flow, answer the request on the channel it came in on, start/stop the media leg, emit this D-Bus state change, arm this deadline. That keeps the whole call FSM — PRACK on reliable 18x, ACK of 2xx vs non-2xx finals, CANCEL/answer races, remote BYE, media-plane far-end-hangup, and the terminating (UAS) side: 100/180 on an inbound INVITE, 200-with-answer on Accept, 486 reject, the remote-CANCEL 200+487 pair — unit-testable against recorded network dialogs with no I/O (tests/Engine). State/reason strings are the frozen D-Bus vocabulary the dialer maps on (dialing/ringing/incoming/active/terminated; outgoing/incoming/accepted/ local-hangup/remote-hangup/refused-or-busy/error). Pure std C++. */ export module Imsd:Engine; import std; import :Util; import :Sip; import :Sdp; import :Messages; export namespace imsd::engine { enum class CallState { Dialing, Ringing, Incoming, Active, Terminated }; enum class Direction { Outgoing, Incoming }; inline std::string_view StateStr(CallState s) { switch (s) { case CallState::Dialing: return "dialing"; case CallState::Ringing: return "ringing"; case CallState::Incoming: return "incoming"; case CallState::Active: return "active"; case CallState::Terminated: return "terminated"; } return "terminated"; } inline std::string_view DirectionStr(Direction d) { return d == Direction::Incoming ? "incoming" : "outgoing"; } // Parsed SDP answer needed to bring up the media leg. struct MediaLeg { std::string remoteIp; int remotePort = 0; int payloadType = -1; bool octetAlign = false; std::string codec = "AMR-WB"; bool Valid() const { return !remoteIp.empty() && remotePort > 0 && payloadType >= 0; } }; struct Action { enum class Type { SendClient, // send `text` on the client TCP flow SendResponse, // answer the just-handled inbound request on its origin channel StartMedia, // bring up the media leg described by `media` StopMedia, // tear the media leg down State, // emit CallStateChanged(uni, `state`, `reason`) Deleted, // emit CallDeleted(uni); drop this machine SetDeadline, // arm a setup timer for `seconds`; fire calls OnDeadline() Log, }; Type type; std::string text; std::string state; std::string reason; MediaLeg media; double seconds = 0; }; // ring/answer budget, and how long to wait for a 487 after CANCEL — the // imsd.py INVITE_TIMEOUT / CANCEL_487_TIMEOUT constants. inline constexpr double InviteTimeout = 90.0; inline constexpr double Cancel487Timeout = 10.0; // How long an unanswered incoming call may ring before we 480 it — a // safety net behind the network's own CANCEL, so generous. inline constexpr double IncomingRingTimeout = 120.0; // Constructor tag: this machine is the UAS of the given inbound INVITE. struct IncomingInvite { std::string_view invite; }; class CallMachine { public: CallMachine(const imsd::msg::Context& ctx, imsd::util::Rng& rng, std::string uni, std::string number, int rtpPort, bool precond) : ctx_(ctx), rng_(rng), uni_(std::move(uni)), number_(std::move(number)), rtpPort_(rtpPort), precond_(precond) { d_.ruri = imsd::msg::RuriFor(number_, ctx_.id.domain); d_.callid = std::format("{}@{}", rng_.Token(20), ctx_.local); d_.itag = rng_.Token(10); d_.invBranch = rng_.Token(20); } CallMachine(const imsd::msg::Context& ctx, imsd::util::Rng& rng, std::string uni, IncomingInvite in, int rtpPort) : ctx_(ctx), rng_(rng), uni_(std::move(uni)), rtpPort_(rtpPort), precond_(false), direction_(Direction::Incoming), state_(CallState::Incoming), reason_("incoming"), invite_(in.invite) { number_ = imsd::sip::CallerId(invite_); d_.callid = std::string(imsd::sip::Header(invite_, "Call-ID").value_or("")); d_.itag = rng_.Token(10); // the To tag we mint = our dialog tag d_.invBranch = rng_.Token(20); // unused as UAS; kept initialized if (auto from = imsd::sip::Header(invite_, "From")) d_.dialogTo = std::string(*from); if (auto ct = imsd::sip::Header(invite_, "Contact")) d_.remoteTarget = AngleUri(*ct); d_.ruri = d_.remoteTarget.value_or(""); // The UAS route set is the Record-Route in RECEIVED order // (RFC 3261 12.1.1); DialogRoute() reverses for the UAC case, so // store it reversed here to cancel that out. auto rr = imsd::sip::Headers(invite_, "Record-Route"); for (std::size_t i = rr.size(); i-- > 0;) d_.recordRoute.emplace_back(rr[i]); answerSdp_ = std::string(Body(invite_)); // the caller's offer nextCseq_ = 0; // our first in-dialog request gets CSeq 1 } const std::string& Uni() const { return uni_; } const std::string& Number() const { return number_; } const std::string& CallId() const { return d_.callid; } CallState State() const { return state_; } std::string_view StateStr() const { return engine::StateStr(state_); } Direction Dir() const { return direction_; } std::string_view DirectionStr() const { return engine::DirectionStr(direction_); } const std::string& Reason() const { return reason_; } bool Terminated() const { return state_ == CallState::Terminated; } // Build + queue the INVITE and arm the ring timeout. std::vector Start() { imsd::sdp::Offer offer; offer.local = ctx_.local; offer.rtpPort = rtpPort_; offer.sessionId = rng_.UInt(1000000, 9999999); offer.precond = precond_; std::string sdp = imsd::sdp::BuildOffer(offer); std::vector a; a.push_back(Send(imsd::msg::BuildInvite(ctx_, d_, sdp, precond_))); a.push_back(Deadline(InviteTimeout)); return a; } // UAS: answer the inbound INVITE with 100 Trying + 180 Ringing (made // reliable only when the caller Requires 100rel) and arm the ring // timeout. An offer we cannot serve — no audio line, or no AMR-WB // (the media plane is AMR-WB-only) — is refused with 488 up front, // before the UI ever rings. std::vector OnInvite() { std::vector a; if (direction_ != Direction::Incoming || state_ != CallState::Incoming) return a; MediaLeg leg = ParseAnswer(); if (!leg.Valid() || leg.codec != "AMR-WB") { a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 488, "Not Acceptable Here", d_.itag))); Append(a, Finish("error", "incoming offer unusable (need AMR-WB audio); 488 sent")); return a; } a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 100, "Trying", d_.itag))); std::string require(imsd::sip::Header(invite_, "Require").value_or("")); if (require.contains("precondition")) a.push_back(LogAct("caller requires preconditions; answering without them")); std::vector extra; if (require.contains("100rel")) { extra.push_back("Require: 100rel"); extra.push_back("RSeq: 1"); } last180_ = imsd::msg::BuildInviteResponse(ctx_, invite_, 180, "Ringing", d_.itag, std::nullopt, extra); a.push_back(Respond(last180_)); a.push_back(Deadline(IncomingRingTimeout)); return a; } // UAS: the user answered. 200 OK with the SDP answer (the offer's own // payload types, RFC 3264), media up, active. The caller's ACK needs // no action when it arrives (OnRequest ignores ACK). std::vector OnAccept() { std::vector a; if (direction_ != Direction::Incoming || state_ != CallState::Incoming) return a; MediaLeg leg = ParseAnswer(); imsd::sdp::AnswerSpec spec; spec.local = ctx_.local; spec.rtpPort = rtpPort_; spec.sessionId = rng_.UInt(1000000, 9999999); spec.payloadType = leg.payloadType; spec.octetAlign = leg.octetAlign; spec.codec = leg.codec; spec.dtmfPt = imsd::sdp::TelephoneEventPt(Body(invite_), 16000); localSdp_ = imsd::sdp::BuildAnswer(spec); // session timers: echo the caller's Session-Expires; with no // refresher stated, make the caller (uac) the refresher — its // re-INVITE/UPDATE refreshes get 200d by OnRequest. std::vector extra; if (auto se = imsd::sip::Header(invite_, "Session-Expires")) { std::string v(*se); if (!v.contains("refresher=")) v += ";refresher=uac"; extra.push_back(std::format("Session-Expires: {}", v)); extra.push_back("Require: timer"); } a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 200, "OK", d_.itag, localSdp_, extra))); a.push_back(StartMediaAct(leg)); state_ = CallState::Active; a.push_back(StateAct("accepted")); return a; } // A response to our INVITE (the shell already matched Call-ID + CSeq). std::vector OnInviteResponse(std::string_view msg) { std::vector a; if (direction_ == Direction::Incoming) return a; // a UAS has no INVITE transaction of its own AbsorbDialog(msg); auto st = imsd::sip::Status(msg); if (!st) return a; int code = *st; if (code > 100 && code < 200) { if (state_ == CallState::Dialing) { state_ = CallState::Ringing; a.push_back(StateAct("outgoing")); } auto rseq = imsd::sip::Header(msg, "RSeq"); std::string require = std::string(imsd::sip::Header(msg, "Require").value_or("")); if (rseq && require.contains("100rel") && !pracked_.contains(std::string(*rseq))) { pracked_.insert(std::string(*rseq)); std::array extra = { std::format("RAck: {} 1 INVITE", *rseq)}; a.push_back(Send(imsd::msg::BuildInDialog(ctx_, d_, "PRACK", nextCseq_, rng_.Token(20), extra))); nextCseq_++; } return a; } if (code < 200) return a; // 1xx other than 100 OnRequest(std::string_view msg) { std::vector a; std::string_view method = Method(msg); if (method == "ACK") return a; // ACK is never responded to (RFC 3261 17.1.1.2) std::string_view body = Body(msg); if (body.contains("m=audio")) answerSdp_ = std::string(body); if (direction_ == Direction::Incoming && state_ == CallState::Incoming) { if (method == "CANCEL") { // the caller gave up: 200 the CANCEL transaction, 487 the // INVITE transaction (RFC 3261 9.2), done. a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg))); a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 487, "Request Terminated", d_.itag))); Append(a, Finish("remote-hangup", WithReason("CANCEL before answer", msg))); } else if (method == "INVITE") { // retransmitted INVITE (our provisional got lost): repeat it a.push_back(Respond(last180_)); } else { // PRACK of our reliable 180, or anything else: 200 echo a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg))); } return a; } if (method == "UPDATE" || method == "INVITE") { std::optional ans; if (direction_ == Direction::Incoming) { // session-refresh: re-state the answer we gave if the // refresh carries an offer; a bodyless refresh gets a // bodyless 200 (RFC 3311/4028) if (body.contains("m=audio")) ans = localSdp_; } else { imsd::sdp::Offer offer; offer.local = ctx_.local; offer.rtpPort = rtpPort_; offer.sessionId = rng_.UInt(1000000, 9999999); offer.precond = precond_; offer.currRemote = "sendrecv"; ans = imsd::sdp::BuildOffer(offer); } // RFC 4028 §9: a 2xx to a refresh MUST echo Session-Expires, // or the refresher treats the session as unrefreshed. KPN's // B2BUA audits ~55 s into every call with such an UPDATE and // releases ("PT: Session timer expired") on a bare 200. std::vector extra; if (auto se = imsd::sip::Header(msg, "Session-Expires")) { std::string v(*se); if (!v.contains("refresher=")) v += ";refresher=uac"; extra.push_back(std::format("Session-Expires: {}", v)); extra.push_back("Require: timer"); } a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg, ans ? std::optional(*ans) : std::nullopt, extra))); } else if (method == "BYE") { a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg))); Append(a, Finish("remote-hangup", WithReason("remote BYE", msg))); } else if (method == "CANCEL") { a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg))); Append(a, Finish("remote-hangup", WithReason("remote CANCEL", msg))); } else { a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg))); } return a; } std::vector OnHangup() { std::vector a; if (direction_ == Direction::Incoming && state_ == CallState::Incoming) { // reject the unanswered call a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 486, "Busy Here", d_.itag))); Append(a, Finish("local-hangup", "rejected (486 sent)")); return a; } if (state_ == CallState::Active) { nextCseq_++; a.push_back(Send(imsd::msg::BuildInDialog(ctx_, d_, "BYE", nextCseq_, rng_.Token(20)))); Append(a, Finish("local-hangup", "HangUp; BYE sent")); } else if ((state_ == CallState::Dialing || state_ == CallState::Ringing) && !cancelled_) { a.push_back(Send(imsd::msg::BuildCancel(ctx_, d_))); cancelled_ = true; a.push_back(Deadline(Cancel487Timeout)); } return a; } // The media subprocess exited. code 3 = the downlink RTP dried up // (far-end hangup); anything else = the leg died. Either way, if the // call was up we release it network-side and tear down. std::vector OnMediaExit(int code) { std::vector a; if (state_ != CallState::Active) return a; nextCseq_++; a.push_back(Send(imsd::msg::BuildInDialog(ctx_, d_, "BYE", nextCseq_, rng_.Token(20)))); Append(a, Finish("remote-hangup", code == 3 ? "far-end hangup (downlink RTP stopped); BYE sent" : std::format("media leg died (code {}); BYE sent", code))); return a; } // A setup deadline fired (never armed once Active). std::vector OnDeadline() { std::vector a; if (state_ == CallState::Active) return a; if (direction_ == Direction::Incoming) { if (state_ == CallState::Incoming) { // rang unanswered past the safety net; the missed call // reads as remote-hangup, like a network CANCEL would a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 480, "Temporarily Unavailable", d_.itag))); Append(a, Finish("remote-hangup", "rang unanswered past the deadline (480 sent)")); } return a; } if (!cancelled_ && (state_ == CallState::Dialing || state_ == CallState::Ringing)) { a.push_back(Send(imsd::msg::BuildCancel(ctx_, d_))); cancelled_ = true; a.push_back(Deadline(Cancel487Timeout)); } else { Append(a, Finish("error", "setup deadline expired")); } return a; } // The shell couldn't send (socket error) — collapse the call. std::vector Fail(std::string_view reason) { std::vector a; Append(a, Finish(reason, "client-flow send failed")); return a; } private: const imsd::msg::Context& ctx_; imsd::util::Rng& rng_; std::string uni_, number_; int rtpPort_; bool precond_; Direction direction_ = Direction::Outgoing; CallState state_ = CallState::Dialing; std::string reason_ = "outgoing"; std::string invite_; // incoming only: the inbound INVITE verbatim std::string last180_; // incoming only: our provisional, for retransmits std::string localSdp_; // incoming only: the SDP answer we sent imsd::msg::Dialog d_; int nextCseq_ = 2; bool cancelled_ = false; std::set pracked_; std::string answerSdp_; static std::string_view Body(std::string_view msg) { std::size_t at = msg.find("\r\n\r\n"); return at == std::string_view::npos ? std::string_view{} : msg.substr(at + 4); } static std::string_view Method(std::string_view msg) { std::size_t sp = msg.find(' '); return sp == std::string_view::npos ? msg : msg.substr(0, sp); } static std::optional AngleUri(std::string_view v) { std::size_t lt = v.find('<'); if (lt == std::string_view::npos) return std::string(v); std::size_t gt = v.find('>', lt); if (gt == std::string_view::npos) return std::string(v); return std::string(v.substr(lt + 1, gt - lt - 1)); } // Fold a response's dialog-establishing headers into the dialog. void AbsorbDialog(std::string_view msg) { if (auto to = imsd::sip::Header(msg, "To")) if (to->contains("tag=")) d_.dialogTo = std::string(*to); auto rr = imsd::sip::Headers(msg, "Record-Route"); if (!rr.empty()) { d_.recordRoute.clear(); for (auto v : rr) d_.recordRoute.emplace_back(v); } if (auto ct = imsd::sip::Header(msg, "Contact")) d_.remoteTarget = AngleUri(*ct); std::string_view body = Body(msg); if (body.contains("m=audio")) answerSdp_ = std::string(body); } MediaLeg ParseAnswer() const { imsd::sdp::Answer ans = imsd::sdp::ParseAnswer(answerSdp_); MediaLeg leg; leg.remoteIp = ans.ip; leg.remotePort = ans.port < 0 ? 0 : ans.port; leg.payloadType = ans.payloadType; leg.octetAlign = ans.octetAlign; leg.codec = ans.codec; return leg; } // Terminate: state change, media teardown, deletion — in that order, // so the UI leaves the call page before the (slow) media teardown. // One journald line per teardown — who ended the call and why // (the s59 60-second-drop hunt ran blind for lack of exactly // this; the network's Reason header named the cause all along). // `detail` carries the SIP-level cause when the caller knows it. std::vector Finish(std::string_view reason, std::string_view detail = "") { state_ = CallState::Terminated; reason_ = std::string(reason); std::vector a; a.push_back(LogAct(detail.empty() ? std::format("call {} ended: {}", uni_, reason) : std::format("call {} ended: {} — {}", uni_, reason, detail))); a.push_back(StateAct(reason)); a.push_back(Action{Action::Type::StopMedia}); a.push_back(Action{Action::Type::Deleted}); return a; } // "remote BYE, Reason: SIP;cause=503;text=..." — the peer's own // stated cause, when it sent one. static std::string WithReason(std::string_view what, std::string_view msg) { auto r = imsd::sip::Header(msg, "Reason"); return r ? std::format("{}, Reason: {}", what, *r) : std::string(what); } Action Send(std::string text) { return Action{Action::Type::SendClient, std::move(text)}; } Action Respond(std::string text) { return Action{Action::Type::SendResponse, std::move(text)}; } Action StateAct(std::string_view reason) { reason_ = std::string(reason); Action a{Action::Type::State}; a.state = std::string(engine::StateStr(state_)); a.reason = reason_; return a; } Action StartMediaAct(const MediaLeg& leg) { Action a{Action::Type::StartMedia}; a.media = leg; return a; } Action Deadline(double s) { Action a{Action::Type::SetDeadline}; a.seconds = s; return a; } Action LogAct(std::string text) { return Action{Action::Type::Log, std::move(text)}; } static void Append(std::vector& into, std::vector more) { for (auto& m : more) into.push_back(std::move(m)); } }; }