feat: add conversation system action messages

This commit is contained in:
richjohnrambo
2026-09-01 13:12:24 +08:00
parent 39e7e019c7
commit edced2675b
8 changed files with 213 additions and 41 deletions
+33 -1
View File
@@ -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 幂等、重试和安全
+3 -2
View File
@@ -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 {
+116
View File
@@ -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)
+9
View File
@@ -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,
+1 -1
View File
@@ -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
+2
View File
@@ -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 {
+10 -2
View File
@@ -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) {
+39 -35
View File
@@ -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))
}
})
}
}