Files
sm-bm-plugin/classes/chat_sync.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

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
}