From edced2675be5f37a5c8ba6ba2fae309296d93f20 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Tue, 1 Sep 2026 13:12:24 +0800 Subject: [PATCH] feat: add conversation system action messages --- docs/IM-前后端对接文档.md | 34 ++++++++- server/db/mysql/tenant_adapter.go | 5 +- server/im_http.go | 116 ++++++++++++++++++++++++++++++ server/im_http_test.go | 9 +++ server/store/store.go | 2 +- server/store/tenant_store_test.go | 2 + server/topic.go | 12 +++- server/topic_test.go | 74 ++++++++++--------- 8 files changed, 213 insertions(+), 41 deletions(-) diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index ed96c98..77f00f7 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -314,6 +314,38 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | @所有人 | `{"pub":{"id":"pub-all-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_all_1","mention_all":true},"content":{"text":"@所有人 开会"}}}` | 无权限时 `{"ctrl":{"id":"pub-all-1","code":403,"text":"permission denied","params":{"reason":"MENTION_ALL_FORBIDDEN"}}}` | | 重复 `client_mid` 重试 | 与第一次发送完全相同,复用 `x-im-client-mid:"mid_1"` | 目标行为:返回第一次成功的同一 `seq`;不得落两条消息 | +##### 0.5.4.3.1 会话内系统提示和可点击动作消息 + +截图中的灰色提示、入群邀请说明、好友已添加提示等属于会话内系统消息,必须进入对应 P2P 或群 Topic 的消息流,占用 `seq`,参与历史拉取、重连补偿和多端同步。它们不是 `info` 实时业务事件,也不是系统通知列表记录;`info` 只负责触发刷新,真正需要在聊天窗口长期展示的内容必须以 `{data}` 下发并入库。 + +客户端不得主动发送 `system_notice`、`system_action`;这两类消息只能由服务端受控身份生成。服务端下发时 `data.from` 可以为空,客户端以 `head.x-im-from="system"` 判断系统身份,不依赖真实用户头像。客户端收到未知 `template` 或未知 `action.type` 时,展示 `content.text`,忽略不可识别动作,不允许执行任意 URL、脚本或非白名单路由。 + +| 类型 | 适用场景 | 服务端下行示例 | 客户端渲染 | +| --- | --- | --- | --- | +| `system_notice` | “你已添加了安妮,现在可以开始聊天了”、撤回提示、群名修改、成员进出群等纯提示 | `{"data":{"topic":"p2p...","seq":19,"from":"","head":{"x-im-schema":1,"x-im-type":"system_notice","x-im-template":"friend_added","x-im-from":"system"},"content":{"text":"你已添加了安妮,现在可以开始聊天了。","level":"normal","template":"friend_added","actors":[{"user_id":"usr_anne","name":"安妮"}],"actions":[]}}}` | 居中灰色提示;不显示发送人头像;如 `actors.click` 为空则不可点 | +| `system_action` | 群邀请确认说明、审批入口、群设置入口、可点击人名或群名提示 | `{"data":{"topic":"grp...","seq":20,"from":"","head":{"x-im-schema":1,"x-im-type":"system_action","x-im-template":"group_invite_confirm_enabled","x-im-from":"system"},"content":{"text":"安妮邀请栗子来了加入了群聊","template":"group_invite_confirm_enabled","actors":[{"user_id":"usr_anne","name":"安妮","click":{"type":"user_profile","target_id":"usr_anne"}},{"user_id":"usr_lizi","name":"栗子来了","click":{"type":"user_profile","target_id":"usr_lizi"}}],"actions":[{"type":"open_group_invite_settings","label":"群聊邀请确认","target_id":"grp..."}]}}}` | 居中灰色或业务提示样式;可点击片段按 `actors.click` 高亮;按钮或文字入口按 `actions` 渲染 | + +`content` 字段约定: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `text` | string | 是 | 降级展示文案;客户端可直接展示,也可按 `template` 重新排版 | +| `template` | string | 是 | 服务端稳定模板编码,例如 `friend_added`、`group_member_joined`、`group_invite_confirm_enabled` | +| `level` | string | 否 | `normal`、`warning`、`danger`,影响颜色和强调程度 | +| `actors` | array | 否 | 文案中的用户、群、文件等实体;支持局部高亮和点击 | +| `actions` | array | 否 | 当前消息可触发的操作入口;只允许客户端白名单动作 | +| `extra` | object | 否 | 模板扩展参数;客户端不得依赖未知字段完成关键流程 | + +`actors[].click` 和 `actions[]` 的 `type` 首版白名单: + +| 类型 | 参数 | 行为 | +| --- | --- | --- | +| `user_profile` | `target_id` | 打开用户资料页 | +| `group_profile` | `target_id` | 打开群资料页 | +| `open_group_invite_settings` | `target_id` | 打开群邀请确认设置 | +| `open_group_join_requests` | `target_id` | 打开群入群审批列表 | +| `open_message` | `topic,seq` | 定位到指定消息 | + ##### 0.5.4.4 回执、正在输入、删除和清空记录 | 场景 | 客户端发送 | 服务端返回/下行示例 | @@ -453,7 +485,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> `x-im-client-mid` 由客户端生成 ULID/UUID,作用域为“租户 + 发送人”,断线重试必须复用;服务端目标实现应返回第一次写入的同一 `seq`。未知 `x-im-type` 显示“暂不支持的消息”,不得崩溃或删除消息。 -消息类型首版固定为:`text`、`image`、`video`、`voice`、`file`、`contact_card`、`link`、`forward_bundle`、`call`、`system_event`。自定义表情启用后追加 `sticker`。媒体内容只保存 `file_id`、文件元数据和缩略图 ID,不保存可长期绕过鉴权的公共 URL。 +消息类型首版固定为:`text`、`image`、`video`、`voice`、`file`、`contact_card`、`link`、`forward_bundle`、`call`、`system_notice`、`system_action`、`system_event`。自定义表情启用后追加 `sticker`。媒体内容只保存 `file_id`、文件元数据和缩略图 ID,不保存可长期绕过鉴权的公共 URL。`system_notice` 和 `system_action` 只能由服务端生成,普通用户构造后发送必须被服务端拒绝。 ### 0.7 HTTP 幂等、重试和安全 diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index f74584e..0b29c1d 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -8635,8 +8635,9 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init if limit <= 0 || limit > 100 { limit = 50 } - where := []string{"fs.user_id=?", "fs.status=?"} - args := []any{decodeUID(uid), t.RelationshipFriend} + uidID := decodeUID(uid) + where := []string{"fs.user_id=?", "fs.status=?", "fs.peer_id<>?"} + args := []any{uidID, t.RelationshipFriend, uidID} if cursor != "" { parsed, err := strconv.ParseInt(cursor, 10, 64) if err != nil || parsed <= 0 { diff --git a/server/im_http.go b/server/im_http.go index a0f0c5d..85dc388 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -551,6 +551,18 @@ type imGroupMuteBatchRequest struct { Reason string `json:"reason,omitempty"` } +type imSystemMessageActor struct { + UserID string `json:"user_id,omitempty"` + Name string `json:"name,omitempty"` + Click map[string]any `json:"click,omitempty"` +} + +type imSystemMessageAction struct { + Type string `json:"type"` + Label string `json:"label,omitempty"` + TargetID string `json:"target_id,omitempty"` +} + func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -4169,6 +4181,9 @@ func serveIMFriends(wrt http.ResponseWriter, req *http.Request) { } payload := make([]map[string]any, 0, len(items)) for _, item := range items { + if item.Friendship.PeerID == principal.Uid { + continue + } payload = append(payload, friendListItemPayload(item)) } writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor}) @@ -4934,6 +4949,16 @@ func serveIMGroupInvitationResource(wrt http.ResponseWriter, req *http.Request) writeIMRelationError(wrt, req, err) return } + if imReliableHTTPEventsEnabled(req) { + publishIMConversationSystemNotice(principal.TenantID, inv.Topic, + "system_action", "group_member_joined", "有成员通过邀请加入了群聊。", + []imSystemMessageActor{ + {UserID: inv.InviterUserID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": inv.InviterUserID.UserId()}}, + {UserID: inv.TargetUserID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": inv.TargetUserID.UserId()}}, + }, + []imSystemMessageAction{{Type: "group_profile", Label: "查看群聊", TargetID: inv.Topic}}, + map[string]any{"invitation_id": inv.InvitationID, "source": "invitation"}) + } dispatchIMGroupEvents(principal.TenantID) writeIMSuccess(wrt, req, groupInvitationPayload(inv)) } @@ -5048,6 +5073,16 @@ func serveIMGroupJoinRequestAction(wrt http.ResponseWriter, req *http.Request, t writeIMRelationError(wrt, req, err) return } + if updated.Status == types.GroupJoinRequestStatusAccepted && imReliableHTTPEventsEnabled(req) { + publishIMConversationSystemNotice(principal.TenantID, topic, + "system_action", "group_member_joined", "有成员通过审批加入了群聊。", + []imSystemMessageActor{ + {UserID: updated.ApplicantUserID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": updated.ApplicantUserID.UserId()}}, + {UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}}, + }, + []imSystemMessageAction{{Type: "group_profile", Label: "查看群聊", TargetID: topic}}, + map[string]any{"request_id": updated.RequestID, "source": "approval"}) + } dispatchIMGroupEvents(principal.TenantID) writeIMSuccess(wrt, req, groupJoinRequestPayload(updated)) } @@ -5176,6 +5211,21 @@ func serveIMGroupGovernance(wrt http.ResponseWriter, req *http.Request, topic st writeIMRelationError(wrt, req, err) return } + if body.JoinApproval != nil && imReliableHTTPEventsEnabled(req) { + template := "group_invite_confirm_disabled" + text := "群聊邀请确认已关闭,成员邀请好友可直接进群。" + if *body.JoinApproval { + template = "group_invite_confirm_enabled" + text = "群聊邀请确认已启用,群成员邀请好友进群需要群主或管理员确认。" + } + publishIMConversationSystemNotice(principal.TenantID, topic, + "system_action", template, text, + []imSystemMessageActor{ + {UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}}, + }, + []imSystemMessageAction{{Type: "open_group_invite_settings", Label: "群聊邀请确认", TargetID: topic}}, + map[string]any{"join_approval": *body.JoinApproval, "version": settings.Version}) + } dispatchIMGroupEvents(principal.TenantID) writeIMSuccess(wrt, req, groupGovernancePayloadFromSettings(settings, selfSub)) default: @@ -5794,6 +5844,14 @@ func serveIMFriendRequestAction(wrt http.ResponseWriter, req *http.Request) { writeIMRelationError(wrt, req, err) return } + if status == types.FriendRequestAccepted && imReliableHTTPEventsEnabled(req) { + publishIMConversationSystemNotice(principal.TenantID, updated.ApplicantID.P2PName(updated.TargetID), + "system_notice", "friend_added", "你们已成为好友,现在可以开始聊天了。", + []imSystemMessageActor{ + {UserID: updated.ApplicantID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": updated.ApplicantID.UserId()}}, + {UserID: updated.TargetID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": updated.TargetID.UserId()}}, + }, nil, map[string]any{"request_id": updated.RequestID}) + } dispatchIMBusinessEvents(principal.TenantID) if imReliableHTTPEventsEnabled(req) { eventType := "friend.request." + status @@ -9248,6 +9306,64 @@ func requireIMTopicReadable(tenantID types.TenantID, uid types.Uid, topic string return nil } +func publishIMConversationSystemNotice(tenantID types.TenantID, topic, msgType, template, text string, actors []imSystemMessageActor, actions []imSystemMessageAction, extra map[string]any) { + if tenantID == types.ZeroTenantID || strings.TrimSpace(topic) == "" || store.Store == nil || !store.Store.IsOpen() || store.Topics == nil || store.Messages == nil { + return + } + msgType = strings.TrimSpace(msgType) + if msgType == "" { + msgType = "system_notice" + } + if msgType != "system_notice" && msgType != "system_action" { + logs.Warn.Printf("im system message: unsupported type %q for topic %s", msgType, topic) + return + } + template = strings.TrimSpace(template) + text = strings.TrimSpace(text) + if template == "" || text == "" { + return + } + topicRecord, err := store.Topics.Get(tenantID, topic) + if err != nil || topicRecord == nil { + if err != nil { + logs.Warn.Printf("im system message: failed to load topic %s: %v", topic, err) + } + return + } + content := map[string]any{ + "text": text, + "template": template, + "level": "normal", + } + if len(actors) > 0 { + content["actors"] = actors + } + if len(actions) > 0 { + content["actions"] = actions + } + if len(extra) > 0 { + content["extra"] = extra + } + now := time.Now().UTC().Round(time.Millisecond) + _, err = store.Messages.Save(tenantID, &types.Message{ + ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now}, + TenantID: tenantID, + SeqId: topicRecord.SeqId + 1, + Topic: topic, + From: types.ZeroUid.String(), + Head: types.KVMap{ + "x-im-schema": 1, + "x-im-type": msgType, + "x-im-template": template, + "x-im-from": "system", + }, + Content: content, + }, nil, false) + if err != nil && !errors.Is(err, types.ErrDuplicate) { + logs.Warn.Printf("im system message: failed to save %s for topic %s: %v", template, topic, err) + } +} + func systemNotificationIDFromPath(path string) string { const marker = "/v1/im/system-notifications/" idx := strings.Index(path, marker) diff --git a/server/im_http_test.go b/server/im_http_test.go index 8d16263..3da08ff 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -5172,6 +5172,15 @@ func TestServeIMFriendsList(t *testing.T) { now := time.Now().UTC().Round(time.Millisecond) peer := types.Uid(2) relations := &testRelationsStore{friends: []types.FriendListItem{{ + Friendship: types.Friendship{ + TenantID: 7, + UserID: 1, + PeerID: 1, + Status: types.RelationshipFriend, + Topic: types.Uid(1).P2PName(types.Uid(1)), + }, + Peer: &types.User{ObjHeader: types.ObjHeader{Id: types.Uid(1).String()}, TenantID: 7, Public: map[string]any{"display_name": "Self"}}, + }, { Friendship: types.Friendship{ TenantID: 7, UserID: 1, diff --git a/server/store/store.go b/server/store/store.go index cb9dc2d..1a67aee 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -3570,7 +3570,7 @@ func validIMMessageHeaderToken(value string, max int) bool { func validIMMessageType(value string) bool { switch strings.TrimSpace(value) { case "text", "image", "file", "audio", "voice", "video", "location", "contact", "contact_card", - "card", "link", "forward_bundle", "sticker", "call", "system", "system_event": + "card", "link", "forward_bundle", "sticker", "call", "system", "system_notice", "system_action", "system_event": return true default: return false diff --git a/server/store/tenant_store_test.go b/server/store/tenant_store_test.go index 747bab9..a4f9170 100644 --- a/server/store/tenant_store_test.go +++ b/server/store/tenant_store_test.go @@ -19,6 +19,8 @@ func TestValidateMessageHeadersProductContract(t *testing.T) { {"x-im-schema": json.Number("1"), "x-im-type": "voice"}, {"x-im-schema": "1", "x-im-type": "contact_card"}, {"x-im-schema": 1, "type": "audio", "x-im-type": "voice"}, + {"x-im-schema": 1, "x-im-type": "system_notice"}, + {"x-im-schema": 1, "x-im-type": "system_action"}, {"type": "system"}, } for _, head := range valid { diff --git a/server/topic.go b/server/topic.go index 281f34d..5ffda32 100644 --- a/server/topic.go +++ b/server/topic.go @@ -1060,8 +1060,7 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid, if msg.sess != nil && msg.sess.authLvl != auth.LevelRoot { legacyType, _ := head["type"].(string) productType, _ := head["x-im-type"].(string) - if legacyType == "system" || legacyType == "system_event" || - productType == "system" || productType == "system_event" { + if isSystemGeneratedMessageType(legacyType) || isSystemGeneratedMessageType(productType) { msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp)) return types.ErrPermissionDenied } @@ -1190,6 +1189,15 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid, return nil } +func isSystemGeneratedMessageType(value string) bool { + switch strings.TrimSpace(value) { + case "system", "system_notice", "system_action", "system_event": + return true + default: + return false + } +} + // handlePubBroadcast fans out {pub} -> {data} messages to recipients in a master topic. // This is a NON-proxy broadcast. func (t *Topic) handlePubBroadcast(msg *ClientComMessage) { diff --git a/server/topic_test.go b/server/topic_test.go index 2ca4027..b514492 100644 --- a/server/topic_test.go +++ b/server/topic_test.go @@ -789,44 +789,48 @@ func TestHandleBroadcastDataDuplicateClientMessage(t *testing.T) { } func TestHandleBroadcastDataRejectsUserSystemType(t *testing.T) { - numUsers := 2 - helper := TopicTestHelper{} - helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test", true) - defer helper.tearDown() + for _, msgType := range []string{"system_event", "system_notice", "system_action"} { + t.Run(msgType, func(t *testing.T) { + numUsers := 2 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test", true) + defer helper.tearDown() - from := helper.uids[0].UserId() - msg := &ClientComMessage{ - Id: "sys-1", - AsUser: from, - Pub: &MsgClientPub{ - Topic: "p2p", - Head: map[string]any{"x-im-schema": float64(1), "x-im-type": "system_event"}, - Content: "test", - }, - sess: helper.sessions[0], - } + from := helper.uids[0].UserId() + msg := &ClientComMessage{ + Id: "sys-1", + AsUser: from, + Pub: &MsgClientPub{ + Topic: "p2p", + Head: map[string]any{"x-im-schema": float64(1), "x-im-type": msgType}, + Content: "test", + }, + sess: helper.sessions[0], + } - helper.topic.handleClientMsg(msg) - helper.finish() + helper.topic.handleClientMsg(msg) + helper.finish() - if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { - t.Fatal(errorMsgs[0].Ctrl.Text) - } - if helper.topic.lastID != 0 { - t.Errorf("Topic.lastID: expected forbidden publish to keep 0, found %d", helper.topic.lastID) - } - if len(helper.results[0].messages) != 1 { - t.Fatalf("User 1 is expected to receive one ctrl message vs %d received.", len(helper.results[0].messages)) - } - resp := helper.results[0].messages[0].(*ServerComMessage) - if resp.Ctrl == nil || resp.Ctrl.Code != 403 { - t.Fatalf("User 1 expected 403 ctrl, received %#v", resp) - } - if len(helper.results[1].messages) != 0 { - t.Errorf("User 2 is not expected to receive any messages, %d received.", len(helper.results[1].messages)) - } - if len(helper.hubMessages) != 0 { - t.Errorf("Hubhelper.route did not expect any messages, however %d received.", len(helper.hubMessages)) + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + if helper.topic.lastID != 0 { + t.Errorf("Topic.lastID: expected forbidden publish to keep 0, found %d", helper.topic.lastID) + } + if len(helper.results[0].messages) != 1 { + t.Fatalf("User 1 is expected to receive one ctrl message vs %d received.", len(helper.results[0].messages)) + } + resp := helper.results[0].messages[0].(*ServerComMessage) + if resp.Ctrl == nil || resp.Ctrl.Code != 403 { + t.Fatalf("User 1 expected 403 ctrl, received %#v", resp) + } + if len(helper.results[1].messages) != 0 { + t.Errorf("User 2 is not expected to receive any messages, %d received.", len(helper.results[1].messages)) + } + if len(helper.hubMessages) != 0 { + t.Errorf("Hubhelper.route did not expect any messages, however %d received.", len(helper.hubMessages)) + } + }) } }