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.
197 lines
6.9 KiB
Go
197 lines
6.9 KiB
Go
package classes
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
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"
|
|
chanmodels "git.golem15.com/golem15/sm-chatchannels-plugin/models"
|
|
userclasses "git.golem15.com/golem15/sm-user-plugin/classes"
|
|
usermodels "git.golem15.com/golem15/sm-user-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// chatGeneralLock is the transaction advisory lock that serialises finding
|
|
// or creating ogólny, so members bootstrapping at the same moment end with
|
|
// one group conversation and one channel.
|
|
const chatGeneralLock int64 = 0x6a7a626d63686174
|
|
|
|
// chatGroupNames are the display names of the default group codes; any
|
|
// other code is named after itself.
|
|
var chatGroupNames = map[string]string{
|
|
"studenci": "Studenci",
|
|
"moderatorzy": "Moderatorzy czatu",
|
|
}
|
|
|
|
// chatMembersSQL selects the ids of the users in the group with code ?
|
|
// that are not deleted.
|
|
const chatMembersSQL = `SELECT DISTINCT users_groups.user_id
|
|
FROM users_groups
|
|
JOIN user_groups ON user_groups.id = users_groups.user_group_id
|
|
JOIN users ON users.id = users_groups.user_id
|
|
WHERE user_groups.code = ? AND users.deleted_at IS NULL`
|
|
|
|
// chatConversationsSQL selects the ids of the conversations of context
|
|
// bm/1 (ogólny and every DM).
|
|
const chatConversationsSQL = `SELECT id FROM golem15_chat_conversations WHERE context_type = ? AND context_id = ?`
|
|
|
|
// EnsureChatGroups creates a user group for each code that has none and
|
|
// returns how many it created. It never assigns a user to a group: members
|
|
// are added by hand in the backend.
|
|
func EnsureChatGroups(ctx context.Context, db *gorm.DB, codes ...string) (int64, error) {
|
|
if db == nil {
|
|
return 0, errors.New("jz.bm: chat groups need a database")
|
|
}
|
|
var created int64
|
|
seen := map[string]bool{}
|
|
for _, code := range codes {
|
|
if code == "" || seen[code] {
|
|
continue
|
|
}
|
|
seen[code] = true
|
|
name := chatGroupNames[code]
|
|
if name == "" {
|
|
name = code
|
|
}
|
|
res := db.WithContext(ctx).Exec(`INSERT INTO user_groups (name, code, description, created_at, updated_at)
|
|
SELECT ?, ?, ?, NOW(), NOW()
|
|
WHERE NOT EXISTS (SELECT 1 FROM user_groups WHERE code = ?)`, name, code, "Czat BM (jz.bm)", code)
|
|
if res.Error != nil {
|
|
return created, res.Error
|
|
}
|
|
created += res.RowsAffected
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
// EnsureGeneral finds or creates the group conversation of context bm/1
|
|
// (golem15.chat GetGroupChat, which also joins actorID) and adopts it as
|
|
// the system default channel ogólny, so mentions accrue on it. Adoption is
|
|
// idempotent. actorID must be a chat-group member.
|
|
func EnsureGeneral(ctx context.Context, db *gorm.DB, chat *chatclasses.Service, channels *chanclasses.ChannelService, actorID uint) (*chatmodels.Conversation, *chanmodels.Channel, error) {
|
|
if db == nil || chat == nil || channels == nil {
|
|
return nil, nil, errors.New("jz.bm: ogólny needs the database and the chat services")
|
|
}
|
|
var conv *chatmodels.Conversation
|
|
var ch *chanmodels.Channel
|
|
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
|
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", chatGeneralLock).Error; err != nil {
|
|
return err
|
|
}
|
|
c, err := chat.WithDB(tx).GetGroupChat(ctx, actorID, ChatContextType, ChatContextID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
yes := true
|
|
adopted, err := channels.WithDB(tx).AdoptConversation(ctx, ChatContextType, ChatContextID, c.ID, chanclasses.AdoptConversationInput{
|
|
Name: ChatGeneralName,
|
|
Slug: ChatGeneralSlug,
|
|
IsSystem: &yes,
|
|
IsDefault: &yes,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
conv, ch = c, adopted
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return conv, ch, nil
|
|
}
|
|
|
|
// FindGeneral returns the group conversation of context bm/1, or nil when
|
|
// nobody has opened the chat yet.
|
|
func FindGeneral(ctx context.Context, db *gorm.DB) (*chatmodels.Conversation, error) {
|
|
var conv chatmodels.Conversation
|
|
err := db.WithContext(ctx).
|
|
Where("type = ? AND context_type = ? AND context_id = ?", chatmodels.TypeGroup, ChatContextType, ChatContextID).
|
|
Order("id").Take(&conv).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &conv, nil
|
|
}
|
|
|
|
// SyncChatParticipants makes the chat's participants follow the chat
|
|
// group, in one transaction: every member missing from ogólny (generalID)
|
|
// joins it, and users no longer in the group lose their participant rows
|
|
// in every conversation of context bm/1. Nothing is pruned while no group
|
|
// with that code exists, so a mistyped code cannot wipe the DMs. It returns
|
|
// how many rows it added and removed.
|
|
func SyncChatParticipants(ctx context.Context, db *gorm.DB, generalID uint, group string) (int64, int64, error) {
|
|
if db == nil {
|
|
return 0, 0, errors.New("jz.bm: chat sync needs a database")
|
|
}
|
|
if group == "" {
|
|
return 0, 0, errors.New("jz.bm: chat sync needs a chat group code")
|
|
}
|
|
var added, removed int64
|
|
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
|
if generalID != 0 {
|
|
res := tx.Exec(`INSERT INTO golem15_chat_participants (conversation_id, user_id, joined_at)
|
|
SELECT ?, m.user_id, NOW() FROM (`+chatMembersSQL+`) m
|
|
ON CONFLICT (conversation_id, user_id) DO NOTHING`, generalID, group)
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
added = res.RowsAffected
|
|
}
|
|
res := tx.Exec(`DELETE FROM golem15_chat_participants
|
|
WHERE conversation_id IN (`+chatConversationsSQL+`)
|
|
AND user_id NOT IN (`+chatMembersSQL+`)
|
|
AND EXISTS (SELECT 1 FROM user_groups WHERE code = ?)`, ChatContextType, ChatContextID, group, group)
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
removed = res.RowsAffected
|
|
return nil
|
|
})
|
|
return added, removed, err
|
|
}
|
|
|
|
// PruneChatParticipant removes userID from every conversation of context
|
|
// bm/1. It runs when someone outside the chat group asks for the chat.
|
|
func PruneChatParticipant(ctx context.Context, db *gorm.DB, userID uint) error {
|
|
if db == nil {
|
|
return errors.New("jz.bm: chat prune needs a database")
|
|
}
|
|
return db.WithContext(ctx).Exec(`DELETE FROM golem15_chat_participants
|
|
WHERE user_id = ? AND conversation_id IN (`+chatConversationsSQL+`)`, userID, ChatContextType, ChatContextID).Error
|
|
}
|
|
|
|
// AssignMissingHandles gives every chat-group member without a handle one
|
|
// (golem15.user AssignHandle), so they can be @mentioned, and returns how
|
|
// many it assigned.
|
|
func AssignMissingHandles(ctx context.Context, db *gorm.DB, group string) (int, error) {
|
|
if db == nil {
|
|
return 0, errors.New("jz.bm: handles need a database")
|
|
}
|
|
if group == "" {
|
|
return 0, nil
|
|
}
|
|
var users []usermodels.User
|
|
err := db.WithContext(ctx).
|
|
Where("id IN ("+chatMembersSQL+")", group).
|
|
Where("handle IS NULL OR handle = ''").
|
|
Order("id").Find(&users).Error
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
n := 0
|
|
for i := range users {
|
|
if err := userclasses.AssignHandle(ctx, db, &users[i]); err != nil {
|
|
return n, err
|
|
}
|
|
n++
|
|
}
|
|
return n, nil
|
|
}
|