gogobee's Mischief Makers ships four new event types — a hit going out, the target walking away from it, the target not walking away, and the monster turning up to an empty dungeon. Pete 400s an unknown event_type, gogobee retries and then parks the bulletin forever, so this deploys first. The anonymity is the story, not an implementation detail. A contract is anonymous unless the buyer paid extra to sign it, so the lede reports the money and pointedly not the name Pete doesn't have. A survival unseals the buyer whether or not they signed — that exposure is the only brake on casual griefing, and it's the one beat worth leading with. Being maimed buys you nothing: an anonymous buyer stays anonymous when the contract lands.
468 lines
20 KiB
Go
468 lines
20 KiB
Go
package web
|
||
|
||
import (
|
||
"crypto/subtle"
|
||
"encoding/json"
|
||
"fmt"
|
||
"html/template"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
"pete/internal/storage"
|
||
)
|
||
|
||
// AdvFact is the game-event fact gogobee POSTs to Pete. It mirrors the contract
|
||
// in pete_adventure_news_voice.md. Names are character names only (never Matrix
|
||
// handles) and Actors is the allow-list of the only names permitted to appear in
|
||
// rendered output.
|
||
type AdvFact struct {
|
||
GUID string `json:"guid"`
|
||
EventType string `json:"event_type"`
|
||
Tier string `json:"tier"` // "priority" | "bulletin"
|
||
Actors []string `json:"actors"`
|
||
Subject string `json:"subject"`
|
||
Opponent string `json:"opponent"`
|
||
Boss string `json:"boss"`
|
||
Zone string `json:"zone"`
|
||
Region string `json:"region"`
|
||
Level int `json:"level"`
|
||
Count int `json:"count"`
|
||
Outcome string `json:"outcome"`
|
||
Stakes string `json:"stakes"`
|
||
ClassRace string `json:"class_race"`
|
||
Milestone string `json:"milestone"`
|
||
OccurredAt int64 `json:"occurred_at"`
|
||
NoPush bool `json:"no_push"`
|
||
}
|
||
|
||
// AdvPost is a priority adventure item to post live to Matrix. Kept minimal and
|
||
// web-local so the web package needs no dependency on internal/poster.
|
||
type AdvPost struct {
|
||
GUID string
|
||
Headline string
|
||
Lede string
|
||
ImageURL string
|
||
ArticleURL string
|
||
Source string
|
||
Channel string
|
||
}
|
||
|
||
// PriorityPoster posts a priority adventure item to Matrix immediately. main
|
||
// adapts *poster.Queue.PostNow to this; nil in web-only/local modes.
|
||
type PriorityPoster func(AdvPost)
|
||
|
||
const advSource = "Pete"
|
||
|
||
// advBackfillEvent is the synthetic post_log event id used to retire a no_push
|
||
// (cold-start backfill) dispatch against the daily digest. It never went to
|
||
// Matrix; the row exists only so the digest skips it.
|
||
const advBackfillEvent = "adv-backfill"
|
||
|
||
// handleAdventureIngest receives a game-event fact from gogobee, templates it
|
||
// into a deterministic story, publishes it to the /adventure section, and posts
|
||
// PRIORITY beats live to Matrix. Bearer-authed; idempotent on the fact GUID.
|
||
func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
||
if !s.adv.Enabled {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
if !s.bearerOK(r) {
|
||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
var f AdvFact
|
||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&f); err != nil {
|
||
http.Error(w, "bad json", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if f.GUID == "" || f.EventType == "" {
|
||
http.Error(w, "guid and event_type are required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
// Fact-guard: any player name we render must be in the actors allow-list.
|
||
// gogobee pre-sanitizes, but Pete never trusts the channel — this is the
|
||
// last line before a name reaches a public page.
|
||
if !factGuard(f) {
|
||
slog.Warn("adventure ingest: fact-guard rejected", "guid", f.GUID, "event_type", f.EventType)
|
||
http.Error(w, "fact-guard: subject/opponent not in actors", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
headline, lede, ok := renderAdventure(f)
|
||
if !ok {
|
||
http.Error(w, "unknown event_type", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Idempotent: a re-delivered fact (gogobee retry) is a no-op success.
|
||
if storage.IsGUIDSeen(f.GUID) {
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte("duplicate"))
|
||
return
|
||
}
|
||
|
||
// A fact with no occurred_at would otherwise be stored at the Unix epoch:
|
||
// dated 1970 on the permalink, pinned to the bottom of the section, and
|
||
// outside every digest window. Treat "missing" as "now".
|
||
occurredAt := f.OccurredAt
|
||
if occurredAt <= 0 {
|
||
occurredAt = time.Now().Unix()
|
||
}
|
||
|
||
articleURL := s.advPermalink(f.GUID)
|
||
imageURL := advArtURL(f.EventType)
|
||
if err := storage.InsertStory(&storage.Story{
|
||
GUID: f.GUID,
|
||
Headline: headline,
|
||
Lede: lede,
|
||
ImageURL: imageURL,
|
||
ArticleURL: articleURL,
|
||
Source: advSource,
|
||
Channel: "adventure",
|
||
Classified: true,
|
||
SeenAt: occurredAt,
|
||
PublishedAt: occurredAt,
|
||
}); err != nil {
|
||
slog.Error("adventure ingest: insert failed", "guid", f.GUID, "err", err)
|
||
http.Error(w, "insert failed", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
slog.Info("adventure ingest: published", "guid", f.GUID, "event_type", f.EventType, "tier", f.Tier)
|
||
|
||
// NoPush (cold-start backfill) means "never goes to Matrix". Suppressing only
|
||
// the live post isn't enough: the digest collects adventure rows that carry no
|
||
// post_log entry, so a backfilled bulletin would still be swept into the next
|
||
// roundup — the back-catalogue dump NoPush exists to prevent. Retire the guid
|
||
// against the digest up front instead.
|
||
if f.NoPush {
|
||
storage.MarkAdventureDigested([]string{f.GUID}, advBackfillEvent)
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte("ok"))
|
||
return
|
||
}
|
||
|
||
// PRIORITY beats post live to Matrix; BULLETIN beats wait for the daily
|
||
// digest. Website section always gets the row above regardless of tier.
|
||
if f.Tier == "priority" && s.advPost != nil && s.adv.Channel != "" {
|
||
// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
|
||
// media), and the link's og:image carries the preview instead.
|
||
// No Source: the source tag exists to credit an outlet Pete is relaying
|
||
// (`ars technica`). On his own reporting it renders as him signing his
|
||
// own name under his own message.
|
||
s.advPost(AdvPost{
|
||
GUID: f.GUID,
|
||
Headline: headline,
|
||
Lede: lede,
|
||
ArticleURL: articleURL,
|
||
Channel: s.adv.Channel,
|
||
})
|
||
}
|
||
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte("ok"))
|
||
}
|
||
|
||
// advEventMeta maps an event_type to a short display label and emoji used by the
|
||
// permalink page (and, later, OG art selection). Unknown types fall back to a
|
||
// neutral dispatch label so a new gogobee event never renders blank.
|
||
func advEventMeta(eventType string) (label, emoji string) {
|
||
switch eventType {
|
||
case "siege_start", "siege_win", "siege_loss":
|
||
return "The Siege", "🏰"
|
||
case "boss_first", "boss_kill":
|
||
return "Boss down", "🐉"
|
||
case "zone_first":
|
||
return "First clear", "🗺️"
|
||
case "zone_clear":
|
||
return "Zone cleared", "🗺️"
|
||
case "death":
|
||
return "In memoriam", "🪦"
|
||
case "arrival":
|
||
return "New arrival", "👋"
|
||
case "standings", "rival_result":
|
||
return "The rival board", "⚔️"
|
||
case "pete_duel_loss", "pete_duel_win":
|
||
return "Pete's duels", "🤝"
|
||
case "milestone":
|
||
return "Milestone", "🏅"
|
||
case "retreat":
|
||
return "Pulled out", "🎒"
|
||
case "departure":
|
||
return "Wandered off", "🚪"
|
||
case "mischief_contract":
|
||
return "Coin on their head", "😈"
|
||
case "mischief_survived":
|
||
return "They walked away", "🛡️"
|
||
case "mischief_downed":
|
||
return "The contract landed", "💀"
|
||
case "mischief_fizzled":
|
||
return "Nobody home", "🚪"
|
||
}
|
||
return "Dispatch", "📣"
|
||
}
|
||
|
||
// advArtURL is the card/OG image for a dispatch: a themed SVG emblem served by
|
||
// handleAdventureArt, keyed on event_type. Local (root-relative) so it bypasses
|
||
// the external-image thumbnailer.
|
||
func advArtURL(eventType string) string {
|
||
return "/adventure/art/" + eventType + ".svg"
|
||
}
|
||
|
||
// handleAdventureArt renders the themed emblem for an event type — an adventure
|
||
// gradient with the event's emoji and label. Deterministic and dependency-free
|
||
// (no external asset), so every dispatch card has visual identity instead of the
|
||
// blank placeholder that made the section look broken next to RSS cards.
|
||
func (s *Server) handleAdventureArt(w http.ResponseWriter, r *http.Request) {
|
||
if !s.adv.Enabled {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
eventType := strings.TrimSuffix(r.PathValue("type"), ".svg")
|
||
label, emoji := advEventMeta(eventType)
|
||
w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
|
||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||
_, _ = fmt.Fprintf(w, advArtSVG, template.HTMLEscapeString(emoji), template.HTMLEscapeString(strings.ToUpper(label)))
|
||
}
|
||
|
||
// advArtSVG is the emblem template: %s = emoji, %s = label. 1200×630 (the OG
|
||
// card ratio) so the same image works as a link-preview image.
|
||
const advArtSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
|
||
<defs>
|
||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||
<stop offset="0" stop-color="#7c5ce8"/>
|
||
<stop offset="1" stop-color="#5836b8"/>
|
||
</linearGradient>
|
||
</defs>
|
||
<rect width="1200" height="630" fill="url(#g)"/>
|
||
<text x="600" y="300" font-size="260" text-anchor="middle" dominant-baseline="central">%s</text>
|
||
<text x="600" y="500" font-size="64" font-family="Fredoka, Nunito, system-ui, sans-serif" font-weight="700" fill="#ffffff" text-anchor="middle" letter-spacing="6" opacity="0.92">%s</text>
|
||
</svg>`
|
||
|
||
// advStoryPage is the per-story permalink view. It reuses the shared layout so a
|
||
// dispatch reads like the rest of the site, with an adventure-themed hero.
|
||
type advStoryPage struct {
|
||
pageData
|
||
EventLabel string
|
||
Emoji string
|
||
Headline string
|
||
Body string
|
||
Region string
|
||
When string
|
||
Permalink string
|
||
}
|
||
|
||
// handleAdventureStory serves the server-rendered permalink for one dispatch
|
||
// (the article_url every ingested story points at). Public and cacheable; 404s
|
||
// when the section is disabled or the guid is unknown. Character names in the
|
||
// stored headline/body already passed the ingest fact-guard, so nothing
|
||
// player-controlled reaches here unchecked.
|
||
func (s *Server) handleAdventureStory(w http.ResponseWriter, r *http.Request) {
|
||
if !s.adv.Enabled {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
guid := r.PathValue("guid")
|
||
st, err := storage.GetStoryByGUID(guid)
|
||
if err != nil || st == nil || st.Channel != "adventure" {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
s.track(r, "adventure")
|
||
|
||
// event_type is encoded in the guid prefix (e.g. "death:<hash>:<ts>"); fall
|
||
// back to the neutral dispatch meta when it isn't a known type.
|
||
eventType, _, _ := strings.Cut(guid, ":")
|
||
label, emoji := advEventMeta(eventType)
|
||
|
||
body := st.Content
|
||
if strings.TrimSpace(body) == "" {
|
||
body = st.Lede // template-only dispatches carry the write-up in the lede
|
||
}
|
||
|
||
base := s.base(r)
|
||
base.Active = "adventure"
|
||
base.NoIndex = true // player-named page; keep out of search indexes (gap #5)
|
||
if abs := strings.TrimRight(s.cfg.BaseURL, "/"); abs != "" {
|
||
base.OGImage = abs + advArtURL(eventType) // emblem for link unfurls
|
||
}
|
||
s.render(w, "story", advStoryPage{
|
||
pageData: base,
|
||
EventLabel: label,
|
||
Emoji: emoji,
|
||
Headline: st.Headline,
|
||
Body: body,
|
||
Region: "", // reserved: region isn't stored on the row yet
|
||
When: time.Unix(st.SeenAt, 0).UTC().Format("Jan 2, 2006"),
|
||
Permalink: s.advPermalink(guid),
|
||
})
|
||
}
|
||
|
||
// bearerOK checks the Authorization: Bearer header against the configured ingest
|
||
// token in constant time.
|
||
func (s *Server) bearerOK(r *http.Request) bool {
|
||
const prefix = "Bearer "
|
||
h := r.Header.Get("Authorization")
|
||
if !strings.HasPrefix(h, prefix) || s.adv.IngestToken == "" {
|
||
return false
|
||
}
|
||
got := strings.TrimPrefix(h, prefix)
|
||
return subtle.ConstantTimeCompare([]byte(got), []byte(s.adv.IngestToken)) == 1
|
||
}
|
||
|
||
// siteURL makes a root-relative path absolute against BaseURL. Links that go out
|
||
// to Matrix need the absolute form to survive safeHref; when BaseURL isn't
|
||
// configured the relative form is all we have, and it's still fine on-site.
|
||
func (s *Server) siteURL(path string) string {
|
||
return strings.TrimRight(s.cfg.BaseURL, "/") + path
|
||
}
|
||
|
||
// advPermalink builds the per-story Pete permalink used as article_url (the card
|
||
// link + Matrix link). The guid is path-escaped: it's ingest-supplied, and a
|
||
// stray "/" or "?" would otherwise produce a link that routes somewhere else.
|
||
func (s *Server) advPermalink(guid string) string {
|
||
return s.siteURL("/adventure/" + url.PathEscape(guid))
|
||
}
|
||
|
||
// factGuard verifies every player-name field we might render is present in the
|
||
// actors allow-list. Boss/zone/region/milestone are game-authored content, not
|
||
// player-controlled, so they are not guarded.
|
||
func factGuard(f AdvFact) bool {
|
||
allow := make(map[string]bool, len(f.Actors))
|
||
for _, a := range f.Actors {
|
||
if a != "" {
|
||
allow[a] = true
|
||
}
|
||
}
|
||
if f.Subject != "" && !allow[f.Subject] {
|
||
return false
|
||
}
|
||
if f.Opponent != "" && !allow[f.Opponent] {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// renderAdventure returns the deterministic headline + lede for a fact. Copied
|
||
// verbatim from the voice spec (pete_adventure_news_voice.md). Template-only —
|
||
// no LLM — so output is safe and reproducible. ok is false for an unknown type.
|
||
func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
|
||
atLevel := ""
|
||
if f.Level > 0 {
|
||
atLevel = fmt.Sprintf(", at level %d", f.Level)
|
||
}
|
||
switch f.EventType {
|
||
case "siege_start":
|
||
return fmt.Sprintf("Breaking: %s is marching on the town.", f.Boss),
|
||
fmt.Sprintf("Folks, this is the big one — %s has camped outside the gates and the whole community's needed to turn it back. You've got %s. Let's rally.", f.Boss, f.Stakes), true
|
||
case "siege_win":
|
||
return fmt.Sprintf("The town holds! %s turned back.", f.Boss),
|
||
fmt.Sprintf("What a turnout — %d defenders stood shoulder to shoulder and sent %s packing. Spoils are going out now. Proud of you all.", f.Count, f.Boss), true
|
||
case "siege_loss":
|
||
return fmt.Sprintf("Heavy news: %s broke through.", f.Boss),
|
||
fmt.Sprintf("We gave it everything, but %s got past the gates and took its tribute. We'll be ready next time — heads up, everyone.", f.Boss), true
|
||
case "boss_first":
|
||
return fmt.Sprintf("First ever: %s brings down %s.", f.Subject, f.Boss),
|
||
fmt.Sprintf("History in %s today — %s is the first anyone's seen clear %s. Nobody had done it before. Hats off.", f.Region, f.Subject, f.Boss), true
|
||
case "boss_kill":
|
||
return fmt.Sprintf("%s takes down %s again.", f.Subject, f.Boss),
|
||
fmt.Sprintf("Another clean run in %s today. Routine for %s by now — but still worth a nod.", f.Zone, f.Subject), true
|
||
case "zone_first", "zone_clear":
|
||
// gogobee splits the realm's first-ever clear (zone_first, priority) from a
|
||
// later repeat (zone_clear, bulletin); they share a lede but differ in
|
||
// headline. Fall back to the tier for a legacy zone_first that predates the
|
||
// split.
|
||
if f.EventType == "zone_first" || f.Tier == "priority" {
|
||
headline = fmt.Sprintf("%s cleared for the very first time.", f.Zone)
|
||
} else {
|
||
headline = fmt.Sprintf("%s clears %s.", f.Subject, f.Zone)
|
||
}
|
||
inRegion := ""
|
||
if f.Region != "" {
|
||
inRegion = " in " + f.Region
|
||
}
|
||
return headline, fmt.Sprintf("%s made it through %s%s%s. Nicely done.", f.Subject, f.Zone, inRegion, atLevel), true
|
||
case "death":
|
||
return fmt.Sprintf("We lost %s in %s.", f.Subject, f.Zone),
|
||
fmt.Sprintf("Sad news to pass along: %s fell at level %d in %s. The graveyard's a little fuller tonight. Rest easy.", f.Subject, f.Level, f.Zone), true
|
||
case "retreat":
|
||
// An expedition that came apart without killing anyone. Until gogobee
|
||
// started sending these, the feed had no way to say "it went badly and
|
||
// everyone lived" — so it never said it, and the classes that retreat
|
||
// often simply never appeared. Warm, not a failure notice: everyone came
|
||
// home, and that is the part Pete leads with.
|
||
howFar := "barely a day in"
|
||
if f.Count > 1 {
|
||
howFar = fmt.Sprintf("%d days in", f.Count)
|
||
}
|
||
return fmt.Sprintf("%s backed out of %s.", f.Subject, f.Zone),
|
||
fmt.Sprintf("%s turned around %s — %s got the better of them this time%s, and they made the call to walk out rather than push it. Everybody came home breathing, which is the bit that counts. That dungeon'll still be there next week.",
|
||
f.Subject, howFar, f.Zone, atLevel), true
|
||
case "departure":
|
||
// A bored adventurer let themselves out. Nobody sent them — they got
|
||
// restless waiting on a player who wasn't coming, took the cheap supplies
|
||
// they could afford, and went. Pete plays it straight and a little fond;
|
||
// the joke tells itself, and the player it's about may well be reading.
|
||
return fmt.Sprintf("%s got bored and left without waiting.", f.Subject),
|
||
fmt.Sprintf("No orders, no escort, no fuss — %s packed the cheapest kit on the shelf and set off into %s%s. Nobody told them to. Nobody talked them out of it either. We'll let you know how it goes.", f.Subject, f.Zone, atLevel), true
|
||
case "mischief_contract":
|
||
// Somebody paid to have a monster sent after an adventurer who is out in a
|
||
// dungeon right now. Anonymous unless the buyer paid extra to sign it, and
|
||
// the anonymity is the story: Pete reports the money, not the name he
|
||
// doesn't have. Opponent carries the buyer only when it's public.
|
||
if f.Opponent != "" {
|
||
return fmt.Sprintf("%s has put %s on %s's head.", f.Opponent, f.Stakes, f.Subject),
|
||
fmt.Sprintf("No secret about it — %s paid for a %s to go find %s out in whatever hole they're currently down, and signed the thing. It's out there looking right now. If %s comes back breathing, they keep a cut of that money.",
|
||
f.Opponent, strings.ToLower(f.Boss), f.Subject, f.Subject), true
|
||
}
|
||
return fmt.Sprintf("Someone's put %s on %s's head.", f.Stakes, f.Subject),
|
||
fmt.Sprintf("Word came in quiet: %s has been paid for a %s to go looking for %s, and whoever paid it isn't saying so. It's already out there. Survive it and the money's theirs — and we all find out who signed the cheque.",
|
||
f.Stakes, strings.ToLower(f.Boss), f.Subject), true
|
||
case "mischief_survived":
|
||
// The unseal. A survival is the only thing that names an anonymous buyer,
|
||
// and it is the whole brake on casual griefing — so Pete leads with it.
|
||
return fmt.Sprintf("%s walked away from it. It was %s who paid.", f.Subject, f.Opponent),
|
||
fmt.Sprintf("%s came for %s, and %s is the one still standing — %s richer for the trouble. The contract's been opened up, and the name inside it is %s. Make of that what you will, folks.",
|
||
f.Boss, f.Subject, f.Subject, f.Stakes, f.Opponent), true
|
||
case "mischief_downed":
|
||
who := "Nobody's saying who paid for it"
|
||
if f.Opponent != "" {
|
||
who = fmt.Sprintf("%s paid for it, and put their name on it", f.Opponent)
|
||
}
|
||
return fmt.Sprintf("%s didn't walk away.", f.Subject),
|
||
fmt.Sprintf("A %s found %s mid-run%s and put them on the floor. They're being carried home — alive, which is more than the contract asked for, but that expedition's finished. %s.",
|
||
f.Boss, f.Subject, atLevel, who), true
|
||
case "mischief_fizzled":
|
||
return fmt.Sprintf("The monster sent for %s arrived to an empty dungeon.", f.Subject),
|
||
fmt.Sprintf("Somebody spent good money to have %s ambushed, and %s had already gone home. It wandered the halls for a bit and left. Most of the fee's been refunded. The rest, the town's keeping.",
|
||
f.Subject, f.Subject), true
|
||
case "arrival":
|
||
return fmt.Sprintf("Welcome to the realm, %s!", f.Subject),
|
||
fmt.Sprintf("A new %s just walked through the gates. Say hello if you see them out there.", f.ClassRace), true
|
||
case "standings":
|
||
return "The rival board's been shaken up.",
|
||
fmt.Sprintf("%s is on the move — here's where the standings sit today.", f.Subject), true
|
||
case "rival_result":
|
||
return fmt.Sprintf("%s settles the score with %s.", f.Subject, f.Opponent),
|
||
fmt.Sprintf("Their duel went %s's way today, and the board reflects it. Good match, you two.", f.Subject), true
|
||
case "pete_duel_loss":
|
||
if f.Tier == "priority" {
|
||
headline = fmt.Sprintf("You got me, %s.", f.Subject)
|
||
} else {
|
||
headline = fmt.Sprintf("%s got the better of me again.", f.Subject)
|
||
}
|
||
return headline, fmt.Sprintf("Credit where it's due — %s beat me fair and square. Good duel. I'll want a rematch when you're ready.", f.Subject), true
|
||
case "pete_duel_win":
|
||
return fmt.Sprintf("Held my ground against %s today.", f.Subject),
|
||
fmt.Sprintf("Closer than the record will show, honestly — %s pushed me. Rematch whenever you like.", f.Subject), true
|
||
case "milestone":
|
||
return fmt.Sprintf("%s hits %s.", f.Subject, f.Milestone),
|
||
fmt.Sprintf("One for the books — %s just reached %s. The long road continues.", f.Subject, f.Milestone), true
|
||
}
|
||
return "", "", false
|
||
}
|