Skip to content
quarry Artifact [aa6b2890e4]
Login

Artifact [aa6b2890e4]

Artifact aa6b2890e40f0566f181c181551cc9a852b0ad8e59af12df281135d7f40c7e76:


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
		},
	})
}