fix: expire stale call pushes
This commit is contained in:
+3
-1
@@ -37,6 +37,8 @@ const (
|
|||||||
constCallEventHangUp = "hang-up"
|
constCallEventHangUp = "hang-up"
|
||||||
|
|
||||||
// Message headers representing call states.
|
// Message headers representing call states.
|
||||||
|
// Call is ringing.
|
||||||
|
constCallMsgStarted = "started"
|
||||||
// Call is established.
|
// Call is established.
|
||||||
constCallMsgAccepted = "accepted"
|
constCallMsgAccepted = "accepted"
|
||||||
// Previously establied call has successfully finished.
|
// Previously establied call has successfully finished.
|
||||||
@@ -149,7 +151,7 @@ func normalizeCallInvite(head map[string]any, content any) (map[string]any, any,
|
|||||||
for key, value := range head {
|
for key, value := range head {
|
||||||
normalizedHead[key] = value
|
normalizedHead[key] = value
|
||||||
}
|
}
|
||||||
normalizedHead["webrtc"] = "started"
|
normalizedHead["webrtc"] = constCallMsgStarted
|
||||||
normalizedHead["x-im-type"] = "call"
|
normalizedHead["x-im-type"] = "call"
|
||||||
if _, ok := normalizedHead["mime"]; !ok {
|
if _, ok := normalizedHead["mime"]; !ok {
|
||||||
normalizedHead["mime"] = "application/json"
|
normalizedHead["mime"] = "application/json"
|
||||||
|
|||||||
@@ -2791,7 +2791,11 @@ func (a *adapter) TenantCallList(id t.TenantID, uid t.Uid, topic, cursor string,
|
|||||||
|
|
||||||
func (a *adapter) finalizeStaleCalls(ctx context.Context) error {
|
func (a *adapter) finalizeStaleCalls(ctx context.Context) error {
|
||||||
now := t.TimeNow()
|
now := t.TimeNow()
|
||||||
startedBefore := now.Add(-2 * time.Minute)
|
startedTimeout := imCallTimeoutSeconds()
|
||||||
|
if startedTimeout <= 0 {
|
||||||
|
startedTimeout = defaultCallEstablishmentTimeout
|
||||||
|
}
|
||||||
|
startedBefore := now.Add(-time.Duration(startedTimeout) * time.Second)
|
||||||
acceptedBefore := now.Add(-12 * time.Hour)
|
acceptedBefore := now.Add(-12 * time.Hour)
|
||||||
if _, err := a.db.ExecContext(ctx, `UPDATE im_call_records
|
if _, err := a.db.ExecContext(ctx, `UPDATE im_call_records
|
||||||
SET status=?,end_reason=?,duration_ms=0,finished_at=?,updated_at=?
|
SET status=?,end_reason=?,duration_ms=0,finished_at=?,updated_at=?
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ func (t *Topic) pushForData(fromUid types.Uid, data *MsgServerData, msgMarkedAsR
|
|||||||
receipt.Payload.Webrtc = webrtc
|
receipt.Payload.Webrtc = webrtc
|
||||||
audioOnly, _ := data.Head["aonly"].(bool)
|
audioOnly, _ := data.Head["aonly"].(bool)
|
||||||
receipt.Payload.AudioOnly = audioOnly
|
receipt.Payload.AudioOnly = audioOnly
|
||||||
|
if webrtc == constCallMsgStarted {
|
||||||
|
timeout := imCallTimeoutSeconds()
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = defaultCallEstablishmentTimeout
|
||||||
|
}
|
||||||
|
expiresAt := data.Timestamp.Add(time.Duration(timeout) * time.Second)
|
||||||
|
receipt.Payload.ExpiresAt = &expiresAt
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if replace, found := data.Head["replace"].(string); found {
|
if replace, found := data.Head["replace"].(string); found {
|
||||||
receipt.Payload.Replace = replace
|
receipt.Payload.Replace = replace
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ func payloadToData(pl *push.Payload) (map[string]string, error) {
|
|||||||
}
|
}
|
||||||
data["topic"] = pl.Topic
|
data["topic"] = pl.Topic
|
||||||
data["ts"] = pl.Timestamp.Format(time.RFC3339Nano)
|
data["ts"] = pl.Timestamp.Format(time.RFC3339Nano)
|
||||||
|
if pl.ExpiresAt != nil && !pl.ExpiresAt.IsZero() {
|
||||||
|
data["expires_at"] = pl.ExpiresAt.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
// Must use "xfrom" because "from" is a reserved word. Google did not bother to document it anywhere.
|
// Must use "xfrom" because "from" is a reserved word. Google did not bother to document it anywhere.
|
||||||
data["xfrom"] = pl.From
|
data["xfrom"] = pl.From
|
||||||
if pl.What == push.ActMsg {
|
if pl.What == push.ActMsg {
|
||||||
@@ -89,6 +92,15 @@ func payloadToData(pl *push.Payload) (map[string]string, error) {
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pushExpired(data map[string]string) bool {
|
||||||
|
expiresAt := data["expires_at"]
|
||||||
|
if expiresAt == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parsed, err := time.Parse(time.RFC3339Nano, expiresAt)
|
||||||
|
return err == nil && !time.Now().UTC().Before(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
func clonePayload(src map[string]string) map[string]string {
|
func clonePayload(src map[string]string) map[string]string {
|
||||||
dst := make(map[string]string, len(src))
|
dst := make(map[string]string, len(src))
|
||||||
maps.Copy(dst, src)
|
maps.Copy(dst, src)
|
||||||
@@ -103,6 +115,9 @@ func PrepareV1Notifications(rcpt *push.Receipt, config *configType) ([]*fcmv1.Me
|
|||||||
logs.Warn.Println("fcm push: could not parse payload:", err)
|
logs.Warn.Println("fcm push: could not parse payload:", err)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
if pushExpired(data) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Device IDs to send pushes to.
|
// Device IDs to send pushes to.
|
||||||
var devices map[t.Uid][]t.DeviceDef
|
var devices map[t.Uid][]t.DeviceDef
|
||||||
@@ -322,6 +337,11 @@ func apnsNotificationConfig(what, topic string, data map[string]string, unread i
|
|||||||
if config.TimeToLive > 0 {
|
if config.TimeToLive > 0 {
|
||||||
expires = time.Now().UTC().Add(time.Duration(config.TimeToLive) * time.Second)
|
expires = time.Now().UTC().Add(time.Duration(config.TimeToLive) * time.Second)
|
||||||
}
|
}
|
||||||
|
if expiresAt := data["expires_at"]; expiresAt != "" {
|
||||||
|
if parsed, err := time.Parse(time.RFC3339Nano, expiresAt); err == nil && parsed.Before(expires) {
|
||||||
|
expires = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
bundleId := config.ApnsBundleID
|
bundleId := config.ApnsBundleID
|
||||||
pushType := common.ApnsPushTypeAlert
|
pushType := common.ApnsPushTypeAlert
|
||||||
priority := 10
|
priority := 10
|
||||||
@@ -335,7 +355,10 @@ func apnsNotificationConfig(what, topic string, data map[string]string, unread i
|
|||||||
// a different adapter.
|
// a different adapter.
|
||||||
// pushType = common.ApnsPushTypeVoip
|
// pushType = common.ApnsPushTypeVoip
|
||||||
// bundleId += ".voip"
|
// bundleId += ".voip"
|
||||||
expires = time.Now().UTC().Add(time.Duration(voipTimeToLive) * time.Second)
|
callExpires := time.Now().UTC().Add(time.Duration(voipTimeToLive) * time.Second)
|
||||||
|
if expires.After(callExpires) {
|
||||||
|
expires = callExpires
|
||||||
|
}
|
||||||
} else if what == push.ActRead {
|
} else if what == push.ActRead {
|
||||||
priority = 5
|
priority = 5
|
||||||
interruptionLevel = common.InterruptionLevelPassive
|
interruptionLevel = common.InterruptionLevelPassive
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package fcm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tinode/chat/server/push"
|
||||||
|
"github.com/tinode/chat/server/store/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPayloadToDataIncludesCallExpiresAt(t *testing.T) {
|
||||||
|
expiresAt := time.Date(2026, 9, 4, 10, 0, 30, 0, time.UTC)
|
||||||
|
data, err := payloadToData(&push.Payload{
|
||||||
|
What: push.ActMsg,
|
||||||
|
Topic: "p2pTopic",
|
||||||
|
From: "usrCaller",
|
||||||
|
SeqId: 30,
|
||||||
|
Webrtc: "started",
|
||||||
|
ExpiresAt: &expiresAt,
|
||||||
|
Content: map[string]any{
|
||||||
|
"txt": "call",
|
||||||
|
},
|
||||||
|
Timestamp: time.Date(2026, 9, 4, 10, 0, 0, 0, time.UTC),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("payload data: %v", err)
|
||||||
|
}
|
||||||
|
if data["expires_at"] != expiresAt.Format(time.RFC3339Nano) {
|
||||||
|
t.Fatalf("unexpected expires_at: %#v", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareV1NotificationsDropsExpiredCallInvite(t *testing.T) {
|
||||||
|
expiresAt := time.Now().Add(-time.Second)
|
||||||
|
messages, uids := PrepareV1Notifications(&push.Receipt{
|
||||||
|
TenantID: 7,
|
||||||
|
To: map[types.Uid]push.Recipient{
|
||||||
|
42: {},
|
||||||
|
},
|
||||||
|
Payload: push.Payload{
|
||||||
|
What: push.ActMsg,
|
||||||
|
Topic: "p2pTopic",
|
||||||
|
SeqId: 30,
|
||||||
|
Webrtc: "started",
|
||||||
|
ExpiresAt: &expiresAt,
|
||||||
|
Content: map[string]any{
|
||||||
|
"txt": "call",
|
||||||
|
},
|
||||||
|
Timestamp: time.Now().Add(-time.Minute),
|
||||||
|
},
|
||||||
|
}, &configType{})
|
||||||
|
if len(messages) != 0 || len(uids) != 0 {
|
||||||
|
t.Fatalf("expired call invite should be dropped: messages=%d uids=%#v", len(messages), uids)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ const (
|
|||||||
defaultAPIDomain = "https://api.jpush.cn"
|
defaultAPIDomain = "https://api.jpush.cn"
|
||||||
defaultPushPath = "/v3/push"
|
defaultPushPath = "/v3/push"
|
||||||
defaultTTL = 86400
|
defaultTTL = 86400
|
||||||
defaultCallTTL = 60
|
defaultCallTTL = 30
|
||||||
)
|
)
|
||||||
|
|
||||||
var handler Handler
|
var handler Handler
|
||||||
@@ -327,6 +327,9 @@ func preparePayloads(rcpt *push.Receipt, config *configType) ([]*jpushPayload, [
|
|||||||
logs.Warn.Println("jpush: could not build payload:", err)
|
logs.Warn.Println("jpush: could not build payload:", err)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
if pushExpired(data) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
useAlias := true
|
useAlias := true
|
||||||
if config.UseAlias != nil {
|
if config.UseAlias != nil {
|
||||||
useAlias = *config.UseAlias
|
useAlias = *config.UseAlias
|
||||||
@@ -399,6 +402,9 @@ func pushData(pl push.Payload) (map[string]string, error) {
|
|||||||
}
|
}
|
||||||
if pl.Webrtc != "" {
|
if pl.Webrtc != "" {
|
||||||
data["webrtc"] = pl.Webrtc
|
data["webrtc"] = pl.Webrtc
|
||||||
|
if pl.ExpiresAt != nil && !pl.ExpiresAt.IsZero() {
|
||||||
|
data["expires_at"] = pl.ExpiresAt.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
if callID := payloadContentString(pl.Content, "callId"); callID != "" {
|
if callID := payloadContentString(pl.Content, "callId"); callID != "" {
|
||||||
data["call_id"] = callID
|
data["call_id"] = callID
|
||||||
}
|
}
|
||||||
@@ -437,11 +443,31 @@ func pushData(pl push.Payload) (map[string]string, error) {
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pushExpired(data map[string]string) bool {
|
||||||
|
expiresAt := data["expires_at"]
|
||||||
|
if expiresAt == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parsed, err := time.Parse(time.RFC3339Nano, expiresAt)
|
||||||
|
return err == nil && !time.Now().UTC().Before(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
func buildPayload(audience map[string][]string, topic string, data map[string]string, unread int, config *configType) *jpushPayload {
|
func buildPayload(audience map[string][]string, topic string, data map[string]string, unread int, config *configType) *jpushPayload {
|
||||||
alert, title := alertText(data, config)
|
alert, title := alertText(data, config)
|
||||||
ttl := config.DefaultTTLSeconds
|
ttl := config.DefaultTTLSeconds
|
||||||
if data["type"] == "call_invite" {
|
if data["type"] == "call_invite" {
|
||||||
ttl = config.CallTTLSeconds
|
ttl = config.CallTTLSeconds
|
||||||
|
if expiresAt := data["expires_at"]; expiresAt != "" {
|
||||||
|
if parsed, err := time.Parse(time.RFC3339Nano, expiresAt); err == nil {
|
||||||
|
remaining := int(time.Until(parsed).Seconds())
|
||||||
|
if remaining < ttl {
|
||||||
|
ttl = remaining
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ttl < 1 {
|
||||||
|
ttl = 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
payload := &jpushPayload{
|
payload := &jpushPayload{
|
||||||
Platform: "all",
|
Platform: "all",
|
||||||
|
|||||||
@@ -171,14 +171,16 @@ func TestPreparePayloadsDoesNotDropRecipientWithDeliveredSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildPayloadUsesShortTTLForCalls(t *testing.T) {
|
func TestBuildPayloadUsesShortTTLForCalls(t *testing.T) {
|
||||||
|
expiresAt := time.Now().Add(30 * time.Second).UTC().Format(time.RFC3339Nano)
|
||||||
config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60}
|
config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60}
|
||||||
payload := buildPayload(map[string][]string{"alias": {"usr1"}}, "p2pTopic", map[string]string{
|
payload := buildPayload(map[string][]string{"alias": {"usr1"}}, "p2pTopic", map[string]string{
|
||||||
"type": "call_invite",
|
"type": "call_invite",
|
||||||
"topic": "p2pTopic",
|
"topic": "p2pTopic",
|
||||||
"seq": "30",
|
"seq": "30",
|
||||||
"call_type": "audio",
|
"call_type": "audio",
|
||||||
|
"expires_at": expiresAt,
|
||||||
}, 0, config)
|
}, 0, config)
|
||||||
if payload.Options.TimeToLive != 60 {
|
if payload.Options.TimeToLive > 30 {
|
||||||
t.Fatalf("unexpected ttl: %d", payload.Options.TimeToLive)
|
t.Fatalf("unexpected ttl: %d", payload.Options.TimeToLive)
|
||||||
}
|
}
|
||||||
if payload.Notification.Android.Title != "语音通话" {
|
if payload.Notification.Android.Title != "语音通话" {
|
||||||
@@ -187,12 +189,14 @@ func TestBuildPayloadUsesShortTTLForCalls(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPushDataExtractsCallFields(t *testing.T) {
|
func TestPushDataExtractsCallFields(t *testing.T) {
|
||||||
|
expiresAt := time.Date(2026, 9, 4, 10, 0, 30, 0, time.UTC)
|
||||||
data, err := pushData(push.Payload{
|
data, err := pushData(push.Payload{
|
||||||
What: push.ActMsg,
|
What: push.ActMsg,
|
||||||
Topic: "p2pTopic",
|
Topic: "p2pTopic",
|
||||||
From: "usrCaller",
|
From: "usrCaller",
|
||||||
SeqId: 30,
|
SeqId: 30,
|
||||||
Webrtc: "started",
|
Webrtc: "started",
|
||||||
|
ExpiresAt: &expiresAt,
|
||||||
Content: map[string]any{
|
Content: map[string]any{
|
||||||
"callId": "call_1",
|
"callId": "call_1",
|
||||||
"callType": "audio",
|
"callType": "audio",
|
||||||
@@ -205,6 +209,32 @@ func TestPushDataExtractsCallFields(t *testing.T) {
|
|||||||
if data["type"] != "call_invite" || data["call_id"] != "call_1" || data["call_type"] != "audio" || data["caller_id"] != "usrCaller" {
|
if data["type"] != "call_invite" || data["call_id"] != "call_1" || data["call_type"] != "audio" || data["caller_id"] != "usrCaller" {
|
||||||
t.Fatalf("unexpected call data: %#v", data)
|
t.Fatalf("unexpected call data: %#v", data)
|
||||||
}
|
}
|
||||||
|
if data["expires_at"] != expiresAt.Format(time.RFC3339Nano) {
|
||||||
|
t.Fatalf("unexpected expires_at: %#v", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparePayloadsDropsExpiredCallInvite(t *testing.T) {
|
||||||
|
uid := types.Uid(42)
|
||||||
|
expiresAt := time.Now().Add(-time.Second)
|
||||||
|
config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 30}
|
||||||
|
payloads, uids := preparePayloads(&push.Receipt{
|
||||||
|
TenantID: 7,
|
||||||
|
To: map[types.Uid]push.Recipient{
|
||||||
|
uid: {},
|
||||||
|
},
|
||||||
|
Payload: push.Payload{
|
||||||
|
What: push.ActMsg,
|
||||||
|
Topic: "p2pTopic",
|
||||||
|
SeqId: 30,
|
||||||
|
Webrtc: "started",
|
||||||
|
ExpiresAt: &expiresAt,
|
||||||
|
Timestamp: time.Now().Add(-time.Minute),
|
||||||
|
},
|
||||||
|
}, config)
|
||||||
|
if len(payloads) != 0 || len(uids) != 0 {
|
||||||
|
t.Fatalf("expired call invite should be dropped: payloads=%d uids=%#v", len(payloads), uids)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSendPayloadUsesBasicAuth(t *testing.T) {
|
func TestSendPayloadUsesBasicAuth(t *testing.T) {
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ type Payload struct {
|
|||||||
Topic string `json:"topic"`
|
Topic string `json:"topic"`
|
||||||
// Timestamp of the action.
|
// Timestamp of the action.
|
||||||
Timestamp time.Time `json:"ts"`
|
Timestamp time.Time `json:"ts"`
|
||||||
|
// Expiration time for short-lived payloads such as call invites.
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
|
|
||||||
// {data} notification.
|
// {data} notification.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user