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]*\)`)
// markdownAutolink is a markdown autolink, <https://...>: the channel
// shows the address as a link, so the digest shows the address alone
// (the libfossil digest quoted "<https://fossil-scm.org/...>" whole).
var markdownAutolink = regexp.MustCompile(`<(https?://[^\s<>]+)>`)
// codeSpans drops the backticks of markdown code spans, `text` or
// “text“: the channel shows the text as code, so the digest shows
// the text (the libfossil digest quoted "`fsl_db`" with its ticks). As
// CommonMark has it, a span closes at the next run of exactly as many
// backticks as opened it; a run with no closer is left as written.
func codeSpans(text string) string {
if !strings.Contains(text, "`") {
return text
}
run := func(i int) int {
j := i
for j < len(text) && text[j] == '`' {
j++
}
return j - i
}
var b strings.Builder
for i := 0; i < len(text); {
if text[i] != '`' {
b.WriteByte(text[i])
i++
continue
}
n := run(i)
end := -1
for j := i + n; j < len(text); {
if text[j] != '`' {
j++
continue
}
if m := run(j); m == n {
end = j
break
} else {
j += m
}
}
if end < 0 {
b.WriteString(text[i : i+n])
i += n
continue
}
inner := text[i+n : end]
if len(inner) > 1 && inner[0] == ' ' && inner[len(inner)-1] == ' ' && strings.Trim(inner, " ") != "" {
inner = inner[1 : len(inner)-1]
}
b.WriteString(inner)
i = end + n
}
return b.String()
}
// 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 = htmlAnchor.ReplaceAllString(text, "$1")
text = markdownAutolink.ReplaceAllString(text, "$1")
text = codeSpans(text)
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 strings.HasPrefix(from, "fossil:") {
// Fossil's own markup, on a line bridged before the bridge tidied
// it. Only there: a person's "use <div> here" is their words.
summary = chat.StripFossilFormatting(summary)
}
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
},
})
}