package api
// chatnotify.go is what a person hears from a repository's chat channel:
// the messages that name them (the default), every message, or nothing.
// A member opening Saur0n's chat found no way to follow it and nothing
// saying that a mention already reaches them by email. The choice is the
// channel header's bell; delivery rides on chatMentionAt, which every
// post to a repository's channel already passes through.
import (
"context"
"fmt"
"log"
"regexp"
"strings"
"unicode/utf8"
"craftdesign.group/quarry/internal/chat"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// ChatNotifyStore is the chat_notify table, satisfied by *oltp.DB.
type ChatNotifyStore interface {
SetChatNotify(repo, account, mode string) error
ChatNotify(repo, account string) string
ChatNotifyEvery(repo string) ([]string, error)
}
// chatNotifyMode is what who hears from repo's channel: mentions when
// the forge keeps no choices.
func (d Deps) chatNotifyMode(repo, who string) string {
if d.ChatNotify == nil {
return oltp.ChatMentions
}
return d.ChatNotify.ChatNotify(repo, who)
}
// chatSummaryRunes bounds the message an "every message" notification
// quotes: the inbox row and the digest's line, not the conversation.
const chatSummaryRunes = 280
// markdownLink is a markdown link, [text](url).
var markdownLink = regexp.MustCompile(`\[((?:\\.|[^\]\\])*)\]\([^)\s]*\)`)
// htmlAnchor is fossil's own anchor markup, <a href='...'>text</a>.
var htmlAnchor = regexp.MustCompile(`<a href=(?:'[^'<>]*'|"[^"<>]*")>([^<]*)</a>`)
// chatPlain is a chat line as a digest quotes it: a link reads as its
// text, as it would on the page, rather than as its markup. The channel
// keeps the markdown; this is the plain line beside a name in an email.
// A line bridged before the bridge turned fossil's anchors into
// markdown still holds them, and reads as its words too.
func chatPlain(text string) string {
text = chat.StripFossilFormatting(htmlAnchor.ReplaceAllString(text, "$1"))
return markdownLink.ReplaceAllStringFunc(text, func(l string) string {
label := markdownLink.FindStringSubmatch(l)[1]
return strings.NewReplacer(`\[`, `[`, `\]`, `]`, `\\`, `\`).Replace(label)
})
}
// chatEveryAt tells everybody who follows every message in repo's
// channel about message seq, except its author and anybody it named (a
// mention already told them, by the louder route). A follower who has
// since lost the chat letter hears nothing: following is a choice made
// while they could read the channel, not a grant.
func (d Deps) chatEveryAt(repo, text, from string, seq uint64, named map[string]bool) {
if d.ChatNotify == nil {
return
}
followers, err := d.ChatNotify.ChatNotifyEvery(repo)
if err != nil {
log.Printf("chat notify %s: followers: %v", repo, err)
return
}
summary := chatPlain(text)
if utf8.RuneCountInString(summary) > chatSummaryRunes {
summary = string([]rune(summary)[:chatSummaryRunes]) + "…"
}
for _, who := range followers {
// A bridged line is by "fossil:<login>"; the same login here is
// taken to be the same person, who needs no copy of their own.
if who == from || "fossil:"+who == from || named[who] {
continue
}
if d.CapsFor != nil && !d.CapsFor(who, repo).HasAny("C") {
continue
}
d.fireNotify(NotifyEvent{
Workflow: "quarry-chat", To: who,
Payload: map[string]any{
"repo": repo, "actor": from, "text": text, "summary": summary,
"surface": "chat", "ref": fmt.Sprintf("message #%d", seq),
"link": fmt.Sprintf("/r/%s/chat/%d", repo, seq),
},
TxnID: fmt.Sprintf("chat:%s:%d:%s", repo, seq, who),
})
}
}
type chatNotifyIn struct {
Repo string `json:"repo" validate:"required,max=64"`
}
type chatNotifySetIn struct {
Repo string `json:"repo" validate:"required,max=64"`
// Mode is mentions, all or off.
Mode string `json:"mode" validate:"required,oneof=mentions|all|off"`
}
type chatNotifyOut struct {
Repo string `json:"repo"`
Mode string `json:"mode"`
}
func registerChatNotify(r *ops.Registry, d Deps) error {
if err := ops.Register(r, ops.Op[chatNotifyIn, chatNotifyOut]{
Name: "chat.notify",
Caps: "C",
Doc: "What the signed-in account hears from a repository's chat channel: `mentions` (messages that name it; " +
"the default), `all` (every message, batched into an email digest per channel) or `off`.",
Kind: ops.Query,
Auth: ops.Token,
Route: "GET /api/v1/r/{repo}/chat/notify",
Run: func(ctx context.Context, in chatNotifyIn) (chatNotifyOut, error) {
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return chatNotifyOut{}, err
}
return chatNotifyOut{Repo: in.Repo, Mode: d.chatNotifyMode(in.Repo, author(ctx))}, nil
},
}); err != nil {
return err
}
return ops.Register(r, ops.Op[chatNotifySetIn, chatNotifyOut]{
Name: "chat.notify.set",
Caps: "C",
Doc: "Choose what the signed-in account hears from a repository's chat channel: `mentions`, `all` or `off`. " +
"`off` silences the channel's mentions too.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/chat/notify",
Run: func(ctx context.Context, in chatNotifySetIn) (chatNotifyOut, error) {
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return chatNotifyOut{}, err
}
if d.ChatNotify == nil {
return chatNotifyOut{}, fmt.Errorf("this forge keeps no chat notification choices: %w", ops.ErrUnavailable)
}
if err := d.ChatNotify.SetChatNotify(in.Repo, author(ctx), in.Mode); err != nil {
return chatNotifyOut{}, refusalAsInvalid(err)
}
return chatNotifyOut{Repo: in.Repo, Mode: in.Mode}, nil
},
})
}