// teamchat.go — RFC 0018 §"Gathering chat": a team's room carries the
// lines from every repository under it.
//
// Nothing is copied to build it. Every stored message already names the
// repository it was posted in, so the merged room is a READ across
// channels in one order (internal/chat.MergeBack). The team's own lines
// live in a channel of their own.
//
// NOT an OLTP table, which is what RFC 0018's schema block sketched.
// The chat plane is where chat lives: putting a team's lines in SQLite
// would have given them different durability from every other message,
// no streaming, no purge, and no robot log — four things they get for
// free as a channel, and four things somebody would later have to
// build twice. The RFC's own principle is "no new field and no
// duplication"; this follows it further than its schema did.
//
// The reply rule is the other half of the design, and it is what keeps
// this a second place to READ rather than a second place to talk:
// GitHub built team discussions and retired them for being the latter.
// A new message goes to the team; replying to a repository's line goes
// back to that repository, where the people watching it will see it.
package api
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"sort"
"time"
"craftdesign.group/quarry/internal/chat"
"craftdesign.group/quarry/internal/ops"
)
type teamChatIn struct {
Name string `json:"name" validate:"required,max=64"`
// Limit is the page. Cursor carries where each channel has been
// read back to; absent starts at the newest across all of them.
Limit int `json:"limit" validate:"min=0,max=200,default=50"`
Cursor string `json:"cursor" validate:"max=4096"`
}
type teamChatOut struct {
Team string `json:"team"`
// Messages are newest-first, which is the order a room is read in.
//
// chatMsgOut rather than chat.Message: a room's lines are RENDERED,
// like a channel's. Returning the raw store message meant the page
// fell back to escaped text, so an @mention in a team room was
// characters where the same words in a repository's channel were a
// link — which is what danmestas saw.
Messages []chatMsgOut `json:"messages"`
// Cursor pages further back; empty when every channel is exhausted,
// so a page with an empty cursor is the end of the room.
Cursor string `json:"cursor,omitempty"`
// Rooms names the channels this view is merging, so a reader can
// see what the room is made of rather than inferring it from
// whichever repositories happen to have spoken recently.
Rooms []string `json:"rooms"`
}
type teamChatPostIn struct {
Name string `json:"name" validate:"required,max=64"`
Text string `json:"text" validate:"required,max=4096"`
// Repo sends this line to a repository's own room instead of the
// team's. Set it when replying to a repository's line: the answer
// belongs where the question was asked.
Repo string `json:"repo" validate:"max=64"`
Ref uint64 `json:"ref"`
}
// The cursor crosses the wire, so it is opaque JSON in base64 rather
// than a format anybody is invited to construct. It carries a position
// per channel and nothing else — no authority, no repository the caller
// could swap — so a forged one can only ask for a page of rooms the
// gate has already allowed.
func encodeChatCursor(c chat.Cursor) (string, error) {
raw, err := json.Marshal(c)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func decodeChatCursor(s string) (chat.Cursor, error) {
if s == "" {
return chat.Cursor{}, nil
}
raw, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return chat.Cursor{}, fmt.Errorf("cursor: %w", ops.ErrInvalid)
}
var c chat.Cursor
if err := json.Unmarshal(raw, &c); err != nil {
return chat.Cursor{}, fmt.Errorf("cursor: %w", ops.ErrInvalid)
}
return c, nil
}
// chatCursorSpent reports whether every channel is exhausted, so the
// op can omit the cursor entirely and say "this is the end of the room"
// rather than hand back one that returns nothing.
func chatCursorSpent(codes []string, c chat.Cursor) bool {
for _, code := range codes {
if !c.Done[code] {
return false
}
}
return true
}
type teamRenderIn struct {
Name string `json:"name" validate:"required,max=64"`
// Repo is the room the line was said in, empty for the team's own.
Repo string `json:"repo" validate:"max=64"`
Text string `json:"text" validate:"required,max=4096"`
}
type teamRenderOut struct {
HTML string `json:"html"`
}
// registerTeamRender exposes the room's own renderer.
//
// Its one caller is the live stream: a line that ARRIVES has to be the
// same markup as the same line after a reload, or an @mention is a
// link until you refresh and then characters, or the other way about.
// markup.preview cannot serve it — that op resolves against a
// repository, and a line said to the TEAM has none.
//
// Gated on the room, not on a repository: this renders what the room
// shows, and the room is for its members.
func (d Deps) registerTeamRender(r *ops.Registry) error {
return ops.Register(r, ops.Op[teamRenderIn, teamRenderOut]{
Name: "team.chat.render",
Doc: "Render one of a team room's lines exactly as the room shows it. " +
"The live stream's renderer: a line that arrives must be the same markup as the same line after a reload.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/teams/{name}/chat/render",
Run: func(ctx context.Context, in teamRenderIn) (teamRenderOut, error) {
if err := d.requireTeamRoom(ctx, in.Name); err != nil {
return teamRenderOut{}, err
}
// A repository named here must actually be under this team,
// or the render would resolve a reader's text against a
// repository the room has nothing to do with.
if in.Repo != "" && d.Teams.TeamOfRepo(in.Repo) != in.Name {
return teamRenderOut{}, fmt.Errorf("%s is not under %s: %w",
in.Repo, in.Name, ops.ErrNotFound)
}
out := d.roomRender(ctx, []chat.Message{{
Kind: "msg", Repo: in.Repo, Text: in.Text, Grammar: chat.GrammarCaret}})
if len(out) == 0 {
return teamRenderOut{}, nil
}
return teamRenderOut{HTML: string(out[0].HTML)}, nil
},
})
}
// roomRender renders a merged room's lines, each against the
// repository it was SAID in.
//
// Per line and not per page, because a room is many channels: a
// reference in a line said in orchard means what orchard says it
// means, and rendering it against some other repository would resolve
// it wrongly or not at all. The repositories are resolved once each
// and reused, since a page of fifty lines is usually two or three
// rooms.
//
// A line said to the TEAM has no repository, and renders with none:
// @mentions are account links and need no index, while an artifact
// reference has nothing to resolve against — correctly, since a team
// has no artifacts. The resolver already answers nil for that.
func (d Deps) roomRender(ctx context.Context, msgs []chat.Message) []chatMsgOut {
out := make([]chatMsgOut, len(msgs))
// Resolved lazily: a repository the reader cannot see is left as
// no repository rather than failing the page, which is the same
// answer the board gives for a line it may not show.
seen := map[string]*Repo{}
nums := &robotNums{}
for i, m := range msgs {
var repo *Repo
if m.Repo != "" {
r, known := seen[m.Repo]
if !known {
r, _ = d.repoFor(ctx, m.Repo)
seen[m.Repo] = r
}
repo = r
}
if repo == nil {
// chatRenderOne reaches for repo.Index, so a line with no
// repository needs an empty one rather than a nil.
repo = &Repo{}
}
out[i] = d.chatRenderWith(ctx, nums, repo, m.Repo, m)
}
return out
}
// teamMentionAt fires mentions for a line said to the TEAM itself.
//
// Separate from chatMentionAt because both of its anchors are missing
// here. There is no repository to record a backlink against — a
// backlink row exists to put a line on an artifact's page, and a
// team's own channel has no artifact — and there is no repository in
// the link either: the line lives in the team's room, so that is
// where being told about it should take you.
//
// Who counts as mentionable is the ROOM's roster, which RFC 0018 says
// is the team's membership. Naming somebody who cannot open the room
// would send them to a page that refuses them.
func (d Deps) teamMentionAt(team, text, from string, seq uint64) {
if d.Teams == nil {
return
}
members, err := d.Teams.TeamMembers(team)
if err != nil {
return
}
in := make(map[string]bool, len(members))
for _, m := range members {
in[m.Account] = true
}
for _, who := range mentionsIn(text, func(handle string) bool { return in[handle] }) {
if who == from {
continue // naming yourself is not being told about yourself
}
d.fireNotify(NotifyEvent{
Workflow: "quarry-mention", To: who,
Payload: map[string]any{
"repo": team, "from": from, "text": text, "surface": "team chat",
"link": "/t/" + team + "/chat",
},
// Address-based on the SEQ, like every other mention key:
// the same line replayed triggers nothing new, and two
// different lines naming the same person are two.
TxnID: fmt.Sprintf("mention:team:%s:%d:%s", team, seq, who),
})
}
}
// teamRoomCodes is the team's own channel plus every repository's,
// skipping repositories this node does not serve.
func (d Deps) teamRoomCodes(team string) ([]string, []string, error) {
rows, err := d.Teams.TeamRepos(team)
if err != nil {
return nil, nil, err
}
var codes, names []string
for _, r := range rows {
h, ok := d.Repos.Get(r.Repo)
if !ok || h.Code == "" {
continue
}
codes = append(codes, h.Code)
names = append(names, r.Repo)
}
sort.Strings(names)
return chat.Codes(chat.TeamCode(team), codes), names, nil
}
// inTeamRoom is requireTeamRoom as a question, for the surfaces that
// draw the tab: a control that refuses is worse than one that is not
// there, and both answers have to come from the same place or the tab
// and the page disagree.
func (d Deps) inTeamRoom(ctx context.Context, team string) bool {
return d.requireTeamRoom(ctx, team) == nil
}
// requireTeamRoom gates the room on MEMBERSHIP.
//
// Not on the repositories' own chat capability, and deliberately not
// narrower per repository. The merged view mixes a team's own lines
// with its repositories', and the team's own lines are the team's
// business — so rather than filter the merge per repository and risk
// one wrong branch leaking a private room, the whole surface asks one
// question with one answer. A non-member reads a public repository's
// room at that repository, exactly as before.
func (d Deps) requireTeamRoom(ctx context.Context, team string) error {
if d.Teams == nil {
return errNoTeams()
}
if _, found := d.Teams.TeamByName(team); !found {
return fmt.Errorf("no team named %q: %w", team, ops.ErrNotFound)
}
who := author(ctx)
if who == "" {
return fmt.Errorf("a team's room is for its members: %w", ops.ErrForbidden)
}
if d.mayManageTeam(ctx, team) {
return nil
}
if _, ok := d.Teams.TeamMemberOf(team, who); ok {
return nil
}
return fmt.Errorf("only a member of %s may read its room: %w", team, ops.ErrForbidden)
}
func (d Deps) registerTeamChat(r *ops.Registry) error {
if err := ops.Register(r, ops.Op[teamChatIn, teamChatOut]{
Name: "team.chat",
Doc: "A team's room: its own lines and every line from the repositories under it, newest first, in one time order. " +
"Nothing is copied — each message already names the repository it was posted in. " +
"For the team's members; a non-member reads a repository's room at that repository.",
Kind: ops.Query,
Auth: ops.Token,
Route: "GET /api/v1/teams/{name}/chat",
Run: func(ctx context.Context, in teamChatIn) (teamChatOut, error) {
if err := d.requireTeamRoom(ctx, in.Name); err != nil {
return teamChatOut{}, err
}
if d.Chat == nil {
return teamChatOut{}, fmt.Errorf("this forge has no chat: %w", ops.ErrUnavailable)
}
codes, names, err := d.teamRoomCodes(in.Name)
if err != nil {
return teamChatOut{}, err
}
cur, err := decodeChatCursor(in.Cursor)
if err != nil {
return teamChatOut{}, err
}
msgs, next, err := chat.MergeBack(codes, cur, in.Limit, d.Chat.History)
if err != nil {
return teamChatOut{}, err
}
out := teamChatOut{Team: in.Name, Rooms: names,
Messages: d.roomRender(ctx, chat.Fold(msgs))}
if out.Messages == nil {
out.Messages = []chatMsgOut{}
}
if out.Rooms == nil {
out.Rooms = []string{}
}
// An empty cursor means the room has no more history, so a
// reader can stop rather than ask again and get nothing.
if !chatCursorSpent(codes, next) {
if out.Cursor, err = encodeChatCursor(next); err != nil {
return teamChatOut{}, err
}
}
return out, nil
},
}); err != nil {
return err
}
return ops.Register(r, ops.Op[teamChatPostIn, chatPostOut]{
Name: "team.chat.post",
Doc: "Say something in a team's room. With `repo`, the line goes to THAT repository's room instead — which is what replying to one of its lines does, so an answer lands where the people watching that repository will see it. " +
"Posting to a repository needs the chat capability on it; posting to the team needs membership.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/teams/{name}/chat",
Run: func(ctx context.Context, in teamChatPostIn) (chatPostOut, error) {
if err := d.requireTeamRoom(ctx, in.Name); err != nil {
return chatPostOut{}, err
}
if d.Chat == nil {
return chatPostOut{}, fmt.Errorf("this forge has no chat: %w", ops.ErrUnavailable)
}
code := chat.TeamCode(in.Name)
target := ""
if in.Repo != "" {
// A repository's room is that repository's, so its own
// capability answers — membership of the team is not a
// way into a room the team does not hold, and the
// repository must actually be under this team.
if d.Teams.TeamOfRepo(in.Repo) != in.Name {
return chatPostOut{}, fmt.Errorf("%s is not under %s: %w",
in.Repo, in.Name, ops.ErrInvalid)
}
h, ok := d.Repos.Get(in.Repo)
if !ok || h.Code == "" {
return chatPostOut{}, fmt.Errorf("no repository %q: %w", in.Repo, ops.ErrNotFound)
}
if d.CapsFor == nil || !d.capsFor(ctx, author(ctx), in.Repo).HasAny("C") {
return chatPostOut{}, fmt.Errorf(
"posting in %s's room needs its chat capability: %w", in.Repo, ops.ErrForbidden)
}
code, target = h.Code, in.Repo
}
seq, err := d.Chat.Post(code, chat.Message{
Kind: "msg", Ref: in.Ref, Repo: target, User: author(ctx),
Text: in.Text, At: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
return chatPostOut{}, err
}
// Mentions. A room is a place people are addressed in, and
// this one fired none at all: @somebody in a team room
// notified nobody and recorded nothing, while the same
// words in a repository's channel did both.
//
// A line sent to a REPOSITORY's room is that repository's
// in every other respect, so it takes the ordinary path —
// the mention is recorded against it and the link points
// where the line actually is.
if target != "" {
d.chatMentionAt(target, in.Text, author(ctx), seq)
} else {
d.teamMentionAt(in.Name, in.Text, author(ctx), seq)
}
return chatPostOut{OK: true, Seq: seq}, nil
},
})
}