feat: add chapter eight call records

This commit is contained in:
richjohnrambo
2026-08-13 16:14:57 +08:00
parent 92d0066a02
commit dfcccbb761
9 changed files with 711 additions and 20 deletions
+146 -1
View File
@@ -12,9 +12,11 @@ import (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
jcr "github.com/tinode/jsonco"
)
@@ -45,6 +47,9 @@ const (
constCallMsgMissed = "missed"
// Call is declined (the callee hung up before picking up).
constCallMsgDeclined = "declined"
constCallTypeAudio = "audio"
constCallTypeVideo = "video"
)
type callConfig struct {
@@ -86,10 +91,127 @@ type videoCall struct {
content any
// Call message content mime type.
contentMime any
// Product-level call identifier.
callID string
// Product-level call type: audio or video.
callType string
// Time when the call was accepted.
acceptedAt time.Time
}
func normalizeCallInvite(head map[string]any, content any) (map[string]any, any, error) {
if head == nil {
return nil, nil, types.ErrMalformed
}
state, _ := head["webrtc"].(string)
if state != "started" {
return nil, nil, types.ErrMalformed
}
call, ok := content.(map[string]any)
if !ok {
if typed, typedOK := content.(types.KVMap); typedOK {
call = map[string]any(typed)
}
}
if len(call) == 0 {
return nil, nil, types.ErrMalformed
}
callID, _ := call["callId"].(string)
callType, _ := call["callType"].(string)
if callType == "" {
callType, _ = call["media_type"].(string)
}
callType = strings.ToLower(strings.TrimSpace(callType))
if strings.TrimSpace(callID) == "" || (callType != constCallTypeAudio && callType != constCallTypeVideo) {
return nil, nil, types.ErrMalformed
}
normalizedHead := make(map[string]any, len(head)+2)
for key, value := range head {
normalizedHead[key] = value
}
normalizedHead["webrtc"] = "started"
normalizedHead["x-im-type"] = "call"
if _, ok := normalizedHead["mime"]; !ok {
normalizedHead["mime"] = "application/json"
}
normalizedContent := make(map[string]any, len(call)+3)
for key, value := range call {
normalizedContent[key] = value
}
normalizedContent["callId"] = strings.TrimSpace(callID)
normalizedContent["callType"] = callType
normalizedContent["audio"] = true
normalizedContent["video"] = callType == constCallTypeVideo
return normalizedHead, normalizedContent, nil
}
func callPayloadString(payload any, key string) string {
data, ok := payload.(map[string]any)
if !ok {
if typed, typedOK := payload.(types.KVMap); typedOK {
data = map[string]any(typed)
}
}
if len(data) == 0 {
return ""
}
value, _ := data[key].(string)
return strings.TrimSpace(value)
}
func callContentString(content any, key string) string {
return callPayloadString(content, key)
}
func (call *videoCall) matchesPayload(payload any) bool {
payloadCallID := callPayloadString(payload, "callId")
return payloadCallID == "" || payloadCallID == call.callID
}
func (t *Topic) auditCallCreate(call *videoCall, caller types.Uid) {
if store.Calls == nil || call == nil || call.callID == "" || caller.IsZero() {
return
}
callee := t.p2pOtherUser(caller)
if callee.IsZero() {
return
}
now := types.TimeNow()
if err := store.Calls.Create(t.tenantID, &types.CallRecord{
TenantID: t.tenantID,
CallID: call.callID,
Topic: t.name,
SeqID: call.seq,
CallerID: caller,
CalleeID: callee,
CallType: call.callType,
Status: types.CallStatusStarted,
StartedAt: now,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
logs.Warn.Printf("topic[%s]: failed to audit call start %s: %v", t.name, call.callID, err)
}
}
func (t *Topic) auditCallAccept(call *videoCall, acceptedAt time.Time) {
if store.Calls == nil || call == nil || call.callID == "" {
return
}
if err := store.Calls.Accept(t.tenantID, call.callID, call.seq, acceptedAt); err != nil {
logs.Warn.Printf("topic[%s]: failed to audit call accept %s: %v", t.name, call.callID, err)
}
}
func (t *Topic) auditCallFinish(call *videoCall, status string, durationMS int64, finishedAt time.Time) {
if store.Calls == nil || call == nil || call.callID == "" {
return
}
if err := store.Calls.Finish(t.tenantID, call.callID, call.seq, status, durationMS, finishedAt); err != nil {
logs.Warn.Printf("topic[%s]: failed to audit call finish %s: %v", t.name, call.callID, err)
}
}
// callPartySession returns a session to be stored in the call party data.
func callPartySession(sess *Session) *Session {
if sess.isProxy() {
@@ -226,12 +348,15 @@ func (t *Topic) handleCallInvite(msg *ClientComMessage, asUid types.Uid) {
seq: t.lastID,
content: msg.Pub.Content,
contentMime: msg.Pub.Head["mime"],
callID: callContentString(msg.Pub.Content, "callId"),
callType: callContentString(msg.Pub.Content, "callType"),
}
t.currentCall.parties[msg.sess.sid] = callPartyData{
uid: asUid,
isOriginator: true,
sess: callPartySession(msg.sess),
}
t.auditCallCreate(t.currentCall, asUid)
// Wait for constCallEstablishmentTimeout for the other side to accept the call.
t.callEstablishmentTimer.Reset(time.Duration(globals.callEstablishmentTimeout) * time.Second)
}
@@ -255,6 +380,10 @@ func (t *Topic) handleCallEvent(msg *ClientComMessage) {
logs.Info.Printf("topic[%s]: invalid seq id - current call (%d) vs received (%d)", t.name, t.currentCall.seq, call.SeqId)
return
}
if !t.currentCall.matchesPayload(call.Payload) {
logs.Info.Printf("topic[%s]: invalid call id for seq %d", t.name, t.currentCall.seq)
return
}
asUid := types.ParseUserId(msg.AsUser)
@@ -307,6 +436,7 @@ func (t *Topic) handleCallEvent(msg *ClientComMessage) {
sess: callPartySession(msg.sess),
}
t.currentCall.acceptedAt = time.Now()
t.auditCallAccept(t.currentCall, t.currentCall.acceptedAt)
// Notify other clients that the call has been accepted.
t.infoCallSubsOffline(msg.AsUser, asUid, call.Event, t.currentCall.seq, call.Payload, msg.sess.sid, false)
@@ -382,10 +512,11 @@ func (t *Topic) maybeEndCallInProgress(from string, msg *ClientComMessage, callD
originatorUid, _ := t.getCallOriginator()
var replaceWith string
var callDuration int64
finishedAt := time.Now()
if from != "" && len(t.currentCall.parties) == 2 {
// This is a call in progress.
replaceWith = constCallMsgFinished
callDuration = time.Since(t.currentCall.acceptedAt).Milliseconds()
callDuration = finishedAt.Sub(t.currentCall.acceptedAt).Milliseconds()
} else {
if from != "" {
// User originated hang-up.
@@ -418,6 +549,7 @@ func (t *Topic) maybeEndCallInProgress(from string, msg *ClientComMessage, callD
if err := t.saveAndBroadcastMessage(&msgCopy, originatorUid, false, nil, head, t.currentCall.content); err != nil {
logs.Err.Printf("topic[%s]: failed to write finalizing message for call seq id %d - '%s'", t.name, t.currentCall.seq, err)
}
t.auditCallFinish(t.currentCall, callFinalStatus(replaceWith), callDuration, finishedAt)
// Send {info} hangup event to the subscribed sessions.
t.broadcastToSessions(t.currentCall.infoMessage(constCallEventHangUp))
@@ -429,6 +561,19 @@ func (t *Topic) maybeEndCallInProgress(from string, msg *ClientComMessage, callD
t.currentCall = nil
}
func callFinalStatus(replaceWith string) string {
switch replaceWith {
case constCallMsgFinished:
return types.CallStatusFinished
case constCallMsgMissed:
return types.CallStatusMissed
case constCallMsgDeclined:
return types.CallStatusDeclined
default:
return types.CallStatusDisconnected
}
}
// Server initiated call termination.
func (t *Topic) terminateCallInProgress(callDidTimeout bool) {
if t.currentCall == nil {