feat: add im group mention flows
This commit is contained in:
@@ -1115,6 +1115,93 @@ func serveIMFriends(wrt http.ResponseWriter, req *http.Request) {
|
||||
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) {
|
||||
if req.Method != http.MethodPost {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
if strings.Contains(strings.ToLower(sub.Topic), query) {
|
||||
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) {
|
||||
if principal == nil {
|
||||
return nil, false, types.ErrPermissionDenied
|
||||
@@ -2068,6 +2215,20 @@ func conversationContextTopicFromPath(path string) string {
|
||||
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) {
|
||||
const prefix = "/v1/im/friend-requests/"
|
||||
idx := strings.Index(path, prefix)
|
||||
|
||||
@@ -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) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
@@ -794,6 +794,7 @@ func main() {
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/system-notifications/", serveIMSystemNotificationRead)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/topics/", serveIMTopicMessageAction)
|
||||
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/", serveIMFriendRequestAction)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/blocks/", serveIMBlockUser)
|
||||
|
||||
@@ -1052,6 +1052,14 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
|
||||
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 &&
|
||||
store.Relations != nil && store.Store != nil && store.Store.IsOpen() {
|
||||
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.
|
||||
// This is a NON-proxy broadcast (at master topic).
|
||||
func (t *Topic) handleNoteBroadcast(msg *ClientComMessage) {
|
||||
|
||||
@@ -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) {
|
||||
numUsers := 2
|
||||
helper := TopicTestHelper{}
|
||||
|
||||
Reference in New Issue
Block a user