guilds.gno
6.10 Kb · 154 lines
1// Package guilds records which Discord server a court's moderators chose.
2//
3// WHY A SEPARATE REALM. kourtv2 is deployed and knows nothing about Discord, and
4// a realm on chain is not edited — adding a field there means a new realm and a
5// migration for every court that exists. This holds one string per court and
6// asks kourtv2 who may set it, so the court's own moderator list stays the only
7// authority and this file never has one of its own.
8//
9// WHY IT EXISTS AT ALL, when the service could verify a signed message and did.
10// It could, and the signature had to be made at a terminal, because a wallet
11// signs TRANSACTIONS and not arbitrary strings. So the last step of standing up
12// a court's Discord server was a command nobody would ever discover, and the
13// court page said so and stopped. A realm call IS a transaction, which is
14// exactly what a wallet already does — the same button every other action on the
15// page uses.
16//
17// WHAT IT DOES NOT DO. It does not check that the guild exists, that the bot is
18// in it, or that anybody can join it. Those are the service's to know and it
19// already does; this records a CHOICE, made by an address the court recognises,
20// at a height anybody can read back. A guild id here with no bot behind it is a
21// court pointing at nothing, which is visible rather than dangerous.
22package guilds
23
24import (
25 "chain"
26 "strings"
27
28 bptree "gno.land/p/nt/bptree/v0"
29
30 kourt "gno.land/r/g1ecsuj0q572jr0dhu29q9njtnmw03hyu7tyyvv6/kourt"
31)
32
33// choice is what a court's moderators decided: which server, and who said so.
34//
35// THE SETTER IS STORED, not only emitted. The event carries it too, and the
36// event is the history — but kourt.xyz reads this realm over qeval and has no
37// indexer, so a service that recorded "listed, signed by" from the event alone
38// would need one. Reading it back is one lookup on a page that already does one.
39type choice struct {
40 guildID string
41 by address
42}
43
44// guildOf is court slug -> choice. One server per court: a court with two is a
45// court whose readers are told different things by the same page.
46//
47// A bptree rather than an avl tree because kourtv2 uses one for every ordered
48// map it keeps, and Courts() wants slug order.
49var guildOf = bptree.NewBPTree32()
50
51// Set records the guild for a court, or clears it when guildID is empty.
52//
53// THE CALLER IS THE MODERATOR, NOT THIS REALM. cur.Previous().Address() is who
54// asked, and kourt.IsCourtMod is the only thing consulted — so a moderator
55// removed by the court loses this at the same moment, with no state here to keep
56// in step. IsCourtMod panics on a court that does not exist, which is the answer
57// we want: there is nothing to bind.
58func Set(cur realm, courtSlug, guildID string) {
59 if !cur.IsCurrent() {
60 panic("guilds: stale realm")
61 }
62 who := cur.Previous().Address()
63 if !kourt.IsCourtMod(courtSlug, who) {
64 panic("guilds: only a moderator of this court may set its server")
65 }
66 id := strings.TrimSpace(guildID)
67 if id == "" {
68 if _, removed := guildOf.Remove(courtSlug); removed {
69 chain.Emit("GuildCleared", "court", courtSlug, "by", who.String())
70 }
71 return
72 }
73 // A Discord snowflake is decimal digits, and every id minted since 2015 is
74 // 17 to 20 of them. Bounded here so a mistyped paste is refused at the call
75 // rather than stored and puzzled over on the page.
76 if len(id) < 17 || len(id) > 20 {
77 panic("guilds: a Discord server id is 17 to 20 digits")
78 }
79 for i := 0; i < len(id); i++ {
80 if id[i] < '0' || id[i] > '9' {
81 panic("guilds: a Discord server id is digits only")
82 }
83 }
84 guildOf.Set(courtSlug, choice{guildID: id, by: who})
85 chain.Emit("GuildSet", "court", courtSlug, "guild", id, "by", who.String())
86}
87
88// GuildOf is the court's chosen server, or "" if it has none. Never panics: the
89// service asks this about every court it knows, including ones that never chose.
90func GuildOf(courtSlug string) string {
91 c, ok := guildOf.Get(courtSlug).(choice)
92 if !ok {
93 return ""
94 }
95 return c.guildID
96}
97
98// Chosen is the whole record: the server, and the address that chose it.
99//
100// ONE READ AND NOT TWO, and this exists for that reason alone. kourt.xyz asks
101// both questions together and acts on the pair — it publishes the guild AND
102// records the setter as who signed for it — and two qeval calls can straddle a
103// change. A moderator re-aiming the binding between them hands one decision's
104// guild to another decision's signer, which is then written into the site's
105// database as fact. A pair that cannot be read atomically should not be read as a
106// pair.
107//
108// BOTH EMPTY OR NEITHER. A court that never chose answers ("", ""), which is the
109// commonest answer and is not an error.
110//
111// THE SETTER IS WHO ASKED, NOT WHO MODERATES NOW. A court that replaces its
112// moderators does not change this — the choice was made by whoever made it, and
113// rewriting that would destroy the only record of who did. Whether they STILL
114// moderate is a separate question, and kourtv2 is the one to ask it.
115//
116// STRINGS AND NOT AN address, because the caller is a machine reading qeval's
117// printed output: an address prints as `("g1…" .uverse.address)` and a string as
118// `("g1…" string)`, and the second is the one a parser can hold to.
119func Chosen(courtSlug string) (guildID, by string) {
120 c, ok := guildOf.Get(courtSlug).(choice)
121 if !ok {
122 return "", ""
123 }
124 return c.guildID, c.by.String()
125}
126
127// Courts lists every court that has chosen a server, in slug order — so the
128// service can reconcile the whole set without knowing what to ask for.
129func Courts() []string {
130 out := []string{}
131 guildOf.Iterate("", "", func(k string, _ any) bool {
132 out = append(out, k)
133 return false
134 })
135 return out
136}
137
138func Render(path string) string {
139 var b strings.Builder
140 b.WriteString("# Court Discord servers\n\n")
141 b.WriteString("Which Discord server each court's moderators chose. ")
142 b.WriteString("Set by a moderator of the court, read by kourt.xyz.\n\n")
143 n := 0
144 guildOf.Iterate("", "", func(k string, v any) bool {
145 c, _ := v.(choice)
146 b.WriteString("- **" + k + "** — `" + c.guildID + "` (set by " + c.by.String() + ")\n")
147 n++
148 return false
149 })
150 if n == 0 {
151 b.WriteString("_No court has chosen one yet._\n")
152 }
153 return b.String()
154}