Skip to content
quarry Artifact [0af81ca6e1]
Login

Artifact [0af81ca6e1]

Artifact 0af81ca6e149316a4623634657a43f131dd1ccf1b3031f82ac71732455ccf550:


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<>]+)>`)

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