feat: add conversation system action messages
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user