Files
sm-bm-plugin/controllers/api/chat.go
Jakub Zych 0642fb1d08 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.
2026-10-26 15:46:13 +01:00

319 lines
8.8 KiB
Go

package api
import (
"net/http"
"sort"
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"
)
// chatNonMemberJSON is the whole answer of /chat for a user outside the
// chat group.
type chatNonMemberJSON struct {
Member bool `json:"member"`
}
// 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 {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
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
}
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
}