feat: chat bootstrap, members and group sync
GET /_bm/api/v1/chat gives a chat-group member ogólny (the bm/1 group conversation, adopted once as the system default channel under an advisory lock), its unread and mention counts, the member's DMs newest message first, the moderator flag and the composer limits. Every member bootstrap syncs participants with the group: missing members join ogólny, users no longer in the group lose their rows in every bm/1 conversation. A non-member gets only member:false and is pruned. GET /_bm/api/v1/chat/members lists members (id, name, surname, handle, is_bot from golem15.chatbots when active) and assigns missing handles; non-members get 404. The chatchannels access policy (ChatAccess) is installed from a database hook: bm/1 for members only, nobody manages channels. bm:chat-sync creates the groups when missing, syncs and assigns handles. Migration 20261026130000_seed_jz_bm_chat_groups creates studenci and moderatorzy without members; its rollback keeps them.
This commit is contained in:
@@ -2,18 +2,105 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
userclasses "git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
chatclasses "git.golem15.com/golem15/sm-chat-plugin/classes"
|
||||
chatmodels "git.golem15.com/golem15/sm-chat-plugin/models"
|
||||
chanclasses "git.golem15.com/golem15/sm-chatchannels-plugin/classes"
|
||||
usermodels "git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/jakub/sm-bm-plugin/classes"
|
||||
"git.golem15.com/jakub/sm-bm-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// chatBootstrapJSON is the answer of /chat. A user outside the chat group
|
||||
// gets only member: false.
|
||||
type chatBootstrapJSON struct {
|
||||
// chatNonMemberJSON is the whole answer of /chat for a user outside the
|
||||
// chat group.
|
||||
type chatNonMemberJSON struct {
|
||||
Member bool `json:"member"`
|
||||
}
|
||||
|
||||
// Chat answers GET /chat, the chat bootstrap: whether the caller is in the
|
||||
// chat group.
|
||||
// chatBootstrapJSON is the answer of /chat for a chat-group member.
|
||||
type chatBootstrapJSON struct {
|
||||
Member bool `json:"member"`
|
||||
Moderator bool `json:"moderator"`
|
||||
Context chatContextJSON `json:"context"`
|
||||
General chatGeneralJSON `json:"general"`
|
||||
Conversations []chatConversationJSON `json:"conversations"`
|
||||
Limits chatLimitsJSON `json:"limits"`
|
||||
}
|
||||
|
||||
type chatContextJSON struct {
|
||||
Type string `json:"type"`
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
|
||||
// chatGeneralJSON is ogólny: its conversation, unread messages and unread
|
||||
// mentions of the caller.
|
||||
type chatGeneralJSON struct {
|
||||
ConversationID uint `json:"conversation_id"`
|
||||
Name string `json:"name"`
|
||||
Unread int64 `json:"unread"`
|
||||
Mentions int64 `json:"mentions"`
|
||||
}
|
||||
|
||||
// chatConversationJSON is one of the caller's DMs.
|
||||
type chatConversationJSON struct {
|
||||
ID uint `json:"id"`
|
||||
Type string `json:"type"`
|
||||
OtherUserID uint `json:"other_user_id"`
|
||||
Unread int64 `json:"unread"`
|
||||
LastMessage *chatLastMessageJSON `json:"last_message"`
|
||||
}
|
||||
|
||||
type chatLastMessageJSON struct {
|
||||
ID uint `json:"id"`
|
||||
SenderID uint `json:"sender_id"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// chatLimitsJSON are the composer limits: attachments per message, their
|
||||
// total size and embeds per message.
|
||||
type chatLimitsJSON struct {
|
||||
MaxFiles int `json:"max_files"`
|
||||
MaxBytes int `json:"max_bytes"`
|
||||
MaxEmbeds int `json:"max_embeds"`
|
||||
}
|
||||
|
||||
// chatMemberJSON is one chat-group member as the mention picker and the DM
|
||||
// picker show them.
|
||||
type chatMemberJSON struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Surname string `json:"surname"`
|
||||
Handle string `json:"handle"`
|
||||
IsBot bool `json:"is_bot"`
|
||||
}
|
||||
|
||||
// chatServices returns golem15.chat's and golem15.chatchannels' services,
|
||||
// or answers an opaque 500 and false while either is missing.
|
||||
func (h *Handlers) chatServices(w http.ResponseWriter) (*chatclasses.Service, *chanclasses.ChannelService, bool) {
|
||||
var chat *chatclasses.Service
|
||||
var channels *chanclasses.ChannelService
|
||||
if h.ChatService != nil {
|
||||
chat = h.ChatService()
|
||||
}
|
||||
if h.Channels != nil {
|
||||
channels = h.Channels()
|
||||
}
|
||||
if chat == nil || channels == nil {
|
||||
writeOpaque500(w)
|
||||
return nil, nil, false
|
||||
}
|
||||
return chat, channels, true
|
||||
}
|
||||
|
||||
// Chat answers GET /chat, the chat bootstrap. A user outside the chat group
|
||||
// gets only member: false and loses any participant rows left in the BM
|
||||
// chat. A member is joined to ogólny (created and adopted as a channel on
|
||||
// first use), the participants are synced with the chat group, and the
|
||||
// answer carries ogólny's unread and mention counts, the caller's DMs
|
||||
// (newest message first), the moderator flag and the composer limits.
|
||||
func (h *Handlers) Chat(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := h.caller(w, r)
|
||||
if !ok {
|
||||
@@ -23,14 +110,209 @@ func (h *Handlers) Chat(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
member := false
|
||||
if h.ChatGroup != "" {
|
||||
var err error
|
||||
member, err = userclasses.HasGroupCode(r.Context(), db, userID, h.ChatGroup)
|
||||
if err != nil {
|
||||
chat, channels, ok := h.chatServices(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
member, err := classes.IsChatMember(ctx, db, userID, h.ChatGroup)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if !member {
|
||||
if err := classes.PruneChatParticipant(ctx, db, userID); err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
writeData(w, chatNonMemberJSON{Member: false})
|
||||
return
|
||||
}
|
||||
writeData(w, chatBootstrapJSON{Member: member})
|
||||
general, channel, err := classes.EnsureGeneral(ctx, db, chat, channels, userID)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if _, _, err := classes.SyncChatParticipants(ctx, db, general.ID, h.ChatGroup); err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
views, err := chat.GetConversationsForUser(ctx, userID, classes.ChatContextType, classes.ChatContextID)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
mentions, err := channels.MentionCountFor(ctx, userID, channel)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
moderator, err := classes.IsChatModerator(ctx, db, userID, h.ChatModeratorGroup)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
out := chatBootstrapJSON{
|
||||
Member: true,
|
||||
Moderator: moderator,
|
||||
Context: chatContextJSON{Type: classes.ChatContextType, ID: classes.ChatContextID},
|
||||
General: chatGeneralJSON{ConversationID: general.ID, Name: classes.ChatGeneralName, Mentions: mentions},
|
||||
Limits: chatLimitsJSON{
|
||||
MaxFiles: chatclasses.MaxAttachments,
|
||||
MaxBytes: h.ChatMaxUploadBytes,
|
||||
MaxEmbeds: h.ChatMaxEmbeds,
|
||||
},
|
||||
}
|
||||
dms := []chatclasses.ConversationView{}
|
||||
for _, v := range views {
|
||||
switch {
|
||||
case v.Conversation.ID == general.ID:
|
||||
out.General.Unread = v.UnreadCount
|
||||
case v.Conversation.Type == chatmodels.TypeDM:
|
||||
dms = append(dms, v)
|
||||
}
|
||||
}
|
||||
others, err := otherParticipants(db, dms, userID)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
out.Conversations = newChatConversationsJSON(dms, others)
|
||||
writeData(w, out)
|
||||
}
|
||||
|
||||
// otherParticipants maps each DM to the participant who is not userID.
|
||||
func otherParticipants(db *gorm.DB, dms []chatclasses.ConversationView, userID uint) (map[uint]uint, error) {
|
||||
others := map[uint]uint{}
|
||||
if len(dms) == 0 {
|
||||
return others, nil
|
||||
}
|
||||
ids := make([]uint, len(dms))
|
||||
for i, v := range dms {
|
||||
ids[i] = v.Conversation.ID
|
||||
}
|
||||
var rows []chatmodels.Participant
|
||||
err := db.Select("conversation_id", "user_id").
|
||||
Where("conversation_id IN ? AND user_id <> ?", ids, userID).
|
||||
Order("id").Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range rows {
|
||||
if _, ok := others[p.ConversationID]; !ok {
|
||||
others[p.ConversationID] = p.UserID
|
||||
}
|
||||
}
|
||||
return others, nil
|
||||
}
|
||||
|
||||
// newChatConversationsJSON lists the DMs newest message first; DMs without
|
||||
// a message come last, newest conversation first.
|
||||
func newChatConversationsJSON(dms []chatclasses.ConversationView, others map[uint]uint) []chatConversationJSON {
|
||||
sort.SliceStable(dms, func(i, j int) bool {
|
||||
a, b := dms[i].LastMessage, dms[j].LastMessage
|
||||
switch {
|
||||
case a == nil && b == nil:
|
||||
return dms[i].Conversation.ID > dms[j].Conversation.ID
|
||||
case a == nil || b == nil:
|
||||
return b == nil
|
||||
case a.CreatedAt == nil || b.CreatedAt == nil:
|
||||
return a.ID > b.ID
|
||||
case !a.CreatedAt.Equal(*b.CreatedAt):
|
||||
return a.CreatedAt.After(*b.CreatedAt)
|
||||
}
|
||||
return a.ID > b.ID
|
||||
})
|
||||
out := make([]chatConversationJSON, 0, len(dms))
|
||||
for _, v := range dms {
|
||||
row := chatConversationJSON{
|
||||
ID: v.Conversation.ID,
|
||||
Type: chatmodels.TypeDM,
|
||||
OtherUserID: others[v.Conversation.ID],
|
||||
Unread: v.UnreadCount,
|
||||
}
|
||||
if m := v.LastMessage; m != nil {
|
||||
last := &chatLastMessageJSON{ID: m.ID, SenderID: m.SenderID}
|
||||
if m.Content != nil {
|
||||
last.Content = *m.Content
|
||||
}
|
||||
if m.CreatedAt != nil {
|
||||
last.CreatedAt = models.FormatDateTime(*m.CreatedAt)
|
||||
}
|
||||
row.LastMessage = last
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ChatMembers answers GET /chat/members: the chat-group members with their
|
||||
// name, surname, @handle and bot flag, ordered by id. Members without a
|
||||
// handle get one first. Anyone outside the chat group gets 404.
|
||||
func (h *Handlers) ChatMembers(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := h.caller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
db, ok := h.db(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
member, err := classes.IsChatMember(ctx, db, userID, h.ChatGroup)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if !member {
|
||||
writeNotFound(w)
|
||||
return
|
||||
}
|
||||
if _, err := classes.AssignMissingHandles(ctx, db, h.ChatGroup); err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
ids, err := classes.ChatMemberIDs(ctx, db, h.ChatGroup)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
out := make([]chatMemberJSON, 0, len(ids))
|
||||
if len(ids) == 0 {
|
||||
writeData(w, out)
|
||||
return
|
||||
}
|
||||
var users []usermodels.User
|
||||
if err := db.Select("id", "name", "surname", "handle").Where("id IN ?", ids).Order("id").Find(&users).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
bots := map[uint]bool{}
|
||||
if h.ChatBots {
|
||||
var botIDs []uint
|
||||
if err := db.Table("golem15_chatbots_bot_flags").Where("is_bot AND user_id IN ?", ids).Pluck("user_id", &botIDs).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
for _, id := range botIDs {
|
||||
bots[id] = true
|
||||
}
|
||||
}
|
||||
for _, u := range users {
|
||||
out = append(out, chatMemberJSON{
|
||||
ID: u.ID,
|
||||
Name: deref(u.Name),
|
||||
Surname: deref(u.Surname),
|
||||
Handle: deref(u.Handle),
|
||||
IsBot: bots[u.ID],
|
||||
})
|
||||
}
|
||||
writeData(w, out)
|
||||
}
|
||||
|
||||
func deref(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user