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
+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))
}
})
}
}