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:
47
chat.go
47
chat.go
@@ -2,10 +2,13 @@ package bm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
chatcontracts "git.golem15.com/golem15/sm-chat-plugin/contracts"
|
||||
chanclasses "git.golem15.com/golem15/sm-chatchannels-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/jakub/sm-bm-plugin/classes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -24,9 +27,10 @@ const (
|
||||
)
|
||||
|
||||
// registerChat makes jz.bm the host of the BM chat: it registers the bm
|
||||
// context alias on golem15.chat's registry. Without golem15.chat it does
|
||||
// nothing, so jz.bm keeps working (and its tests keep activating) without
|
||||
// the chat plugins; jz.bm does not Require them.
|
||||
// context alias on golem15.chat's registry and installs the channel access
|
||||
// policy. Without golem15.chat it does nothing, so jz.bm keeps working (and
|
||||
// its tests keep activating) without the chat plugins; jz.bm does not
|
||||
// Require them.
|
||||
func (p *Plugin) registerChat(app *backpack.App) error {
|
||||
if app == nil || !app.HasPlugin("golem15.chat") {
|
||||
return nil
|
||||
@@ -35,17 +39,44 @@ func (p *Plugin) registerChat(app *backpack.App) error {
|
||||
if !ok || reg == nil {
|
||||
return errors.New("jz.bm: golem15.chat must be listed before jz.bm in summer.yaml")
|
||||
}
|
||||
return reg.Add(p.ID(), chatcontracts.Registration{Alias: classes.ChatContextType, Loader: p.loadChatContext})
|
||||
if err := reg.Add(p.ID(), chatcontracts.Registration{Alias: classes.ChatContextType, Loader: p.loadChatContext}); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.installChatAccess(app)
|
||||
}
|
||||
|
||||
// loadChatContext is the loader of the bm alias. The only context is id 1;
|
||||
// any other id is unknown, which golem15.chat answers with its not-a-member
|
||||
// error.
|
||||
func (p *Plugin) loadChatContext(_ context.Context, db *gorm.DB, id uint) (chatcontracts.Context, error) {
|
||||
if id != classes.ChatContextID {
|
||||
return nil, nil
|
||||
func (p *Plugin) loadChatContext(ctx context.Context, db *gorm.DB, id uint) (chatcontracts.Context, error) {
|
||||
return classes.ChatContextLoader(p.chatGroup, p.chatModeratorGroup)(ctx, db, id)
|
||||
}
|
||||
|
||||
// installChatAccess installs BM's access policy on golem15.chatchannels'
|
||||
// channel service. The service is published from chatchannels' own
|
||||
// database hook, which runs before this one because chatchannels boots
|
||||
// first; under a database-less boot (tests, route:list) neither runs.
|
||||
func (p *Plugin) installChatAccess(app *backpack.App) error {
|
||||
if app == nil || !app.HasPlugin("golem15.chatchannels") {
|
||||
return nil
|
||||
}
|
||||
return classes.NewChatContext(db, p.chatGroup(), p.chatModeratorGroup()), nil
|
||||
return lagoon.OnDatabase(app, func(_ *sql.DB, _ *gorm.DB) error {
|
||||
svc, ok := app.Lookup[*chanclasses.ChannelService]()
|
||||
if !ok || svc == nil {
|
||||
return errors.New("jz.bm: golem15.chatchannels must be listed before jz.bm in summer.yaml")
|
||||
}
|
||||
svc.SetAccessResolver(classes.NewChatAccess(p.database, p.chatGroup(), nil))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// database returns the published application database, or nil.
|
||||
func (p *Plugin) database() *gorm.DB {
|
||||
if p.app == nil {
|
||||
return nil
|
||||
}
|
||||
db, _ := p.app.Lookup[*gorm.DB]()
|
||||
return db
|
||||
}
|
||||
|
||||
// chatGroup is the code of the user group whose members are in the chat.
|
||||
|
||||
@@ -4,11 +4,15 @@ import (
|
||||
"context"
|
||||
|
||||
chatcontracts "git.golem15.com/golem15/sm-chat-plugin/contracts"
|
||||
chancontracts "git.golem15.com/golem15/sm-chatchannels-plugin/contracts"
|
||||
userclasses "git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var _ chatcontracts.Context = (*ChatContext)(nil)
|
||||
var (
|
||||
_ chatcontracts.Context = (*ChatContext)(nil)
|
||||
_ chancontracts.AccessResolver = (*ChatAccess)(nil)
|
||||
)
|
||||
|
||||
// The BM chat context: one context of type bm with id 1, whose members are
|
||||
// the members of one user group.
|
||||
@@ -29,6 +33,46 @@ const (
|
||||
ChatBroadcastChannel = "chat:context:1"
|
||||
)
|
||||
|
||||
// MemberFunc reports whether userID belongs to the user group with code
|
||||
// group. IsChatMember is the production check; tests inject their own.
|
||||
type MemberFunc func(ctx context.Context, db *gorm.DB, userID uint, group string) (bool, error)
|
||||
|
||||
// IsChatMember reports whether userID is in the chat group group. No user,
|
||||
// no group code or no database is never a member.
|
||||
func IsChatMember(ctx context.Context, db *gorm.DB, userID uint, group string) (bool, error) {
|
||||
if userID == 0 || group == "" || db == nil {
|
||||
return false, nil
|
||||
}
|
||||
return userclasses.HasGroupCode(ctx, db, userID, group)
|
||||
}
|
||||
|
||||
// IsChatModerator reports whether userID is in the moderator group
|
||||
// moderatorGroup.
|
||||
func IsChatModerator(ctx context.Context, db *gorm.DB, userID uint, moderatorGroup string) (bool, error) {
|
||||
return IsChatMember(ctx, db, userID, moderatorGroup)
|
||||
}
|
||||
|
||||
// ChatMemberIDs lists the ids of the users in group that are not deleted,
|
||||
// ordered by id. A group code with no group row has no member.
|
||||
func ChatMemberIDs(ctx context.Context, db *gorm.DB, group string) ([]uint, error) {
|
||||
ids := []uint{}
|
||||
if group == "" || db == nil {
|
||||
return ids, nil
|
||||
}
|
||||
err := db.WithContext(ctx).
|
||||
Table("users_groups").
|
||||
Joins("JOIN user_groups ON user_groups.id = users_groups.user_group_id").
|
||||
Joins("JOIN users ON users.id = users_groups.user_id").
|
||||
Where("user_groups.code = ? AND users.deleted_at IS NULL", group).
|
||||
Distinct().
|
||||
Order("users_groups.user_id").
|
||||
Pluck("users_groups.user_id", &ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// ChatContext is the golem15.chat context of the BM chat. Membership is the
|
||||
// user group named by group; nobody oversees other people's chats, so a
|
||||
// moderator reads no DM and edits no foreign message.
|
||||
@@ -36,6 +80,7 @@ type ChatContext struct {
|
||||
db *gorm.DB
|
||||
group string
|
||||
moderatorGroup string
|
||||
isMember MemberFunc
|
||||
}
|
||||
|
||||
// NewChatContext returns the BM chat context over db for the chat group
|
||||
@@ -44,6 +89,12 @@ func NewChatContext(db *gorm.DB, group, moderatorGroup string) *ChatContext {
|
||||
return &ChatContext{db: db, group: group, moderatorGroup: moderatorGroup}
|
||||
}
|
||||
|
||||
// WithMembership replaces the group check (IsChatMember) and returns c.
|
||||
func (c *ChatContext) WithMembership(fn MemberFunc) *ChatContext {
|
||||
c.isMember = fn
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *ChatContext) ID() uint { return ChatContextID }
|
||||
func (c *ChatContext) Type() string { return ChatContextType }
|
||||
func (c *ChatContext) ChatEnabled() bool { return true }
|
||||
@@ -67,25 +118,63 @@ func (c *ChatContext) HasMember(ctx context.Context, userID uint) (bool, error)
|
||||
if userID == 0 || c.group == "" {
|
||||
return false, nil
|
||||
}
|
||||
return userclasses.HasGroupCode(ctx, c.db, userID, c.group)
|
||||
if c.isMember != nil {
|
||||
return c.isMember(ctx, c.db, userID, c.group)
|
||||
}
|
||||
return IsChatMember(ctx, c.db, userID, c.group)
|
||||
}
|
||||
|
||||
// Members lists the ids of the chat group's users that are not deleted.
|
||||
func (c *ChatContext) Members(ctx context.Context) ([]uint, error) {
|
||||
ids := []uint{}
|
||||
if c.group == "" {
|
||||
return ids, nil
|
||||
}
|
||||
err := c.db.WithContext(ctx).
|
||||
Table("users_groups").
|
||||
Joins("JOIN user_groups ON user_groups.id = users_groups.user_group_id").
|
||||
Joins("JOIN users ON users.id = users_groups.user_id").
|
||||
Where("user_groups.code = ? AND users.deleted_at IS NULL", c.group).
|
||||
Distinct().
|
||||
Order("users_groups.user_id").
|
||||
Pluck("users_groups.user_id", &ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
return ChatMemberIDs(ctx, c.db, c.group)
|
||||
}
|
||||
|
||||
// ChatContextLoader is the golem15.chat loader of the bm alias. The only
|
||||
// context is id 1; any other id is unknown (nil, nil), which golem15.chat
|
||||
// answers with its not-a-member error. The group codes are read per load,
|
||||
// so a config change needs no re-registration.
|
||||
func ChatContextLoader(group, moderatorGroup func() string) chatcontracts.Loader {
|
||||
return func(_ context.Context, db *gorm.DB, id uint) (chatcontracts.Context, error) {
|
||||
if id != ChatContextID {
|
||||
return nil, nil
|
||||
}
|
||||
return NewChatContext(db, group(), moderatorGroup()), nil
|
||||
}
|
||||
}
|
||||
|
||||
// ChatAccess is golem15.chatchannels' access policy for BM: chat-group
|
||||
// members may access context bm/1 and nothing else, and nobody manages it,
|
||||
// so no channel is ever created through the channel service.
|
||||
type ChatAccess struct {
|
||||
db func() *gorm.DB
|
||||
group string
|
||||
isMember MemberFunc
|
||||
}
|
||||
|
||||
// NewChatAccess returns the access policy for the chat group group. db is
|
||||
// resolved per check; isMember nil means IsChatMember.
|
||||
func NewChatAccess(db func() *gorm.DB, group string, isMember MemberFunc) *ChatAccess {
|
||||
if isMember == nil {
|
||||
isMember = IsChatMember
|
||||
}
|
||||
return &ChatAccess{db: db, group: group, isMember: isMember}
|
||||
}
|
||||
|
||||
// CanAccessContext is true only for a chat-group member and context bm/1.
|
||||
// Any error, or no database, denies.
|
||||
func (a *ChatAccess) CanAccessContext(ctx context.Context, userID uint, contextType string, contextID uint) bool {
|
||||
if contextType != ChatContextType || contextID != ChatContextID || userID == 0 || a.group == "" || a.db == nil {
|
||||
return false
|
||||
}
|
||||
db := a.db()
|
||||
if db == nil {
|
||||
return false
|
||||
}
|
||||
ok, err := a.isMember(ctx, db, userID, a.group)
|
||||
return err == nil && ok
|
||||
}
|
||||
|
||||
// CanManageContext is always false: nobody creates or edits channels.
|
||||
func (a *ChatAccess) CanManageContext(context.Context, uint, string, uint) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
196
classes/chat_sync.go
Normal file
196
classes/chat_sync.go
Normal file
@@ -0,0 +1,196 @@
|
||||
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
|
||||
}
|
||||
59
console/chat_sync.go
Normal file
59
console/chat_sync.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package console
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/jakub/sm-bm-plugin/classes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// chatSyncName is the name of the chat sync command.
|
||||
const chatSyncName = "bm:chat-sync"
|
||||
|
||||
// ChatSyncCommand returns the chat sync console command. withDB runs the
|
||||
// command's work with the application's database; groups returns the chat
|
||||
// group code and the moderator group code. It never adds anyone to a group:
|
||||
// members are assigned by hand in the backend.
|
||||
func ChatSyncCommand(withDB func(ctx context.Context, fn func(*gorm.DB) error) error, groups func() (string, string)) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: chatSyncName,
|
||||
Description: "Create the chat groups if missing, sync ogólny participants with the chat group and give members a handle",
|
||||
Run: func(ctx context.Context, _ bonfire.Input, out bonfire.Output) error {
|
||||
group, moderatorGroup := groups()
|
||||
return withDB(ctx, func(db *gorm.DB) error {
|
||||
created, err := classes.EnsureChatGroups(ctx, db, group, moderatorGroup)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: groups: %w", chatSyncName, err)
|
||||
}
|
||||
general, err := classes.FindGeneral(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: ogólny: %w", chatSyncName, err)
|
||||
}
|
||||
var generalID uint
|
||||
if general != nil {
|
||||
generalID = general.ID
|
||||
}
|
||||
added, removed, err := classes.SyncChatParticipants(ctx, db, generalID, group)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: participants: %w", chatSyncName, err)
|
||||
}
|
||||
handles, err := classes.AssignMissingHandles(ctx, db, group)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: handles: %w", chatSyncName, err)
|
||||
}
|
||||
out.Printf("groups created: %d\n", created)
|
||||
if general == nil {
|
||||
out.Printf("ogólny does not exist yet; it is created when a member first opens the chat\n")
|
||||
} else {
|
||||
out.Printf("ogólny participants added: %d\n", added)
|
||||
}
|
||||
out.Printf("participant rows removed: %d\n", removed)
|
||||
out.Printf("handles assigned: %d\n", handles)
|
||||
out.Success(chatSyncName + ": chat group " + group + " synced")
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -99,10 +99,14 @@ func (p *Plugin) AdminControllers() []pact.AdminController {
|
||||
return generatedAdminControllers()
|
||||
}
|
||||
|
||||
// Commands returns the generated commands, bm:seed and websockets:health.
|
||||
// API tokens are managed application-wide by golem15.user.
|
||||
// Commands returns the generated commands, bm:seed, bm:chat-sync and
|
||||
// websockets:health. API tokens are managed application-wide by
|
||||
// golem15.user.
|
||||
func (p *Plugin) Commands() []bonfire.Command {
|
||||
cmds := append(generatedCommands(), console.SeedCommand(p.withDB))
|
||||
cmds = append(cmds, console.ChatSyncCommand(p.withDB, func() (string, string) {
|
||||
return p.chatGroup(), p.chatModeratorGroup()
|
||||
}))
|
||||
return append(cmds, centrifugo.Commands(p.app)...)
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ func generatedMigrations() []*gormigrate.Migration {
|
||||
updates.MakeCourseIDNullable(),
|
||||
updates.AddLecturerToClassSessions(),
|
||||
updates.CreateReadingOwnership(),
|
||||
updates.SeedChatGroups(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +131,7 @@ func (p *Plugin) Routes(r pact.Router) error {
|
||||
g.Delete("/calendar-feed", h.DeleteCalendarFeed)
|
||||
if p.app != nil && p.app.HasPlugin("golem15.chat") {
|
||||
g.Get("/chat", h.Chat, bucketChat)
|
||||
g.Get("/chat/members", h.ChatMembers, bucketChat)
|
||||
}
|
||||
})
|
||||
// The management API: what bm:seed does, plus materials,
|
||||
|
||||
31
updates/20261026130000_seed_jz_bm_chat_groups.go
Normal file
31
updates/20261026130000_seed_jz_bm_chat_groups.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SeedChatGroups returns the 20261026130000_seed_jz_bm_chat_groups gormigrate
|
||||
// entry: the user groups studenci (the chat's members) and moderatorzy (its
|
||||
// moderators), each created only when no group with that code exists
|
||||
// (user_groups.code is not unique). No user is put into either group;
|
||||
// Jakub assigns members in the backend.
|
||||
//
|
||||
// Rollback leaves both groups in place: they may already carry memberships
|
||||
// assigned in the backend, or have existed before this migration.
|
||||
func SeedChatGroups() *gormigrate.Migration {
|
||||
return &gormigrate.Migration{
|
||||
ID: "20261026130000_seed_jz_bm_chat_groups",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return execAll(tx, `INSERT INTO user_groups (name, code, description, created_at, updated_at)
|
||||
SELECT 'Studenci', 'studenci', 'Czat BM (jz.bm)', NOW(), NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM user_groups WHERE code = 'studenci');
|
||||
INSERT INTO user_groups (name, code, description, created_at, updated_at)
|
||||
SELECT 'Moderatorzy czatu', 'moderatorzy', 'Czat BM (jz.bm)', NOW(), NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM user_groups WHERE code = 'moderatorzy')`)
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user