Skip to content
quarry Artifact [c2b27280e5]
Login

Artifact [c2b27280e5]

Artifact c2b27280e54388aabb6f453cb900e14269aad3b481ecec53fc20051c0f8be9a0:


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"
	"unicode/utf8"

	"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

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