feat: delete-only chat moderation and embed search and resolve
- ChatContext implements golem15.chat's MessageModerator: moderator
group members who are chat members delete in group conversations
only, never in DMs
- GET /chat/embeds?type=deadline|class|reading&q= (members only,
at most 20 rows, escaped ILIKE, q at most 100 characters)
- POST /chat/embeds/resolve {refs} (members only, at most 50 distinct
refs, 8 KiB body): current data or null per ref
This commit is contained in:
@@ -10,8 +10,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
_ chatcontracts.Context = (*ChatContext)(nil)
|
||||
_ chancontracts.AccessResolver = (*ChatAccess)(nil)
|
||||
_ chatcontracts.Context = (*ChatContext)(nil)
|
||||
_ chatcontracts.MessageModerator = (*ChatContext)(nil)
|
||||
_ chancontracts.AccessResolver = (*ChatAccess)(nil)
|
||||
)
|
||||
|
||||
// The BM chat context: one context of type bm with id 1, whose members are
|
||||
@@ -75,7 +76,8 @@ func ChatMemberIDs(ctx context.Context, db *gorm.DB, group string) ([]uint, erro
|
||||
|
||||
// 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.
|
||||
// moderator reads no DM and edits no foreign message. Moderators delete
|
||||
// through CanModerateMessage, in group conversations only.
|
||||
type ChatContext struct {
|
||||
db *gorm.DB
|
||||
group string
|
||||
@@ -113,6 +115,25 @@ func (c *ChatContext) CanOverseeChats(context.Context, uint) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// CanModerateMessage is golem15.chat's delete-only moderation hook (D-10):
|
||||
// a member of the moderator group who is also a chat-group member may
|
||||
// delete other people's messages in group conversations (# ogólny). In a
|
||||
// DM the answer is always false, so moderators never act on private
|
||||
// conversations (D-24). It grants no viewing or editing.
|
||||
func (c *ChatContext) CanModerateMessage(ctx context.Context, userID uint, conversationType string) (bool, error) {
|
||||
if conversationType != "group" || userID == 0 || c.moderatorGroup == "" {
|
||||
return false, nil
|
||||
}
|
||||
member, err := c.HasMember(ctx, userID)
|
||||
if err != nil || !member {
|
||||
return false, err
|
||||
}
|
||||
if c.isMember != nil {
|
||||
return c.isMember(ctx, c.db, userID, c.moderatorGroup)
|
||||
}
|
||||
return IsChatModerator(ctx, c.db, userID, c.moderatorGroup)
|
||||
}
|
||||
|
||||
// HasMember reports whether userID is in the chat group.
|
||||
func (c *ChatContext) HasMember(ctx context.Context, userID uint) (bool, error) {
|
||||
if userID == 0 || c.group == "" {
|
||||
|
||||
289
controllers/api/chat_embeds.go
Normal file
289
controllers/api/chat_embeds.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/jakub/sm-bm-plugin/classes"
|
||||
"git.golem15.com/jakub/sm-bm-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The entity types a chat message can embed (D-14). Materials and quizzes
|
||||
// are deferred and are not accepted.
|
||||
const (
|
||||
embedTypeDeadline = "deadline"
|
||||
embedTypeClass = "class"
|
||||
embedTypeReading = "reading"
|
||||
)
|
||||
|
||||
// embedSearchLimit is the number of rows one picker search answers.
|
||||
const embedSearchLimit = 20
|
||||
|
||||
// embedResolveLimit is the number of distinct refs one resolve call takes.
|
||||
const embedResolveLimit = 50
|
||||
|
||||
var embedTypes = []string{embedTypeDeadline, embedTypeClass, embedTypeReading}
|
||||
|
||||
var chatEmbedsRules = []lagoon.RequestRule{
|
||||
{Field: "type", Rules: append(lagoon.ParseRules("required"), lagoon.In(embedTypes...))},
|
||||
{Field: "q", Rules: lagoon.ParseRules("nullable|string|max:100")},
|
||||
}
|
||||
|
||||
// embedRef is one entity reference a message stores in its metadata.
|
||||
type embedRef struct {
|
||||
Type string `json:"type"`
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
|
||||
// embedJSON is one embeddable item: its type and id, and the same JSON the
|
||||
// rest of the API serves for it in the field named after the type.
|
||||
type embedJSON struct {
|
||||
Type string `json:"type"`
|
||||
ID uint `json:"id"`
|
||||
Deadline *deadlineJSON `json:"deadline,omitempty"`
|
||||
Session *sessionJSON `json:"session,omitempty"`
|
||||
Reading *readingCardJSON `json:"reading,omitempty"`
|
||||
}
|
||||
|
||||
// readingCardJSON is a reading as an embed card shows it: no per-user read
|
||||
// or owned state.
|
||||
type readingCardJSON struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Author *string `json:"author"`
|
||||
IsRequired bool `json:"is_required"`
|
||||
CourseID uint `json:"course_id"`
|
||||
Course *courseRef `json:"course"`
|
||||
}
|
||||
|
||||
// resolveEmbedsRequest is the body of POST /chat/embeds/resolve.
|
||||
type resolveEmbedsRequest struct {
|
||||
Refs []embedRef `json:"refs"`
|
||||
}
|
||||
|
||||
// embedKey is the key of a ref in the resolve answer, "{type}:{id}".
|
||||
func embedKey(typ string, id uint) string {
|
||||
return typ + ":" + strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
|
||||
// escapeLike escapes the ILIKE wildcards % and _ and the escape character
|
||||
// itself, so user input matches literally.
|
||||
func escapeLike(s string) string {
|
||||
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
|
||||
}
|
||||
|
||||
// parseEmbedRefs checks refs: a supported type, a positive id, at most
|
||||
// embedResolveLimit distinct refs. Duplicates collapse and the first-seen
|
||||
// order is kept. Any problem returns validation errors and no refs.
|
||||
func parseEmbedRefs(refs []embedRef) ([]embedRef, map[string][]string) {
|
||||
errs := map[string][]string{}
|
||||
seen := map[embedRef]bool{}
|
||||
out := make([]embedRef, 0, len(refs))
|
||||
for i, r := range refs {
|
||||
if !slices.Contains(embedTypes, r.Type) {
|
||||
errs[fmt.Sprintf("refs.%d.type", i)] = []string{"The selected type is invalid."}
|
||||
continue
|
||||
}
|
||||
if r.ID == 0 {
|
||||
errs[fmt.Sprintf("refs.%d.id", i)] = []string{"The id must be at least 1."}
|
||||
continue
|
||||
}
|
||||
if seen[r] {
|
||||
continue
|
||||
}
|
||||
seen[r] = true
|
||||
out = append(out, r)
|
||||
}
|
||||
if len(out) > embedResolveLimit {
|
||||
errs["refs"] = []string{fmt.Sprintf("The refs may not have more than %d items.", embedResolveLimit)}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return nil, errs
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// chatMember answers 404 itself and returns false when userID is not in the
|
||||
// chat group (500 when the check fails).
|
||||
func (h *Handlers) chatMember(w http.ResponseWriter, r *http.Request, db *gorm.DB, userID uint) bool {
|
||||
member, err := classes.IsChatMember(r.Context(), db, userID, h.ChatGroup)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return false
|
||||
}
|
||||
if !member {
|
||||
writeNotFound(w)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ChatEmbeds answers GET /chat/embeds?type=deadline|class|reading&q=, the
|
||||
// embed picker search, to chat-group members (404 otherwise). It lists at
|
||||
// most embedSearchLimit items: upcoming deadlines soonest first, upcoming
|
||||
// class sessions soonest first, readings required first then by title. q
|
||||
// (at most 100 characters) narrows by deadline title, course name or
|
||||
// reading title and author, case-insensitively.
|
||||
func (h *Handlers) ChatEmbeds(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := h.caller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in := query(r, "type", "q")
|
||||
if !h.validate(w, r, in, chatEmbedsRules) {
|
||||
return
|
||||
}
|
||||
db, ok := h.db(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !h.chatMember(w, r, db, userID) {
|
||||
return
|
||||
}
|
||||
items, err := searchEmbeds(db, str(in, "type"), strings.TrimSpace(str(in, "q")), h.now().UTC())
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
writeData(w, items)
|
||||
}
|
||||
|
||||
func searchEmbeds(db *gorm.DB, typ, q string, now time.Time) ([]embedJSON, error) {
|
||||
pattern := "%" + escapeLike(q) + "%"
|
||||
out := []embedJSON{}
|
||||
switch typ {
|
||||
case embedTypeDeadline:
|
||||
var rows []models.Deadline
|
||||
find := db.Preload("Course").Where("jz_bm_deadlines.due_at >= ?", now)
|
||||
if q != "" {
|
||||
find = find.Where("jz_bm_deadlines.title ILIKE ?", pattern)
|
||||
}
|
||||
if err := find.Order("jz_bm_deadlines.due_at, jz_bm_deadlines.id").Limit(embedSearchLimit).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, d := range newDeadlinesJSON(rows) {
|
||||
out = append(out, embedJSON{Type: embedTypeDeadline, ID: d.ID, Deadline: &d})
|
||||
}
|
||||
case embedTypeClass:
|
||||
var rows []models.ClassSession
|
||||
find := preloadSession(db).Where("jz_bm_class_sessions.ends_at > ?", now)
|
||||
if q != "" {
|
||||
find = find.Joins("JOIN jz_bm_courses ON jz_bm_courses.id = jz_bm_class_sessions.course_id").
|
||||
Where("jz_bm_courses.name ILIKE ?", pattern)
|
||||
}
|
||||
if err := find.Order("jz_bm_class_sessions.starts_at, jz_bm_class_sessions.id").Limit(embedSearchLimit).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, s := range newSessionsJSON(rows) {
|
||||
out = append(out, embedJSON{Type: embedTypeClass, ID: s.ID, Session: &s})
|
||||
}
|
||||
case embedTypeReading:
|
||||
var rows []models.Reading
|
||||
find := db.Preload("Course").Where("jz_bm_readings.course_id IS NOT NULL")
|
||||
if q != "" {
|
||||
find = find.Where("(jz_bm_readings.title ILIKE ? OR jz_bm_readings.author ILIKE ?)", pattern, pattern)
|
||||
}
|
||||
if err := find.Order("jz_bm_readings.is_required DESC, jz_bm_readings.title, jz_bm_readings.id").Limit(embedSearchLimit).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, rd := range rows {
|
||||
card := newReadingCardJSON(rd)
|
||||
out = append(out, embedJSON{Type: embedTypeReading, ID: rd.ID, Reading: &card})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func newReadingCardJSON(r models.Reading) readingCardJSON {
|
||||
return readingCardJSON{
|
||||
ID: r.ID,
|
||||
Title: r.Title,
|
||||
Author: r.Author,
|
||||
IsRequired: r.IsRequired,
|
||||
CourseID: idOf(r.CourseID),
|
||||
Course: newCourseRef(r.Course),
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveChatEmbeds answers POST /chat/embeds/resolve {"refs": [{type, id}]}
|
||||
// for chat-group members (404 otherwise): {"data": {"{type}:{id}": item or
|
||||
// null}} for every requested ref. Items are read on every call, so a
|
||||
// renamed or moved deadline shows its current title and date. A missing
|
||||
// row, or a class session without a course, is null: the neutral
|
||||
// unavailable card (D-04).
|
||||
func (h *Handlers) ResolveChatEmbeds(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := h.caller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body resolveEmbedsRequest
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
writeValidation(w, map[string][]string{"refs": {"The request body must be a JSON object with a refs list."}})
|
||||
return
|
||||
}
|
||||
refs, errs := parseEmbedRefs(body.Refs)
|
||||
if len(errs) > 0 {
|
||||
writeValidation(w, errs)
|
||||
return
|
||||
}
|
||||
db, ok := h.db(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !h.chatMember(w, r, db, userID) {
|
||||
return
|
||||
}
|
||||
out, err := resolveEmbeds(db, refs)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
writeData(w, out)
|
||||
}
|
||||
|
||||
func resolveEmbeds(db *gorm.DB, refs []embedRef) (map[string]*embedJSON, error) {
|
||||
out := make(map[string]*embedJSON, len(refs))
|
||||
ids := map[string][]uint{}
|
||||
for _, ref := range refs {
|
||||
out[embedKey(ref.Type, ref.ID)] = nil
|
||||
ids[ref.Type] = append(ids[ref.Type], ref.ID)
|
||||
}
|
||||
if list := ids[embedTypeDeadline]; len(list) > 0 {
|
||||
var rows []models.Deadline
|
||||
if err := db.Preload("Course").Where("jz_bm_deadlines.id IN ?", list).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, d := range newDeadlinesJSON(rows) {
|
||||
out[embedKey(embedTypeDeadline, d.ID)] = &embedJSON{Type: embedTypeDeadline, ID: d.ID, Deadline: &d}
|
||||
}
|
||||
}
|
||||
if list := ids[embedTypeClass]; len(list) > 0 {
|
||||
var rows []models.ClassSession
|
||||
if err := preloadSession(db).Where("jz_bm_class_sessions.id IN ?", list).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, s := range newSessionsJSON(rows) {
|
||||
out[embedKey(embedTypeClass, s.ID)] = &embedJSON{Type: embedTypeClass, ID: s.ID, Session: &s}
|
||||
}
|
||||
}
|
||||
if list := ids[embedTypeReading]; len(list) > 0 {
|
||||
var rows []models.Reading
|
||||
if err := db.Preload("Course").Where("jz_bm_readings.id IN ? AND jz_bm_readings.course_id IS NOT NULL", list).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, rd := range rows {
|
||||
card := newReadingCardJSON(rd)
|
||||
out[embedKey(embedTypeReading, rd.ID)] = &embedJSON{Type: embedTypeReading, ID: rd.ID, Reading: &card}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -132,6 +132,8 @@ func (p *Plugin) Routes(r pact.Router) error {
|
||||
if p.app != nil && p.app.HasPlugin("golem15.chat") {
|
||||
g.Get("/chat", h.Chat, bucketChat)
|
||||
g.Get("/chat/members", h.ChatMembers, bucketChat)
|
||||
g.Get("/chat/embeds", h.ChatEmbeds, bucketChat)
|
||||
g.Post("/chat/embeds/resolve", h.ResolveChatEmbeds, bucketChat, "body.limit:8192")
|
||||
}
|
||||
})
|
||||
// The management API: what bm:seed does, plus materials,
|
||||
|
||||
Reference in New Issue
Block a user