Skip to content
quarry Artifact [9833338d5c]
Login

Artifact [9833338d5c]

Artifact 9833338d5cca42ed661d2a5a7f5dd5e4bdc4aeefdd6620c0806acc2b148b4a37:


// 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}})
			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{}
	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.chatRenderOne(ctx, 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.isSiteAdmin(who) || 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(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
		},
	})
}