feat: add im group mention flows

This commit is contained in:
richjohnrambo
2026-08-12 15:33:56 +08:00
parent 943ebe1397
commit 774a448c12
5 changed files with 345 additions and 0 deletions
+161
View File
@@ -1115,6 +1115,93 @@ func serveIMFriends(wrt http.ResponseWriter, req *http.Request) {
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor}) writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
} }
func serveIMGroupResource(wrt http.ResponseWriter, req *http.Request) {
topic, tail, ok := groupResourcePath(req.URL.Path)
if !ok {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil)
return
}
if tail == "members" {
serveIMGroupMembers(wrt, req, topic)
return
}
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil)
}
func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
if types.GetTopicCat(topic) != types.TopicCatGrp {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
selfSub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
selfAccess := types.ModeNone
if selfSub != nil {
selfAccess = selfSub.ModeGiven & selfSub.ModeWant
}
if selfSub == nil || !selfAccess.IsReader() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 30, 100)
cursor, err := parseIMNonNegativeCursor(req.URL.Query().Get("cursor"))
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
return
}
query := strings.TrimSpace(req.URL.Query().Get("query"))
mentionableOnly := parseIMBool(req.URL.Query().Get("mentionable"))
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000})
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
items := make([]map[string]any, 0, limit)
seen := 0
nextCursor := ""
for _, member := range members {
access := member.ModeGiven & member.ModeWant
mentionable := access.IsJoiner() && access.IsReader()
if mentionableOnly && !mentionable {
continue
}
item := groupMemberPayload(member, mentionable)
if query != "" && !groupMemberMatches(item, query) {
continue
}
if seen < cursor {
seen++
continue
}
if len(items) == limit {
nextCursor = strconv.Itoa(seen)
break
}
items = append(items, item)
seen++
}
permissions := map[string]any{
"can_mention_all": selfAccess.IsAdmin(),
}
writeIMSuccess(wrt, req, map[string]any{
"topic": topic,
"items": items,
"next_cursor": nextCursor,
"permissions": permissions,
})
}
func serveIMFriendRequestAction(wrt http.ResponseWriter, req *http.Request) { func serveIMFriendRequestAction(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost { if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
@@ -1525,6 +1612,27 @@ func parseIMLimit(raw string, fallback, max int) int {
return limit return limit
} }
func parseIMNonNegativeCursor(raw string) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, nil
}
cursor, err := strconv.Atoi(raw)
if err != nil || cursor < 0 {
return 0, errors.New("invalid cursor")
}
return cursor, nil
}
func parseIMBool(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "yes", "y":
return true
default:
return false
}
}
func conversationMatches(sub types.Subscription, query string) bool { func conversationMatches(sub types.Subscription, query string) bool {
if strings.Contains(strings.ToLower(sub.Topic), query) { if strings.Contains(strings.ToLower(sub.Topic), query) {
return true return true
@@ -1560,6 +1668,45 @@ func receiptMemberPayload(sub types.Subscription) map[string]any {
} }
} }
func groupMemberPayload(sub types.Subscription, mentionable bool) map[string]any {
access := sub.ModeGiven & sub.ModeWant
userID := types.ParseUserId(sub.User)
user := &types.User{ObjHeader: types.ObjHeader{Id: userID.String()}, Public: sub.GetPublic(), Trusted: sub.GetTrusted()}
data := userSummaryPayload(user, "")
data["role"] = groupMemberRole(access)
data["mentionable"] = mentionable
data["acs"] = map[string]any{
"mode": access.String(),
"want": sub.ModeWant.String(),
"given": sub.ModeGiven.String(),
}
return data
}
func groupMemberRole(access types.AccessMode) string {
switch {
case access.IsOwner():
return "owner"
case access.IsAdmin():
return "admin"
default:
return "member"
}
}
func groupMemberMatches(item map[string]any, query string) bool {
query = strings.ToLower(strings.TrimSpace(query))
if query == "" {
return true
}
for _, key := range []string{"display_name", "user_id"} {
if value, ok := item[key]; ok && strings.Contains(strings.ToLower(fmt.Sprint(value)), query) {
return true
}
}
return false
}
func authorizeIMMessageMutation(principal *imPrincipal, topic string, seq int, allowAdmin bool) (*types.Message, bool, error) { func authorizeIMMessageMutation(principal *imPrincipal, topic string, seq int, allowAdmin bool) (*types.Message, bool, error) {
if principal == nil { if principal == nil {
return nil, false, types.ErrPermissionDenied return nil, false, types.ErrPermissionDenied
@@ -2068,6 +2215,20 @@ func conversationContextTopicFromPath(path string) string {
return strings.TrimSuffix(strings.TrimPrefix(path[idx:], prefix), suffix) return strings.TrimSuffix(strings.TrimPrefix(path[idx:], prefix), suffix)
} }
func groupResourcePath(path string) (string, string, bool) {
const prefix = "/v1/im/groups/"
idx := strings.Index(path, prefix)
if idx < 0 {
return "", "", false
}
rest := strings.TrimPrefix(path[idx:], prefix)
topic, tail, ok := strings.Cut(rest, "/")
if !ok || topic == "" || tail == "" {
return "", "", false
}
return topic, strings.Trim(tail, "/"), true
}
func friendRequestActionFromPath(path string) (string, string) { func friendRequestActionFromPath(path string) (string, string) {
const prefix = "/v1/im/friend-requests/" const prefix = "/v1/im/friend-requests/"
idx := strings.Index(path, prefix) idx := strings.Index(path, prefix)
+47
View File
@@ -821,6 +821,53 @@ func TestServeIMMessageReceipts(t *testing.T) {
} }
} }
func TestServeIMGroupMembers(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSubs := store.Subs
oldTopics := store.Topics
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
store.Subs = subs
store.Topics = topics
defer func() {
store.Subs = oldSubs
store.Topics = oldTopics
}()
subs.EXPECT().Get(types.TenantID(7), "grpMention", types.Uid(42), false).Return(&types.Subscription{
TenantID: 7,
User: types.Uid(42).String(),
Topic: "grpMention",
ModeWant: types.ModeCFull,
ModeGiven: types.ModeCFull,
}, nil)
alice := types.Subscription{User: types.Uid(101).UserId(), ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic}
alice.SetPublic(map[string]any{"display_name": "Alice"})
bob := types.Subscription{User: types.Uid(102).UserId(), ModeWant: types.ModeJoin, ModeGiven: types.ModeJoin}
bob.SetPublic(map[string]any{"display_name": "Bob"})
topics.EXPECT().GetUsers(types.TenantID(7), "grpMention", gomock.Any()).Return([]types.Subscription{alice, bob}, nil)
req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpMention/members?query=ali&mentionable=true", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMGroupResource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["display_name"] != "Alice" || items[0].(map[string]any)["mentionable"] != true {
t.Fatalf("unexpected members response: %#v", data)
}
if data["permissions"].(map[string]any)["can_mention_all"] != true {
t.Fatalf("expected can_mention_all permission: %#v", data)
}
}
func TestServeIMMessageRecall(t *testing.T) { func TestServeIMMessageRecall(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
+1
View File
@@ -794,6 +794,7 @@ func main() {
mux.HandleFunc(config.ApiPath+"v1/im/system-notifications/", serveIMSystemNotificationRead) mux.HandleFunc(config.ApiPath+"v1/im/system-notifications/", serveIMSystemNotificationRead)
mux.HandleFunc(config.ApiPath+"v1/im/topics/", serveIMTopicMessageAction) mux.HandleFunc(config.ApiPath+"v1/im/topics/", serveIMTopicMessageAction)
mux.HandleFunc(config.ApiPath+"v1/im/conversations/", serveIMConversationContext) mux.HandleFunc(config.ApiPath+"v1/im/conversations/", serveIMConversationContext)
mux.HandleFunc(config.ApiPath+"v1/im/groups/", serveIMGroupResource)
mux.HandleFunc(config.ApiPath+"v1/im/friend-requests", serveIMFriendRequests) mux.HandleFunc(config.ApiPath+"v1/im/friend-requests", serveIMFriendRequests)
mux.HandleFunc(config.ApiPath+"v1/im/friend-requests/", serveIMFriendRequestAction) mux.HandleFunc(config.ApiPath+"v1/im/friend-requests/", serveIMFriendRequestAction)
mux.HandleFunc(config.ApiPath+"v1/im/blocks/", serveIMBlockUser) mux.HandleFunc(config.ApiPath+"v1/im/blocks/", serveIMBlockUser)
+69
View File
@@ -1052,6 +1052,14 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
return types.ErrPermissionDenied return types.ErrPermissionDenied
} }
} }
if t.cat == types.TopicCatGrp && head != nil && msg.sess != nil && msg.sess.authLvl != auth.LevelRoot {
normalized, err := t.validateAndNormalizeMentions(asUid, head)
if err != nil {
msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp))
return err
}
head = normalized
}
if t.cat == types.TopicCatP2P && msg.sess != nil && msg.sess.authLvl != auth.LevelRoot && if t.cat == types.TopicCatP2P && msg.sess != nil && msg.sess.authLvl != auth.LevelRoot &&
store.Relations != nil && store.Store != nil && store.Store.IsOpen() { store.Relations != nil && store.Store != nil && store.Store.IsOpen() {
peer := t.p2pOtherUser(asUid) peer := t.p2pOtherUser(asUid)
@@ -1196,6 +1204,67 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
} }
} }
func (t *Topic) validateAndNormalizeMentions(asUid types.Uid, head map[string]any) (map[string]any, error) {
mentionAll, _ := head["mention_all"].(bool)
rawMentions, hasMentions := head["mentions"]
if !mentionAll && !hasMentions {
return head, nil
}
pud := t.perUser[asUid]
access := pud.modeGiven & pud.modeWant
if mentionAll && !access.IsAdmin() {
return nil, types.ErrPermissionDenied
}
validMembers := make(map[string]struct{}, len(t.perUser))
for uid, pud := range t.perUser {
mode := pud.modeGiven & pud.modeWant
if !pud.deleted && mode.IsJoiner() && mode.IsReader() {
validMembers[uid.UserId()] = struct{}{}
}
}
normalized := make(map[string]any, len(head))
for key, value := range head {
normalized[key] = value
}
if hasMentions {
filtered := filterValidMentions(rawMentions, validMembers)
if len(filtered) == 0 {
delete(normalized, "mentions")
} else {
normalized["mentions"] = filtered
}
}
return normalized, nil
}
func filterValidMentions(raw any, validMembers map[string]struct{}) []map[string]any {
mentions, ok := raw.([]any)
if !ok {
if typed, ok := raw.([]map[string]any); ok {
mentions = make([]any, 0, len(typed))
for _, item := range typed {
mentions = append(mentions, item)
}
}
}
if len(mentions) == 0 {
return nil
}
filtered := make([]map[string]any, 0, len(mentions))
for _, item := range mentions {
mention, ok := item.(map[string]any)
if !ok {
continue
}
userID, _ := mention["user_id"].(string)
if _, ok = validMembers[userID]; !ok {
continue
}
filtered = append(filtered, mention)
}
return filtered
}
// handleNoteBroadcast fans out {note} -> {info} messages to recipients in a master topic. // handleNoteBroadcast fans out {note} -> {info} messages to recipients in a master topic.
// This is a NON-proxy broadcast (at master topic). // This is a NON-proxy broadcast (at master topic).
func (t *Topic) handleNoteBroadcast(msg *ClientComMessage) { func (t *Topic) handleNoteBroadcast(msg *ClientComMessage) {
+67
View File
@@ -761,6 +761,73 @@ func TestHandleBroadcastDataRejectsUserSystemType(t *testing.T) {
} }
} }
func TestHandleBroadcastDataRejectsMentionAllByMember(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}
helper.setUp(t, numUsers, types.TopicCatGrp, "grp-test", true)
defer helper.tearDown()
pud := helper.topic.perUser[helper.uids[0]]
pud.modeWant = types.ModeCPublic
pud.modeGiven = types.ModeCPublic
helper.topic.perUser[helper.uids[0]] = pud
from := helper.uids[0].UserId()
msg := &ClientComMessage{
Id: "mention-all-1",
AsUser: from,
Pub: &MsgClientPub{
Topic: "grp-test",
Head: map[string]any{"mention_all": true},
Content: "hello all",
},
sess: helper.sessions[0],
}
helper.topic.handleClientMsg(msg)
helper.finish()
verifyResponseCodes(helper.results[0], []int{http.StatusForbidden}, t)
if helper.topic.lastID != 0 {
t.Fatalf("Topic.lastID: expected 0, got %d", helper.topic.lastID)
}
}
func TestHandleBroadcastDataFiltersInvalidMentions(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}
helper.setUp(t, numUsers, types.TopicCatGrp, "grp-test", true)
defer helper.tearDown()
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) {
mentions, ok := msg.Head["mentions"].([]map[string]any)
if !ok || len(mentions) != 1 || mentions[0]["user_id"] != helper.uids[1].UserId() {
t.Fatalf("unexpected normalized mentions: %#v", msg.Head["mentions"])
}
return &types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil
})
from := helper.uids[0].UserId()
msg := &ClientComMessage{
Id: "mention-1",
AsUser: from,
Pub: &MsgClientPub{
Topic: "grp-test",
Head: map[string]any{"mentions": []any{
map[string]any{"user_id": helper.uids[1].UserId(), "offset": 0, "length": 2},
map[string]any{"user_id": types.Uid(99).UserId(), "offset": 3, "length": 2},
}},
Content: "hi",
NoEcho: true,
},
sess: helper.sessions[0],
}
helper.topic.handleClientMsg(msg)
helper.finish()
verifyResponseCodes(helper.results[0], []int{http.StatusAccepted}, t)
}
func TestHandleBroadcastDataInactiveTopic(t *testing.T) { func TestHandleBroadcastDataInactiveTopic(t *testing.T) {
numUsers := 2 numUsers := 2
helper := TopicTestHelper{} helper := TopicTestHelper{}