mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-20 23:03:44 +08:00
fix: align IMS calls with MMTel dialog requirements (#48)
* fix: align IMS calls with MMTel routing * fix: originate calls with associated public identity * fix: use carrier-specific IMS dial URIs * fix: acknowledge rejected INVITE transactions * docs: record sanitized IMS call validation * Delete docs/IMS_CALL_INTEROPERABILITY.md * Update README.md --------- Co-authored-by: Meng Meng <[email protected]>
This commit is contained in:
@@ -33,6 +33,11 @@ type CarrierProfile struct {
|
||||
IMSRegisterProfile string
|
||||
IMSIPSecEncryption string
|
||||
SMSCenter string
|
||||
PANICountry string
|
||||
PANINode string
|
||||
IMSDialURIScheme string
|
||||
IMSUserEqPhone bool
|
||||
IMSVoiceCodecs []string
|
||||
}
|
||||
|
||||
type carrierProfileDocument struct {
|
||||
@@ -75,11 +80,16 @@ type carrierProfileIKE struct {
|
||||
}
|
||||
|
||||
type carrierProfileIMS struct {
|
||||
Transport string `json:"transport"`
|
||||
IdentityProfile string `json:"identity_profile"`
|
||||
RegisterProfile string `json:"register_profile"`
|
||||
IPSecEncryption string `json:"ipsec_encryption"`
|
||||
SMSCenter string `json:"sms_center"`
|
||||
Transport string `json:"transport"`
|
||||
IdentityProfile string `json:"identity_profile"`
|
||||
RegisterProfile string `json:"register_profile"`
|
||||
IPSecEncryption string `json:"ipsec_encryption"`
|
||||
SMSCenter string `json:"sms_center"`
|
||||
PANICountry string `json:"pani_country"`
|
||||
PANINode string `json:"pani_node"`
|
||||
DialURIScheme string `json:"dial_uri_scheme"`
|
||||
UserEqPhone *bool `json:"user_eq_phone"`
|
||||
VoiceCodecs []string `json:"voice_codecs"`
|
||||
}
|
||||
|
||||
//go:embed carrier_profiles.json
|
||||
@@ -140,6 +150,20 @@ func validCarrierProfileRule(rule carrierProfileRule) bool {
|
||||
encryption != "aes-cbc" && encryption != "null" {
|
||||
return false
|
||||
}
|
||||
if country := strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry)); country != "" &&
|
||||
(len(country) != 2 || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z') {
|
||||
return false
|
||||
}
|
||||
if scheme := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); scheme != "" && scheme != "tel" && scheme != "sip" {
|
||||
return false
|
||||
}
|
||||
for _, codec := range rule.IMS.VoiceCodecs {
|
||||
switch strings.ToUpper(strings.TrimSpace(codec)) {
|
||||
case "PCMA", "PCMU", "AMR", "AMR-WB":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -155,6 +179,8 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
||||
IMSIdentityProfile: IMSProfileStandard,
|
||||
IMSRegisterProfile: IMSProfileStandard,
|
||||
IMSIPSecEncryption: "aes-cbc",
|
||||
IMSDialURIScheme: "tel",
|
||||
IMSVoiceCodecs: []string{"PCMA", "PCMU"},
|
||||
}
|
||||
bestScore := -1
|
||||
for _, rule := range builtinCarrierProfiles {
|
||||
@@ -256,9 +282,37 @@ func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, sourc
|
||||
base.IMSIPSecEncryption = value
|
||||
}
|
||||
base.SMSCenter = strings.TrimSpace(rule.IMS.SMSCenter)
|
||||
base.PANICountry = strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry))
|
||||
base.PANINode = strings.TrimSpace(rule.IMS.PANINode)
|
||||
if value := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); value != "" {
|
||||
base.IMSDialURIScheme = value
|
||||
}
|
||||
if rule.IMS.UserEqPhone != nil {
|
||||
base.IMSUserEqPhone = *rule.IMS.UserEqPhone
|
||||
}
|
||||
if len(rule.IMS.VoiceCodecs) > 0 {
|
||||
base.IMSVoiceCodecs = normalizeVoiceCodecs(rule.IMS.VoiceCodecs)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func normalizeVoiceCodecs(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := seen[value]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func canonicalPLMN(mcc, mnc string) string {
|
||||
mcc = strings.TrimSpace(mcc)
|
||||
mnc = strings.TrimSpace(mnc)
|
||||
|
||||
@@ -18,7 +18,13 @@
|
||||
"imsi_prefixes": ["23433"],
|
||||
"iccid_prefixes": ["894430"]
|
||||
},
|
||||
"route": { "mcc": "234", "mnc": "30" }
|
||||
"route": { "mcc": "234", "mnc": "30" },
|
||||
"ims": {
|
||||
"pani_country": "GB",
|
||||
"pani_node": "000000000000",
|
||||
"dial_uri_scheme": "sip",
|
||||
"voice_codecs": ["PCMA", "PCMU"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "att-us",
|
||||
@@ -58,7 +64,8 @@
|
||||
"match": { "home_plmns": ["23410"] },
|
||||
"ims": {
|
||||
"transport": "udp",
|
||||
"sms_center": "+447802000332"
|
||||
"sms_center": "+447802000332",
|
||||
"user_eq_phone": true
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -20,21 +20,30 @@ var (
|
||||
|
||||
const terminalCallRetention = 30 * time.Second
|
||||
|
||||
const (
|
||||
mmtelServiceURN = "urn:urn-7:3gpp-service.ims.icsi.mmtel"
|
||||
mmtelFeatureTag = "urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel"
|
||||
)
|
||||
|
||||
type imsCall struct {
|
||||
public vowifi.Call
|
||||
callID string
|
||||
target string
|
||||
from string
|
||||
to string
|
||||
branch string
|
||||
cseq uint32
|
||||
invite *sipRequest
|
||||
respond func([]byte) error
|
||||
responses chan *sipResponse
|
||||
remoteTag string
|
||||
routes []string
|
||||
terminated bool
|
||||
media *rtpMedia
|
||||
public vowifi.Call
|
||||
callID string
|
||||
target string
|
||||
from string
|
||||
to string
|
||||
branch string
|
||||
cseq uint32
|
||||
inviteTarget string
|
||||
invite *sipRequest
|
||||
respond func([]byte) error
|
||||
responses chan *sipResponse
|
||||
remoteTag string
|
||||
routes []string
|
||||
terminated bool
|
||||
media *rtpMedia
|
||||
pracked map[string]bool
|
||||
sessionExpires int
|
||||
sessionCancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (session *Session) Calls() []vowifi.Call {
|
||||
@@ -67,12 +76,14 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
||||
return vowifi.Call{}, err
|
||||
}
|
||||
callID := callToken + "@" + addressHost(session.conn.LocalAddr())
|
||||
target := "tel:" + number
|
||||
carrierProfile := vowifi.ResolveCarrierProfile(session.request.Identity)
|
||||
target := callTargetURI(number, session.identity.domain, carrierProfile)
|
||||
session.mu.Lock()
|
||||
cseq := session.cseq
|
||||
session.cseq++
|
||||
routes := append([]string(nil), session.evidence.ServiceRoute...)
|
||||
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
|
||||
fromIdentity, preferredIdentity, identitySource := session.callOriginatingIdentitiesLocked(carrierProfile)
|
||||
session.mu.Unlock()
|
||||
media, err := newRTPMedia(session.localMediaIP())
|
||||
if err != nil {
|
||||
@@ -80,7 +91,7 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
||||
}
|
||||
body := media.offerSDP(session.localMediaIP())
|
||||
transportUpper := strings.ToUpper(session.transport)
|
||||
from := "<" + session.identity.public + ">;tag=" + session.fromTag
|
||||
from := "<" + fromIdentity + ">;tag=" + session.fromTag
|
||||
to := "<" + target + ">"
|
||||
lines := []string{
|
||||
"INVITE " + target + " SIP/2.0",
|
||||
@@ -100,10 +111,17 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
||||
"To: "+to,
|
||||
"Call-ID: "+callID,
|
||||
fmt.Sprintf("CSeq: %d INVITE", cseq),
|
||||
"Contact: <sip:"+session.identity.user+"@"+session.contactAddress()+";transport="+session.transport+">",
|
||||
"P-Preferred-Identity: <"+session.identity.public+">",
|
||||
"Allow: INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE",
|
||||
"Supported: timer",
|
||||
session.dialogContactHeader(),
|
||||
"P-Preferred-Identity: <"+preferredIdentity+">",
|
||||
"P-Preferred-Service: "+mmtelServiceURN,
|
||||
`Accept-Contact: *;+g.3gpp.icsi-ref="`+mmtelFeatureTag+`"`,
|
||||
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
|
||||
"User-Agent: "+session.callUserAgent(),
|
||||
"Allow: INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE, PRACK, UPDATE, INFO",
|
||||
"Supported: 100rel, timer, replaces",
|
||||
"Session-Expires: 1800;refresher=uac",
|
||||
"Min-SE: 90",
|
||||
"Accept: application/sdp",
|
||||
"Content-Type: application/sdp",
|
||||
"Content-Length: "+strconv.Itoa(len(body)), "", "",
|
||||
)
|
||||
@@ -120,12 +138,19 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
||||
session.transactionsMu.Unlock()
|
||||
call := &imsCall{
|
||||
public: vowifi.Call{ID: callID, Number: number, Direction: "outgoing", State: "dialing", StartedAt: time.Now().UTC()},
|
||||
callID: callID, target: target, from: from, to: to, branch: branch, cseq: cseq, responses: responses,
|
||||
routes: routes, media: media,
|
||||
callID: callID, target: target, inviteTarget: target, from: from, to: to, branch: branch, cseq: cseq, responses: responses,
|
||||
routes: routes, media: media, pracked: make(map[string]bool),
|
||||
}
|
||||
session.callMu.Lock()
|
||||
session.calls[callID] = call
|
||||
session.callMu.Unlock()
|
||||
if session.provider != nil && session.provider.config.Logger != nil {
|
||||
session.provider.config.Logger.Info("IMS call started",
|
||||
"direction", "outgoing",
|
||||
"identity_source", identitySource,
|
||||
"target_scheme", strings.ToLower(strings.TrimSuffix(strings.SplitN(target, ":", 2)[0], ":")),
|
||||
)
|
||||
}
|
||||
session.writeMu.Lock()
|
||||
_, err = session.conn.Write(request)
|
||||
session.writeMu.Unlock()
|
||||
@@ -166,24 +191,26 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
|
||||
if response == nil {
|
||||
continue
|
||||
}
|
||||
diagnostic := callResponseDiagnostic(response)
|
||||
session.logCallResponse(response, diagnostic)
|
||||
if response.StatusCode < 200 {
|
||||
session.setCallDiagnostic(call.callID, response.StatusCode, response.Reason)
|
||||
if response.StatusCode >= 180 {
|
||||
session.setCallDiagnostic(call.callID, response.StatusCode, diagnostic)
|
||||
session.updateCallDialogFromResponse(call, response)
|
||||
if len(response.Body) > 0 {
|
||||
if mediaErr := call.media.configureRemote(response.Body); mediaErr == nil {
|
||||
session.setCallMediaReady(call.callID)
|
||||
session.setCallState(call.callID, "early_media")
|
||||
}
|
||||
} else if response.StatusCode >= 180 {
|
||||
session.setCallState(call.callID, "ringing")
|
||||
}
|
||||
if reliableProvisional(response) {
|
||||
go session.sendPRACK(call, response)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
||||
session.callMu.Lock()
|
||||
call.to = response.value("To")
|
||||
call.remoteTag = headerParameter(call.to, "tag")
|
||||
if contact := headerURI(response.value("Contact")); contact != "" {
|
||||
call.target = contact
|
||||
}
|
||||
if recordRoutes := response.values("Record-Route"); len(recordRoutes) > 0 {
|
||||
call.routes = reverseStrings(recordRoutes)
|
||||
}
|
||||
session.callMu.Unlock()
|
||||
session.updateCallDialogFromResponse(call, response)
|
||||
mediaErr := call.media.configureRemote(response.Body)
|
||||
_ = session.sendACK(call)
|
||||
if mediaErr != nil {
|
||||
@@ -197,13 +224,23 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
|
||||
}
|
||||
session.setCallMediaReady(call.callID)
|
||||
session.setCallState(call.callID, "active")
|
||||
} else if session.callWasTerminated(call.callID) {
|
||||
// CANCEL normally causes the pending INVITE transaction to finish
|
||||
// with 487 Request Terminated. It is the expected response to our
|
||||
// local hang-up, not a new network rejection.
|
||||
session.finishCall(call.callID, "ended", response.StatusCode, response.Reason)
|
||||
session.startSessionTimer(call, response.value("Session-Expires"))
|
||||
} else {
|
||||
session.finishCall(call.callID, "failed", response.StatusCode, response.Reason)
|
||||
if ackErr := session.sendRejectedInviteACK(call, response); ackErr != nil && session.provider != nil && session.provider.config.Logger != nil {
|
||||
session.provider.config.Logger.Warn("IMS rejected INVITE ACK failed",
|
||||
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
|
||||
"sip_status", response.StatusCode,
|
||||
"error", safeSIPDiagnostic(ackErr.Error()),
|
||||
)
|
||||
}
|
||||
if session.callWasTerminated(call.callID) {
|
||||
// CANCEL normally causes the pending INVITE transaction to finish
|
||||
// with 487 Request Terminated. It is the expected response to our
|
||||
// local hang-up, not a new network rejection.
|
||||
session.finishCall(call.callID, "ended", response.StatusCode, diagnostic)
|
||||
} else {
|
||||
session.finishCall(call.callID, "failed", response.StatusCode, diagnostic)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -223,7 +260,13 @@ func (session *Session) AnswerCall(_ context.Context, id string) (vowifi.Call, e
|
||||
}
|
||||
request, respond := call.invite, call.respond
|
||||
session.callMu.Unlock()
|
||||
response, err := buildSIPResponseWithBody(request, 200, session.fromTag, call.media.answerSDP(session.localMediaIP()))
|
||||
response, err := buildSIPResponseWithBody(
|
||||
request,
|
||||
200,
|
||||
session.fromTag,
|
||||
call.media.answerSDP(session.localMediaIP()),
|
||||
session.dialogContactHeader(),
|
||||
)
|
||||
if err != nil {
|
||||
return vowifi.Call{}, err
|
||||
}
|
||||
@@ -231,6 +274,7 @@ func (session *Session) AnswerCall(_ context.Context, id string) (vowifi.Call, e
|
||||
return vowifi.Call{}, err
|
||||
}
|
||||
session.setCallState(id, "active")
|
||||
session.startSessionTimer(call, request.value("Session-Expires"))
|
||||
if call.media.ready() {
|
||||
session.setCallMediaReady(id)
|
||||
}
|
||||
@@ -264,7 +308,7 @@ func (session *Session) HangupCall(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
method := "BYE"
|
||||
if direction == "outgoing" && (state == "dialing" || state == "ringing") {
|
||||
if direction == "outgoing" && (state == "dialing" || state == "ringing" || state == "early_media") {
|
||||
method = "CANCEL"
|
||||
}
|
||||
err := session.sendDialogRequest(ctx, call, method)
|
||||
@@ -288,6 +332,12 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
||||
if callID == "" {
|
||||
return true
|
||||
}
|
||||
session.callMu.Lock()
|
||||
existing := session.calls[callID]
|
||||
session.callMu.Unlock()
|
||||
if existing != nil && existing.public.State == "active" {
|
||||
return session.handleDialogOffer(request, respond, existing)
|
||||
}
|
||||
number := identityNumber(request.value("From"))
|
||||
target := headerURI(request.value("Contact"))
|
||||
if target == "" {
|
||||
@@ -311,8 +361,9 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
||||
}
|
||||
call := &imsCall{
|
||||
public: vowifi.Call{ID: callID, Number: number, Direction: "incoming", State: "ringing", StartedAt: time.Now().UTC()},
|
||||
callID: callID, target: target, from: request.value("To") + ";tag=" + session.fromTag,
|
||||
callID: callID, target: target, inviteTarget: request.URI, from: request.value("To") + ";tag=" + session.fromTag,
|
||||
to: request.value("From"), invite: request, respond: respond, routes: request.values("Record-Route"), media: media,
|
||||
pracked: make(map[string]bool),
|
||||
}
|
||||
session.callMu.Lock()
|
||||
session.calls[callID] = call
|
||||
@@ -322,6 +373,21 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
||||
_ = respond(response)
|
||||
}
|
||||
return true
|
||||
case "PRACK":
|
||||
response, err := buildSIPResponseWithBody(request, 200, session.fromTag, nil)
|
||||
if err == nil {
|
||||
_ = respond(response)
|
||||
}
|
||||
return true
|
||||
case "UPDATE":
|
||||
callID := strings.TrimSpace(request.value("Call-ID"))
|
||||
session.callMu.Lock()
|
||||
call := session.calls[callID]
|
||||
session.callMu.Unlock()
|
||||
if call == nil {
|
||||
return false
|
||||
}
|
||||
return session.handleDialogOffer(request, respond, call)
|
||||
case "ACK":
|
||||
callID := strings.TrimSpace(request.value("Call-ID"))
|
||||
session.callMu.Lock()
|
||||
@@ -366,9 +432,199 @@ func (session *Session) sendACK(call *imsCall) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// sendRejectedInviteACK acknowledges a non-2xx final response using the
|
||||
// original INVITE transaction branch and request URI. Unlike a 2xx ACK this is
|
||||
// part of the INVITE transaction; sending a dialog-style ACK with a new branch
|
||||
// leaves the P-CSCF retransmitting the rejection and leaking transaction state.
|
||||
func (session *Session) sendRejectedInviteACK(call *imsCall, response *sipResponse) error {
|
||||
if call == nil || response == nil || response.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
if session == nil || session.conn == nil {
|
||||
return errors.New("ims: SIP connection unavailable for rejected INVITE ACK")
|
||||
}
|
||||
target := call.inviteTarget
|
||||
if target == "" {
|
||||
target = call.target
|
||||
}
|
||||
to := strings.TrimSpace(response.value("To"))
|
||||
if to == "" {
|
||||
to = call.to
|
||||
}
|
||||
lines := []string{
|
||||
"ACK " + target + " SIP/2.0",
|
||||
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", strings.ToUpper(session.transport), session.conn.LocalAddr().String(), call.branch),
|
||||
"Max-Forwards: 70",
|
||||
}
|
||||
session.mu.Lock()
|
||||
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
|
||||
session.mu.Unlock()
|
||||
lines = append(lines, securityHeaders...)
|
||||
for _, route := range call.routes {
|
||||
lines = append(lines, "Route: "+route)
|
||||
}
|
||||
lines = append(lines,
|
||||
"From: "+call.from,
|
||||
"To: "+to,
|
||||
"Call-ID: "+call.callID,
|
||||
fmt.Sprintf("CSeq: %d ACK", call.cseq),
|
||||
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
|
||||
"User-Agent: "+session.callUserAgent(),
|
||||
"Content-Length: 0", "", "",
|
||||
)
|
||||
session.writeMu.Lock()
|
||||
_, err := session.conn.Write([]byte(strings.Join(lines, "\r\n")))
|
||||
session.writeMu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
func reliableProvisional(response *sipResponse) bool {
|
||||
if response == nil || strings.TrimSpace(response.value("RSeq")) == "" {
|
||||
return false
|
||||
}
|
||||
for _, token := range strings.Split(strings.ToLower(response.value("Require")), ",") {
|
||||
if strings.TrimSpace(token) == "100rel" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (session *Session) updateCallDialogFromResponse(call *imsCall, response *sipResponse) {
|
||||
if call == nil || response == nil {
|
||||
return
|
||||
}
|
||||
session.callMu.Lock()
|
||||
defer session.callMu.Unlock()
|
||||
call.to = response.value("To")
|
||||
call.remoteTag = headerParameter(call.to, "tag")
|
||||
if contact := headerURI(response.value("Contact")); contact != "" {
|
||||
call.target = contact
|
||||
}
|
||||
if recordRoutes := response.values("Record-Route"); len(recordRoutes) > 0 {
|
||||
call.routes = reverseStrings(recordRoutes)
|
||||
}
|
||||
}
|
||||
|
||||
func (session *Session) sendPRACK(call *imsCall, response *sipResponse) {
|
||||
rseq := strings.TrimSpace(response.value("RSeq"))
|
||||
inviteCSeq := strings.TrimSpace(response.value("CSeq"))
|
||||
if rseq == "" || inviteCSeq == "" {
|
||||
return
|
||||
}
|
||||
key := rseq + "|" + inviteCSeq
|
||||
session.callMu.Lock()
|
||||
if call.pracked == nil {
|
||||
call.pracked = make(map[string]bool)
|
||||
}
|
||||
if call.pracked[key] || call.public.EndedAt != nil {
|
||||
session.callMu.Unlock()
|
||||
return
|
||||
}
|
||||
call.pracked[key] = true
|
||||
target, to, from := call.target, call.to, call.from
|
||||
routes := append([]string(nil), call.routes...)
|
||||
session.callMu.Unlock()
|
||||
|
||||
session.mu.Lock()
|
||||
cseq := session.cseq
|
||||
session.cseq++
|
||||
session.mu.Unlock()
|
||||
branch, _ := randomHex(12)
|
||||
lines := []string{
|
||||
"PRACK " + target + " SIP/2.0",
|
||||
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", strings.ToUpper(session.transport), session.conn.LocalAddr().String(), branch),
|
||||
"Max-Forwards: 70",
|
||||
}
|
||||
session.mu.Lock()
|
||||
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
|
||||
session.mu.Unlock()
|
||||
lines = append(lines, securityHeaders...)
|
||||
for _, route := range routes {
|
||||
lines = append(lines, "Route: "+route)
|
||||
}
|
||||
lines = append(lines,
|
||||
"From: "+from, "To: "+to, "Call-ID: "+call.callID,
|
||||
fmt.Sprintf("CSeq: %d PRACK", cseq), "RAck: "+rseq+" "+inviteCSeq,
|
||||
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
|
||||
"User-Agent: "+session.callUserAgent(),
|
||||
"Content-Length: 0", "", "",
|
||||
)
|
||||
ctx, cancel := context.WithTimeout(session.refreshContext, 10*time.Second)
|
||||
defer cancel()
|
||||
result, err := session.exchangeRuntime(ctx, []byte(strings.Join(lines, "\r\n")), sipTransactionKey{callID: call.callID, cseq: cseq, method: "PRACK"})
|
||||
if err != nil || result.StatusCode < 200 || result.StatusCode >= 300 {
|
||||
reason := "reliable provisional response could not be acknowledged"
|
||||
if err != nil {
|
||||
reason = err.Error()
|
||||
}
|
||||
session.finishCall(call.callID, "failed", 0, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func (session *Session) handleDialogOffer(request *sipRequest, respond func([]byte) error, call *imsCall) bool {
|
||||
var body []byte
|
||||
if len(request.Body) > 0 {
|
||||
if err := call.media.configureRemote(request.Body); err != nil {
|
||||
if response, buildErr := buildSIPResponseWithBody(request, 488, session.fromTag, nil); buildErr == nil {
|
||||
_ = respond(response)
|
||||
}
|
||||
return true
|
||||
}
|
||||
body = call.media.answerSDP(session.localMediaIP())
|
||||
session.setCallMediaReady(call.callID)
|
||||
}
|
||||
extraHeaders := []string(nil)
|
||||
if request.Method == "INVITE" {
|
||||
extraHeaders = append(extraHeaders, session.dialogContactHeader())
|
||||
}
|
||||
response, err := buildSIPResponseWithBody(request, 200, session.fromTag, body, extraHeaders...)
|
||||
if err == nil {
|
||||
_ = respond(response)
|
||||
session.startSessionTimer(call, request.value("Session-Expires"))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (session *Session) startSessionTimer(call *imsCall, header string) {
|
||||
value := strings.TrimSpace(strings.Split(header, ";")[0])
|
||||
seconds, err := strconv.Atoi(value)
|
||||
if err != nil || seconds < 90 || seconds > 86400 || call == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(session.refreshContext)
|
||||
session.callMu.Lock()
|
||||
if call.sessionCancel != nil {
|
||||
call.sessionCancel()
|
||||
}
|
||||
call.sessionExpires = seconds
|
||||
call.sessionCancel = cancel
|
||||
session.callMu.Unlock()
|
||||
go func() {
|
||||
interval := time.Duration(seconds) * time.Second / 2
|
||||
timer := time.NewTimer(interval)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
refreshContext, cancelRefresh := context.WithTimeout(ctx, 15*time.Second)
|
||||
refreshErr := session.sendDialogRequest(refreshContext, call, "UPDATE")
|
||||
cancelRefresh()
|
||||
if refreshErr != nil {
|
||||
session.finishCall(call.callID, "failed", 0, "SIP session refresh failed")
|
||||
return
|
||||
}
|
||||
timer.Reset(interval)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (session *Session) sendDialogRequest(ctx context.Context, call *imsCall, method string) error {
|
||||
cseq := call.cseq
|
||||
if method == "BYE" {
|
||||
if method == "BYE" || method == "UPDATE" {
|
||||
session.mu.Lock()
|
||||
cseq = session.cseq
|
||||
session.cseq++
|
||||
@@ -396,15 +652,23 @@ func (session *Session) buildDialogRequest(call *imsCall, method string, cseq ui
|
||||
if method == "CANCEL" {
|
||||
branch = call.branch
|
||||
}
|
||||
target := call.target
|
||||
if method == "CANCEL" && call.inviteTarget != "" {
|
||||
target = call.inviteTarget
|
||||
}
|
||||
to := call.to
|
||||
if to == "" {
|
||||
to = "<" + call.target + ">"
|
||||
}
|
||||
lines := []string{
|
||||
method + " " + call.target + " SIP/2.0",
|
||||
method + " " + target + " SIP/2.0",
|
||||
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", strings.ToUpper(session.transport), session.conn.LocalAddr().String(), branch),
|
||||
"Max-Forwards: 70",
|
||||
}
|
||||
session.mu.Lock()
|
||||
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
|
||||
session.mu.Unlock()
|
||||
lines = append(lines, securityHeaders...)
|
||||
for _, route := range call.routes {
|
||||
lines = append(lines, "Route: "+route)
|
||||
}
|
||||
@@ -413,8 +677,19 @@ func (session *Session) buildDialogRequest(call *imsCall, method string, cseq ui
|
||||
"To: "+to,
|
||||
"Call-ID: "+call.callID,
|
||||
fmt.Sprintf("CSeq: %d %s", cseq, method),
|
||||
"Content-Length: 0", "", "",
|
||||
"Supported: 100rel, timer",
|
||||
"User-Agent: "+session.callUserAgent(),
|
||||
)
|
||||
if method != "CANCEL" {
|
||||
lines = append(lines, "P-Access-Network-Info: "+session.pAccessNetworkInfo())
|
||||
}
|
||||
if method == "UPDATE" {
|
||||
lines = append(lines, session.dialogContactHeader())
|
||||
}
|
||||
lines = append(lines, "Content-Length: 0", "", "")
|
||||
if method == "UPDATE" && call.sessionExpires > 0 {
|
||||
lines = append(lines[:len(lines)-3], fmt.Sprintf("Session-Expires: %d;refresher=uac", call.sessionExpires), "Content-Length: 0", "", "")
|
||||
}
|
||||
return []byte(strings.Join(lines, "\r\n"))
|
||||
}
|
||||
|
||||
@@ -426,7 +701,7 @@ func (session *Session) localMediaIP() net.IP {
|
||||
return addressIP(localAddress)
|
||||
}
|
||||
|
||||
func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body []byte) ([]byte, error) {
|
||||
func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body []byte, extraHeaders ...string) ([]byte, error) {
|
||||
reasons := map[int]string{180: "Ringing", 200: "OK", 486: "Busy Here", 487: "Request Terminated", 488: "Not Acceptable Here"}
|
||||
reason := reasons[status]
|
||||
if reason == "" {
|
||||
@@ -446,6 +721,14 @@ func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body
|
||||
lines = append(lines, "Via: "+value)
|
||||
}
|
||||
lines = append(lines, "From: "+from, "To: "+to, "Call-ID: "+callID, "CSeq: "+cseq)
|
||||
for _, header := range extraHeaders {
|
||||
if strings.TrimSpace(header) != "" {
|
||||
lines = append(lines, header)
|
||||
}
|
||||
}
|
||||
if value := strings.TrimSpace(request.value("Session-Expires")); value != "" && status >= 200 && status < 300 && (request.Method == "INVITE" || request.Method == "UPDATE") {
|
||||
lines = append(lines, "Supported: timer", "Session-Expires: "+value)
|
||||
}
|
||||
if len(body) > 0 {
|
||||
lines = append(lines, "Content-Type: application/sdp")
|
||||
}
|
||||
@@ -453,10 +736,110 @@ func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body
|
||||
return append([]byte(strings.Join(lines, "\r\n")), body...), nil
|
||||
}
|
||||
|
||||
func (session *Session) dialogContactHeader() string {
|
||||
if session == nil || session.conn == nil || strings.TrimSpace(session.identity.user) == "" {
|
||||
return ""
|
||||
}
|
||||
contact := "Contact: <sip:" + session.identity.user + "@" + session.contactAddress() + ";transport=" + session.transport + ">"
|
||||
if strings.TrimSpace(session.instanceID) != "" {
|
||||
contact += `;+sip.instance="<` + session.instanceID + `>"`
|
||||
}
|
||||
return contact + `;audio;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"`
|
||||
}
|
||||
|
||||
func callTargetURI(number, domain string, profile vowifi.CarrierProfile) string {
|
||||
domain = strings.TrimSpace(domain)
|
||||
if profile.IMSDialURIScheme == "sip" {
|
||||
target := "sip:" + number + "@" + domain
|
||||
if profile.IMSUserEqPhone {
|
||||
target += ";user=phone"
|
||||
}
|
||||
return target
|
||||
}
|
||||
if strings.HasPrefix(number, "+") {
|
||||
return "tel:" + number
|
||||
}
|
||||
return "tel:" + number + ";phone-context=" + domain
|
||||
}
|
||||
|
||||
// callOriginatingIdentitiesLocked selects only a number that IMS explicitly
|
||||
// associated with this registration. 3GPP originating sessions use that
|
||||
// public identity in both From and P-Preferred-Identity; some TAS deployments
|
||||
// accept an IMSI IMPU at the P-CSCF and then terminate the session immediately.
|
||||
// The fallback deliberately remains the registered IMPU and never derives a
|
||||
// telephone number from IMSI digits.
|
||||
func (session *Session) callOriginatingIdentitiesLocked(profile vowifi.CarrierProfile) (from, preferred, source string) {
|
||||
if number, numberSource, ok := vowifi.ExtractAssociatedMSISDN(session.evidence); ok {
|
||||
from = "sip:" + number + "@" + session.identity.domain
|
||||
if profile.IMSUserEqPhone {
|
||||
from += ";user=phone"
|
||||
}
|
||||
return from, "tel:" + number, numberSource
|
||||
}
|
||||
return session.identity.public, session.identity.public, "registered_impu"
|
||||
}
|
||||
|
||||
func (session *Session) pAccessNetworkInfo() string {
|
||||
profile := vowifi.ResolveCarrierProfile(session.request.Identity)
|
||||
node := strings.TrimSpace(profile.PANINode)
|
||||
if node == "" {
|
||||
node = "000000000000"
|
||||
}
|
||||
value := "IEEE-802.11;i-wlan-node-id=" + node
|
||||
if country := strings.ToUpper(strings.TrimSpace(profile.PANICountry)); country != "" {
|
||||
value += ";country=" + country
|
||||
}
|
||||
return value + ";network-provided"
|
||||
}
|
||||
|
||||
func (session *Session) callUserAgent() string {
|
||||
if session != nil && session.provider != nil {
|
||||
if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return "vocat/1"
|
||||
}
|
||||
|
||||
func callResponseDiagnostic(response *sipResponse) string {
|
||||
if response == nil {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, 3)
|
||||
if reason := safeSIPDiagnostic(response.Reason); reason != "" {
|
||||
parts = append(parts, reason)
|
||||
}
|
||||
for _, name := range []string{"Reason", "Warning"} {
|
||||
for _, value := range response.values(name) {
|
||||
if value = safeSIPDiagnostic(value); value != "" {
|
||||
parts = append(parts, name+": "+value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return safeSIPDiagnostic(strings.Join(parts, "; "))
|
||||
}
|
||||
|
||||
func (session *Session) logCallResponse(response *sipResponse, diagnostic string) {
|
||||
if session == nil || session.provider == nil || session.provider.config.Logger == nil || response == nil {
|
||||
return
|
||||
}
|
||||
session.provider.config.Logger.Info("IMS call response",
|
||||
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
|
||||
"sip_status", response.StatusCode,
|
||||
"diagnostic", diagnostic,
|
||||
"content_type", safeSIPDiagnostic(response.value("Content-Type")),
|
||||
"body_bytes", len(response.Body),
|
||||
)
|
||||
}
|
||||
|
||||
func (session *Session) setCallState(id, state string) {
|
||||
session.callMu.Lock()
|
||||
if call := session.calls[id]; call != nil {
|
||||
call.public.State = state
|
||||
if state == "active" && call.public.AnsweredAt == nil {
|
||||
now := time.Now().UTC()
|
||||
call.public.AnsweredAt = &now
|
||||
}
|
||||
if state != "ended" && state != "failed" {
|
||||
call.public.EndedAt = nil
|
||||
}
|
||||
@@ -509,6 +892,10 @@ func (session *Session) finishCall(id, state string, code int, reason string) {
|
||||
call.public.Reason = reason
|
||||
}
|
||||
call.public.EndedAt = &now
|
||||
if call.sessionCancel != nil {
|
||||
call.sessionCancel()
|
||||
call.sessionCancel = nil
|
||||
}
|
||||
}
|
||||
session.callMu.Unlock()
|
||||
if media != nil {
|
||||
|
||||
@@ -2,6 +2,8 @@ package ims
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -10,7 +12,13 @@ import (
|
||||
)
|
||||
|
||||
func TestIncomingCallCanRingAndAnswerWithMediaOffer(t *testing.T) {
|
||||
session := &Session{fromTag: "local-tag", calls: make(map[string]*imsCall)}
|
||||
client, peer := net.Pipe()
|
||||
defer client.Close()
|
||||
defer peer.Close()
|
||||
session := &Session{
|
||||
fromTag: "local-tag", calls: make(map[string]*imsCall), conn: client,
|
||||
identity: identitySet{user: "subscriber"}, transport: "udp",
|
||||
}
|
||||
packet, err := parseSIPPacket([]byte(strings.Join([]string{
|
||||
"INVITE sip:[email protected] SIP/2.0",
|
||||
"Via: SIP/2.0/UDP 192.0.2.10:5060;branch=z9hG4bK-incoming",
|
||||
@@ -42,6 +50,9 @@ func TestIncomingCallCanRingAndAnswerWithMediaOffer(t *testing.T) {
|
||||
if answered.State != "active" || len(responses) != 2 || !strings.Contains(string(responses[1]), "a=sendrecv") {
|
||||
t.Fatalf("answered = %#v, response = %q", answered, responses[1])
|
||||
}
|
||||
if !strings.Contains(string(responses[1]), "Contact: <sip:subscriber@pipe;transport=udp>") {
|
||||
t.Fatalf("answer omitted dialog Contact: %q", responses[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncomingCallCanBeRejected(t *testing.T) {
|
||||
@@ -116,3 +127,155 @@ func TestValidCallNumber(t *testing.T) {
|
||||
t.Fatal("call number validation mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) {
|
||||
client, peer := net.Pipe()
|
||||
defer peer.Close()
|
||||
refreshContext, cancelRefresh := context.WithCancel(context.Background())
|
||||
defer cancelRefresh()
|
||||
session := &Session{
|
||||
provider: &Provider{config: Config{
|
||||
UserAgent: "VoCat Test",
|
||||
SecurityMode: SecurityDisabled,
|
||||
}},
|
||||
request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{
|
||||
HomeMCC: "234", HomeMNC: "33", IMSI: "234330000000001", ICCID: "8944300000000000000",
|
||||
}},
|
||||
identity: identitySet{
|
||||
domain: "ims.mnc033.mcc234.3gppnetwork.org",
|
||||
public: "sip:[email protected]",
|
||||
user: "234330000000001",
|
||||
},
|
||||
endpoint: pcscfEndpoint{host: "pcscf.test", port: 5060},
|
||||
transport: "tcp",
|
||||
conn: client,
|
||||
fromTag: "local-tag",
|
||||
instanceID: "urn:uuid:00000000-0000-4000-8000-000000000001",
|
||||
cseq: 1,
|
||||
transactions: make(map[sipTransactionKey]chan *sipResponse),
|
||||
calls: make(map[string]*imsCall),
|
||||
refreshContext: refreshContext,
|
||||
evidence: vowifi.IMSEvidence{
|
||||
PAssociatedURI: []string{"<tel:+447700900123>"},
|
||||
ServiceRoute: []string{"<sip:route.ims.test;lr>"},
|
||||
},
|
||||
}
|
||||
wireResult := make(chan string, 1)
|
||||
go func() {
|
||||
packet, _ := io.ReadAll(peer)
|
||||
wireResult <- string(packet)
|
||||
}()
|
||||
|
||||
call, err := session.DialCall(context.Background(), "888")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session.finishCall(call.ID, "ended", 0, "test complete")
|
||||
cancelRefresh()
|
||||
_ = client.Close()
|
||||
wire := <-wireResult
|
||||
|
||||
for _, expected := range []string{
|
||||
"INVITE sip:[email protected] SIP/2.0\r\n",
|
||||
"To: <sip:[email protected]>\r\n",
|
||||
"From: <sip:[email protected]>;tag=local-tag\r\n",
|
||||
"P-Preferred-Identity: <tel:+447700900123>\r\n",
|
||||
"P-Preferred-Service: " + mmtelServiceURN + "\r\n",
|
||||
`Accept-Contact: *;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + "\r\n",
|
||||
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;country=GB;network-provided\r\n",
|
||||
"User-Agent: VoCat Test\r\n",
|
||||
"Accept: application/sdp\r\n",
|
||||
} {
|
||||
if !strings.Contains(wire, expected) {
|
||||
t.Fatalf("INVITE omitted %q:\n%s", expected, wire)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallOriginatingIdentitiesFallBackToRegisteredIMPU(t *testing.T) {
|
||||
session := &Session{
|
||||
identity: identitySet{
|
||||
domain: "ims.mnc033.mcc234.3gppnetwork.org",
|
||||
public: "sip:[email protected]",
|
||||
},
|
||||
}
|
||||
from, preferred, source := session.callOriginatingIdentitiesLocked(vowifi.CarrierProfile{})
|
||||
if from != session.identity.public || preferred != session.identity.public || source != "registered_impu" {
|
||||
t.Fatalf("fallback identities = (%q, %q, %q)", from, preferred, source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallTargetURIUsesPhoneContextOnlyForLocalNumbers(t *testing.T) {
|
||||
domain := "ims.mnc033.mcc234.3gppnetwork.org"
|
||||
if got := callTargetURI("888", domain, vowifi.CarrierProfile{IMSDialURIScheme: "tel"}); got != "tel:888;phone-context="+domain {
|
||||
t.Fatalf("local target = %q", got)
|
||||
}
|
||||
if got := callTargetURI("+447700900123", domain, vowifi.CarrierProfile{IMSDialURIScheme: "tel"}); got != "tel:+447700900123" {
|
||||
t.Fatalf("global target = %q", got)
|
||||
}
|
||||
if got := callTargetURI("888", domain, vowifi.CarrierProfile{IMSDialURIScheme: "sip"}); got != "sip:888@"+domain {
|
||||
t.Fatalf("SIP target = %q", got)
|
||||
}
|
||||
if got := callTargetURI("888", domain, vowifi.CarrierProfile{IMSDialURIScheme: "sip", IMSUserEqPhone: true}); got != "sip:888@"+domain+";user=phone" {
|
||||
t.Fatalf("SIP user=phone target = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallResponseDiagnosticIncludesNetworkReason(t *testing.T) {
|
||||
response := &sipResponse{
|
||||
StatusCode: 487,
|
||||
Reason: "Request Terminated",
|
||||
Headers: map[string][]string{
|
||||
"reason": {`Q.850;cause=31;text="Normal, unspecified"`},
|
||||
},
|
||||
}
|
||||
want := `Request Terminated; Reason: Q.850;cause=31;text="Normal, unspecified"`
|
||||
if got := callResponseDiagnostic(response); got != want {
|
||||
t.Fatalf("diagnostic = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectedInviteACKUsesOriginalTransaction(t *testing.T) {
|
||||
client, peer := net.Pipe()
|
||||
defer client.Close()
|
||||
defer peer.Close()
|
||||
session := &Session{
|
||||
provider: &Provider{config: Config{UserAgent: "VoCat Test"}},
|
||||
request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "33"}},
|
||||
identity: identitySet{domain: "ims.mnc033.mcc234.3gppnetwork.org"},
|
||||
transport: "tcp",
|
||||
conn: client,
|
||||
}
|
||||
call := &imsCall{
|
||||
callID: "call-1",
|
||||
inviteTarget: "sip:[email protected]",
|
||||
from: "<sip:[email protected]>;tag=local",
|
||||
to: "<sip:[email protected]>",
|
||||
branch: "original-branch",
|
||||
cseq: 41,
|
||||
routes: []string{"<sip:pcscf.test;lr>"},
|
||||
}
|
||||
response := &sipResponse{StatusCode: 487, Headers: map[string][]string{
|
||||
"to": {"<sip:[email protected]>;tag=tas"},
|
||||
}}
|
||||
ackResult := make(chan string, 1)
|
||||
go func() {
|
||||
buffer := make([]byte, 4096)
|
||||
count, _ := peer.Read(buffer)
|
||||
ackResult <- string(buffer[:count])
|
||||
}()
|
||||
if err := session.sendRejectedInviteACK(call, response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ack := <-ackResult
|
||||
for _, expected := range []string{
|
||||
"ACK sip:[email protected] SIP/2.0\r\n",
|
||||
"branch=z9hG4bKoriginal-branch;rport",
|
||||
"To: <sip:[email protected]>;tag=tas\r\n",
|
||||
"CSeq: 41 ACK\r\n",
|
||||
} {
|
||||
if !strings.Contains(ack, expected) {
|
||||
t.Fatalf("rejected INVITE ACK omitted %q:\n%s", expected, ack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -783,7 +783,7 @@ func zeroBytes(value []byte) {
|
||||
}
|
||||
|
||||
func (session *Session) securityOffered() bool {
|
||||
return session.provider.config.SecurityMode != SecurityDisabled && !session.securityDeclined
|
||||
return session.provider != nil && session.provider.config.SecurityMode != SecurityDisabled && !session.securityDeclined
|
||||
}
|
||||
|
||||
func (session *Session) securityFromResponse(response *sipResponse) (securityAgreement, bool, error) {
|
||||
|
||||
@@ -405,6 +405,7 @@ type Call struct {
|
||||
Direction string `json:"direction"`
|
||||
State string `json:"state"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
AnsweredAt *time.Time `json:"answered_at,omitempty"`
|
||||
SIPCode int `json:"sip_code,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
MediaReady bool `json:"media_ready,omitempty"`
|
||||
|
||||
Reference in New Issue
Block a user