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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user