fix: add call push category
This commit is contained in:
+11
-5
@@ -530,7 +530,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `im_message` | `tenant_code,topic,seq` | 打开会话,按本地最后 seq 补齐消息并定位。 |
|
| `im_message` | `tenant_code,topic,seq` | 打开会话,按本地最后 seq 补齐消息并定位。 |
|
||||||
| `group_mention` | `tenant_code,topic,seq,sender_id` | 打开群会话并定位被 @ 消息。 |
|
| `group_mention` | `tenant_code,topic,seq,sender_id` | 打开群会话并定位被 @ 消息。 |
|
||||||
| `call_invite` | `tenant_code,topic,seq,call_id,call_type,caller_id` | 打开来电页前先登录 IM 并确认通话仍为有效来电。 |
|
| `call_invite` | `tenant_code,topic,seq,call_id,call_type,caller_id,caller_display,expires_at` | 打开来电页前先登录 IM 并确认通话仍为有效来电。 |
|
||||||
| `call_missed` | `tenant_code,topic,call_id` | 打开通话记录或对应会话。 |
|
| `call_missed` | `tenant_code,topic,call_id` | 打开通话记录或对应会话。 |
|
||||||
| `system_notification` | `tenant_code,notification_id` | 打开系统消息列表并刷新详情。 |
|
| `system_notification` | `tenant_code,notification_id` | 打开系统消息列表并刷新详情。 |
|
||||||
| `friend_request` | `tenant_code,request_id` | 打开新朋友列表并刷新未处理数。 |
|
| `friend_request` | `tenant_code,request_id` | 打开新朋友列表并刷新未处理数。 |
|
||||||
@@ -584,6 +584,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
|||||||
},
|
},
|
||||||
"sound": "default",
|
"sound": "default",
|
||||||
"badge": "+1",
|
"badge": "+1",
|
||||||
|
"category": "im_call",
|
||||||
"extras": {
|
"extras": {
|
||||||
"type": "call_invite",
|
"type": "call_invite",
|
||||||
"tenant_code": "default",
|
"tenant_code": "default",
|
||||||
@@ -591,18 +592,22 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
|||||||
"seq": "30",
|
"seq": "30",
|
||||||
"call_id": "call_01K...",
|
"call_id": "call_01K...",
|
||||||
"call_type": "audio",
|
"call_type": "audio",
|
||||||
"caller_id": "usr_caller"
|
"caller_id": "usr_caller",
|
||||||
|
"caller_display": "张三",
|
||||||
|
"expires_at": "2026-09-18T10:00:30Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"time_to_live": 60,
|
"time_to_live": 30,
|
||||||
"apns_production": false,
|
"apns_production": false,
|
||||||
"classification": 1
|
"classification": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
来电推送必须走普通 APNs/极光 alert 通知,不使用 PushKit VoIP topic;iOS `notification.ios.category` 固定为 `im_call`,用于客户端通知按钮。`time_to_live` 和 `expires_at` 以 30 秒左右短有效期为准,过期后服务端不再投递。
|
||||||
|
|
||||||
#### 0.5.5 Long Polling 与文件基础示例
|
#### 0.5.5 Long Polling 与文件基础示例
|
||||||
|
|
||||||
##### 0.5.5.1 WebSocket 与文件基础接口
|
##### 0.5.5.1 WebSocket 与文件基础接口
|
||||||
@@ -2405,11 +2410,12 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
|
|||||||
|
|
||||||
<img src="images/对方状态.png" alt="对方状态" width="360">
|
<img src="images/对方状态.png" alt="对方状态" width="360">
|
||||||
|
|
||||||
**状态:在线邀请已实现;Push 与产品鉴权需改造。**
|
**状态:在线邀请、短有效期普通 Push 与产品鉴权已实现。**
|
||||||
|
|
||||||
- 来电是 Topic 下行的初始 `{data}`:`head.webrtc="started"`、`head.x-im-type="call"`,`content` 至少含 `callId,callType,audio,video`。客户端保存该消息 `seq` 作为后续所有 call note 的关联 ID。
|
- 来电是 Topic 下行的初始 `{data}`:`head.webrtc="started"`、`head.x-im-type="call"`,`content` 至少含 `callId,callType,audio,video`。客户端保存该消息 `seq` 作为后续所有 call note 的关联 ID。
|
||||||
- 来电端开始响铃后发送 `{note:{topic,what:"call",event:"ringing",seq,payload:{"callId":"..."}}}`;接听发送 `event:"accept"`;拒绝发送 `event:"hang-up"`。
|
- 来电端开始响铃后发送 `{note:{topic,what:"call",event:"ringing",seq,payload:{"callId":"..."}}}`;接听发送 `event:"accept"`;拒绝发送 `event:"hang-up"`。
|
||||||
- 后台 Push 只携带 `callId,topic,seq,caller_display,callType,expires_at`,不含 SDP、ICE 或 Token;用户操作后必须回 WebSocket 鉴权。
|
- 后台 Push 只携带 `callId,topic,seq,caller_id,caller_display,callType,expires_at`,不含 SDP、ICE 或 Token;iOS 来电 Push 使用普通 alert,`category=im_call`,短 TTL 约 30 秒,不使用 PushKit VoIP。
|
||||||
|
- 被叫在未发送 `ringing/accept` 前也可以直接发送 `hang-up`,服务端按 `topic+seq+callId` 校验后立即结束主叫侧振铃,并把原通话消息替换为 `declined`;主叫主动取消则替换为 `missed`。
|
||||||
|
|
||||||
### 8.3 语音通话-对方待接受状态.png
|
### 8.3 语音通话-对方待接受状态.png
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const (
|
|||||||
defaultPushPath = "/v3/push"
|
defaultPushPath = "/v3/push"
|
||||||
defaultTTL = 86400
|
defaultTTL = 86400
|
||||||
defaultCallTTL = 30
|
defaultCallTTL = 30
|
||||||
|
callCategory = "im_call"
|
||||||
)
|
)
|
||||||
|
|
||||||
var handler Handler
|
var handler Handler
|
||||||
@@ -78,10 +79,11 @@ type androidPayload struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type iosPayload struct {
|
type iosPayload struct {
|
||||||
Alert iosAlert `json:"alert"`
|
Alert iosAlert `json:"alert"`
|
||||||
Sound string `json:"sound,omitempty"`
|
Sound string `json:"sound,omitempty"`
|
||||||
Badge string `json:"badge,omitempty"`
|
Badge string `json:"badge,omitempty"`
|
||||||
Extras map[string]string `json:"extras,omitempty"`
|
Category string `json:"category,omitempty"`
|
||||||
|
Extras map[string]string `json:"extras,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type iosAlert struct {
|
type iosAlert struct {
|
||||||
@@ -327,6 +329,7 @@ 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
|
||||||
}
|
}
|
||||||
|
hydrateCallInviteData(rcpt.TenantID, data)
|
||||||
if pushExpired(data) {
|
if pushExpired(data) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -424,6 +427,9 @@ func pushData(pl push.Payload) (map[string]string, error) {
|
|||||||
if pl.From != "" {
|
if pl.From != "" {
|
||||||
data["caller_id"] = pl.From
|
data["caller_id"] = pl.From
|
||||||
}
|
}
|
||||||
|
if display := payloadContentFirstString(pl.Content, "caller_display", "callerDisplay", "caller_name", "callerName", "display_name", "from_display"); display != "" {
|
||||||
|
data["caller_display"] = display
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if pl.Replace != "" {
|
if pl.Replace != "" {
|
||||||
data["replace"] = pl.Replace
|
data["replace"] = pl.Replace
|
||||||
@@ -495,10 +501,11 @@ func buildPayload(audience map[string][]string, topic string, data map[string]st
|
|||||||
Priority: 1,
|
Priority: 1,
|
||||||
},
|
},
|
||||||
IOS: &iosPayload{
|
IOS: &iosPayload{
|
||||||
Alert: iosAlert{Title: title, Body: alert},
|
Alert: iosAlert{Title: title, Body: alert},
|
||||||
Sound: "default",
|
Sound: "default",
|
||||||
Badge: badge,
|
Badge: badge,
|
||||||
Extras: data,
|
Category: iosCategory(data),
|
||||||
|
Extras: data,
|
||||||
},
|
},
|
||||||
HMOS: &hmosPayload{
|
HMOS: &hmosPayload{
|
||||||
Title: title,
|
Title: title,
|
||||||
@@ -514,9 +521,16 @@ func alertText(data map[string]string, config *configType) (string, string) {
|
|||||||
title := config.Title
|
title := config.Title
|
||||||
switch data["type"] {
|
switch data["type"] {
|
||||||
case "call_invite":
|
case "call_invite":
|
||||||
|
caller := strings.TrimSpace(data["caller_display"])
|
||||||
if data["call_type"] == "audio" {
|
if data["call_type"] == "audio" {
|
||||||
|
if caller != "" {
|
||||||
|
return caller + "邀请你语音通话", "语音通话"
|
||||||
|
}
|
||||||
return "收到一通语音通话", "语音通话"
|
return "收到一通语音通话", "语音通话"
|
||||||
}
|
}
|
||||||
|
if caller != "" {
|
||||||
|
return caller + "邀请你视频通话", "视频通话"
|
||||||
|
}
|
||||||
return "收到一通视频通话", "视频通话"
|
return "收到一通视频通话", "视频通话"
|
||||||
case "call_missed":
|
case "call_missed":
|
||||||
return "你有一通未接来电", "未接来电"
|
return "你有一通未接来电", "未接来电"
|
||||||
@@ -527,6 +541,57 @@ func alertText(data map[string]string, config *configType) (string, string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func iosCategory(data map[string]string) string {
|
||||||
|
if data["type"] == "call_invite" {
|
||||||
|
return callCategory
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func hydrateCallInviteData(tenantID t.TenantID, data map[string]string) {
|
||||||
|
if data["type"] != "call_invite" || strings.TrimSpace(data["caller_display"]) != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callerID := strings.TrimSpace(data["caller_id"])
|
||||||
|
if callerID == "" || store.Users == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := t.ParseUserId(callerID)
|
||||||
|
if uid.IsZero() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := store.Users.Get(tenantID, uid)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if display := displayNameFromPublic(user.Public); display != "" {
|
||||||
|
data["caller_display"] = display
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayNameFromPublic(public any) string {
|
||||||
|
for _, key := range []string{"display_name", "fn", "nickname", "name"} {
|
||||||
|
if value := publicMapString(public, key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicMapString(public any, key string) string {
|
||||||
|
switch values := public.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
value, ok := values[key]
|
||||||
|
if !ok || value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(fmt.Sprint(value))
|
||||||
|
case map[string]string:
|
||||||
|
return strings.TrimSpace(values[key])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func cidFor(topic string, data map[string]string) string {
|
func cidFor(topic string, data map[string]string) string {
|
||||||
parts := []string{data["type"], topic, data["seq"], data["replace"]}
|
parts := []string{data["type"], topic, data["seq"], data["replace"]}
|
||||||
return strings.Trim(strings.Join(parts, "_"), "_")
|
return strings.Trim(strings.Join(parts, "_"), "_")
|
||||||
@@ -560,6 +625,15 @@ func payloadContentString(content any, key string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func payloadContentFirstString(content any, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value := payloadContentString(content, key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func truncateRunes(value string, limit int) string {
|
func truncateRunes(value string, limit int) string {
|
||||||
if limit <= 0 || len(value) <= limit {
|
if limit <= 0 || len(value) <= limit {
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -174,11 +174,12 @@ func TestBuildPayloadUsesShortTTLForCalls(t *testing.T) {
|
|||||||
expiresAt := time.Now().Add(30 * time.Second).UTC().Format(time.RFC3339Nano)
|
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,
|
"caller_display": "张三",
|
||||||
|
"expires_at": expiresAt,
|
||||||
}, 0, config)
|
}, 0, config)
|
||||||
if payload.Options.TimeToLive > 30 {
|
if payload.Options.TimeToLive > 30 {
|
||||||
t.Fatalf("unexpected ttl: %d", payload.Options.TimeToLive)
|
t.Fatalf("unexpected ttl: %d", payload.Options.TimeToLive)
|
||||||
@@ -186,6 +187,12 @@ func TestBuildPayloadUsesShortTTLForCalls(t *testing.T) {
|
|||||||
if payload.Notification.Android.Title != "语音通话" {
|
if payload.Notification.Android.Title != "语音通话" {
|
||||||
t.Fatalf("unexpected title: %s", payload.Notification.Android.Title)
|
t.Fatalf("unexpected title: %s", payload.Notification.Android.Title)
|
||||||
}
|
}
|
||||||
|
if payload.Notification.IOS.Category != callCategory {
|
||||||
|
t.Fatalf("unexpected iOS category: %s", payload.Notification.IOS.Category)
|
||||||
|
}
|
||||||
|
if payload.Notification.IOS.Alert.Body != "张三邀请你语音通话" {
|
||||||
|
t.Fatalf("unexpected body: %s", payload.Notification.IOS.Alert.Body)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPushDataExtractsCallFields(t *testing.T) {
|
func TestPushDataExtractsCallFields(t *testing.T) {
|
||||||
@@ -198,8 +205,9 @@ func TestPushDataExtractsCallFields(t *testing.T) {
|
|||||||
Webrtc: "started",
|
Webrtc: "started",
|
||||||
ExpiresAt: &expiresAt,
|
ExpiresAt: &expiresAt,
|
||||||
Content: map[string]any{
|
Content: map[string]any{
|
||||||
"callId": "call_1",
|
"callId": "call_1",
|
||||||
"callType": "audio",
|
"callType": "audio",
|
||||||
|
"caller_display": "张三",
|
||||||
},
|
},
|
||||||
Timestamp: time.Date(2026, 9, 4, 10, 0, 0, 0, time.UTC),
|
Timestamp: time.Date(2026, 9, 4, 10, 0, 0, 0, time.UTC),
|
||||||
})
|
})
|
||||||
@@ -212,6 +220,9 @@ func TestPushDataExtractsCallFields(t *testing.T) {
|
|||||||
if data["expires_at"] != expiresAt.Format(time.RFC3339Nano) {
|
if data["expires_at"] != expiresAt.Format(time.RFC3339Nano) {
|
||||||
t.Fatalf("unexpected expires_at: %#v", data)
|
t.Fatalf("unexpected expires_at: %#v", data)
|
||||||
}
|
}
|
||||||
|
if data["caller_display"] != "张三" {
|
||||||
|
t.Fatalf("unexpected caller_display: %#v", data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPreparePayloadsDropsExpiredCallInvite(t *testing.T) {
|
func TestPreparePayloadsDropsExpiredCallInvite(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user