Staff push notifications, and revalidate embedded assets
Notifications: staff turn them on per device from a new Notifications page, pick each type (bans, gags, mutes and silences added or lifted, server down and back up) and quiet hours. A watcher polls SimpleAdmin's tables, so in-game penalties are sent too. Web Push with VAPID and RFC 8291 encryption in internal/push, standard library only. Tables in sql/push.sql, created at startup; the VAPID key is made on first start and kept in saw_settings. Penalty notifications link to the public list searching for the player (#bans?q=<steamid64>). The Android app delegates notifications (versionCode 3). Caching: pages, scripts and styles are served with Cache-Control: no-cache and an ETag, so a deploy shows up at once instead of after Cloudflare's default 4-hour browser cache.
This commit is contained in:
parent
7900bf5b3e
commit
17232bd5a2
23 changed files with 2135 additions and 12 deletions
587
internal/web/notify.go
Normal file
587
internal/web/notify.go
Normal file
|
|
@ -0,0 +1,587 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.zio.sh/cs2/simpleadmin-web/internal/push"
|
||||
"git.zio.sh/cs2/simpleadmin-web/internal/store"
|
||||
)
|
||||
|
||||
// Push notifications for staff. A watcher polls SimpleAdmin's tables for new bans, comm blocks and
|
||||
// lifts (so ones made in game are caught too) and the live poller for the server going down, and
|
||||
// sends each to the devices of staff who want that type and aren't in their quiet hours.
|
||||
|
||||
// notifyType is one kind of notification staff can pick. Ready is false for ones the panel can't
|
||||
// send yet: the page shows them, switched off.
|
||||
type notifyType struct {
|
||||
Key string `json:"key"`
|
||||
Group string `json:"group"`
|
||||
Label string `json:"label"`
|
||||
Help string `json:"help,omitempty"`
|
||||
Ready bool `json:"ready"`
|
||||
}
|
||||
|
||||
var notifyTypes = []notifyType{
|
||||
{Key: "ban", Group: "Bans", Label: "Ban added", Ready: true},
|
||||
{Key: "unban", Group: "Bans", Label: "Ban lifted", Ready: true},
|
||||
{Key: "gag", Group: "Gags", Label: "Gag added", Ready: true},
|
||||
{Key: "ungag", Group: "Gags", Label: "Gag lifted", Ready: true},
|
||||
{Key: "mute", Group: "Mutes", Label: "Mute added", Ready: true},
|
||||
{Key: "unmute", Group: "Mutes", Label: "Mute lifted", Ready: true},
|
||||
{Key: "silence", Group: "Silences", Label: "Silence added", Ready: true},
|
||||
{Key: "unsilence", Group: "Silences", Label: "Silence lifted", Ready: true},
|
||||
{Key: "server", Group: "Server", Label: "Server down and back up", Help: "When the panel loses the game server for 30 seconds, and when it's back.", Ready: true},
|
||||
{Key: "livescore", Group: "Server", Label: "Live score", Help: "An updating scoreboard while people are playing. Coming later."},
|
||||
{Key: "calladmin", Group: "Players", Label: "Admin called", Help: "A player asks for an admin in game. Needs a server plugin, coming later."},
|
||||
}
|
||||
|
||||
// readyTypes are the keys that can be switched on, which is also everyone's default.
|
||||
func readyTypes() []string {
|
||||
var out []string
|
||||
for _, t := range notifyTypes {
|
||||
if t.Ready {
|
||||
out = append(out, t.Key)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// commKey maps SimpleAdmin's comm types to notification types.
|
||||
var commKey = map[string]string{"GAG": "gag", "MUTE": "mute", "SILENCE": "silence"}
|
||||
|
||||
// ---------------------------------------------------------------- Preferences
|
||||
|
||||
type quietView struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Start string `json:"start"` // HH:MM
|
||||
End string `json:"end"`
|
||||
}
|
||||
|
||||
type prefsView struct {
|
||||
Types []string `json:"types"`
|
||||
Quiet quietView `json:"quiet"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
func defaultPrefs() store.PushPrefs {
|
||||
return store.PushPrefs{Types: readyTypes(), QuietStart: 23 * 60, QuietEnd: 8 * 60, Timezone: "UTC"}
|
||||
}
|
||||
|
||||
func hhmm(m int) string {
|
||||
return strconv.Itoa(100 + m/60)[1:] + ":" + strconv.Itoa(100 + m%60)[1:]
|
||||
}
|
||||
|
||||
var hhmmRe = regexp.MustCompile(`^([01]\d|2[0-3]):([0-5]\d)$`)
|
||||
|
||||
func parseHHMM(s string) (int, bool) {
|
||||
m := hhmmRe.FindStringSubmatch(s)
|
||||
if m == nil {
|
||||
return 0, false
|
||||
}
|
||||
h, _ := strconv.Atoi(m[1])
|
||||
min, _ := strconv.Atoi(m[2])
|
||||
return h*60 + min, true
|
||||
}
|
||||
|
||||
// inQuietHours reports whether now falls in someone's quiet hours, in their timezone.
|
||||
func inQuietHours(p store.PushPrefs, now time.Time) bool {
|
||||
if !p.QuietEnabled || p.QuietStart == p.QuietEnd {
|
||||
return false
|
||||
}
|
||||
loc, err := time.LoadLocation(p.Timezone)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
t := now.In(loc)
|
||||
m := t.Hour()*60 + t.Minute()
|
||||
if p.QuietStart < p.QuietEnd {
|
||||
return m >= p.QuietStart && m < p.QuietEnd
|
||||
}
|
||||
return m >= p.QuietStart || m < p.QuietEnd
|
||||
}
|
||||
|
||||
type notificationsView struct {
|
||||
Available bool `json:"available"`
|
||||
PublicKey string `json:"publicKey,omitempty"`
|
||||
Types []notifyType `json:"types"`
|
||||
Prefs prefsView `json:"prefs"`
|
||||
// SHA-256 (hex) of each device's endpoint, so a browser can tell if it's one of them.
|
||||
Devices []string `json:"devices"`
|
||||
}
|
||||
|
||||
func (s *Server) adminNotifications(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
|
||||
v := notificationsView{Available: s.cfg.Push != nil, Types: notifyTypes, Devices: []string{}}
|
||||
p := defaultPrefs()
|
||||
if s.cfg.Push != nil {
|
||||
v.PublicKey = s.cfg.Push.Keys.Public()
|
||||
saved, ok, err := s.st.PushPrefsFor(r.Context(), id.SteamID)
|
||||
if err != nil {
|
||||
internal(w, r, err)
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
p = saved
|
||||
}
|
||||
if v.Devices, err = s.st.PushDeviceHashes(r.Context(), id.SteamID); err != nil {
|
||||
internal(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
v.Prefs = prefsView{Types: p.Types, Quiet: quietView{Enabled: p.QuietEnabled, Start: hhmm(p.QuietStart), End: hhmm(p.QuietEnd)}, Timezone: p.Timezone}
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
}
|
||||
|
||||
func (s *Server) needPush(w http.ResponseWriter) bool {
|
||||
if s.cfg.Push == nil {
|
||||
fail(w, http.StatusServiceUnavailable, "Notifications aren't set up on the panel. Its log says why.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) saveNotificationPrefs(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
|
||||
if !s.needPush(w) {
|
||||
return
|
||||
}
|
||||
var req prefsView
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
fail(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
p := store.PushPrefs{Types: []string{}, QuietEnabled: req.Quiet.Enabled, Timezone: req.Timezone}
|
||||
ready := readyTypes()
|
||||
for _, t := range req.Types {
|
||||
// Types that aren't ready can't be switched on yet.
|
||||
if slices.Contains(ready, t) && !slices.Contains(p.Types, t) {
|
||||
p.Types = append(p.Types, t)
|
||||
}
|
||||
}
|
||||
var ok1, ok2 bool
|
||||
p.QuietStart, ok1 = parseHHMM(req.Quiet.Start)
|
||||
p.QuietEnd, ok2 = parseHHMM(req.Quiet.End)
|
||||
if !ok1 || !ok2 {
|
||||
fail(w, http.StatusBadRequest, "Quiet hours need a start and end time, like 23:00.")
|
||||
return
|
||||
}
|
||||
if _, err := time.LoadLocation(p.Timezone); err != nil || p.Timezone == "" || len(p.Timezone) > 64 {
|
||||
p.Timezone = "UTC"
|
||||
}
|
||||
if err := s.st.SavePushPrefs(r.Context(), id.SteamID, p); err != nil {
|
||||
internal(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "Saved."})
|
||||
}
|
||||
|
||||
// subscribeRequest is PushSubscription.toJSON(), plus the browser's user agent.
|
||||
type subscribeRequest struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
ExpirationTime *float64 `json:"expirationTime"`
|
||||
Keys struct {
|
||||
P256dh string `json:"p256dh"`
|
||||
Auth string `json:"auth"`
|
||||
} `json:"keys"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
// The browser's timezone, for the quiet hours in the first saved preferences.
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
func (s *Server) subscribeNotifications(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
|
||||
if !s.needPush(w) {
|
||||
return
|
||||
}
|
||||
var req subscribeRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
fail(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
sub := push.Subscription{Endpoint: req.Endpoint, P256dh: req.Keys.P256dh, Auth: req.Keys.Auth}
|
||||
if len(req.Endpoint) > 1024 || !push.AllowedEndpoint(req.Endpoint) {
|
||||
fail(w, http.StatusBadRequest, "This browser's push service isn't one the panel knows. Try Chrome, Firefox, Edge or Safari.")
|
||||
return
|
||||
}
|
||||
if err := sub.Check(); err != nil {
|
||||
fail(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.st.SavePushDevice(r.Context(), id.SteamID, sub.Endpoint, sub.P256dh, sub.Auth, req.UserAgent); err != nil {
|
||||
internal(w, r, err)
|
||||
return
|
||||
}
|
||||
// Save the defaults the page showed, so they're what the watcher uses.
|
||||
if _, ok, err := s.st.PushPrefsFor(r.Context(), id.SteamID); err == nil && !ok {
|
||||
p := defaultPrefs()
|
||||
if _, err := time.LoadLocation(req.Timezone); err == nil && req.Timezone != "" && len(req.Timezone) <= 64 {
|
||||
p.Timezone = req.Timezone
|
||||
}
|
||||
_ = s.st.SavePushPrefs(r.Context(), id.SteamID, p)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "Notifications are on for this device."})
|
||||
}
|
||||
|
||||
func (s *Server) unsubscribeNotifications(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
|
||||
if !s.needPush(w) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
All bool `json:"all"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
fail(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Endpoint == "" && !req.All {
|
||||
fail(w, http.StatusBadRequest, "Say which device to turn off.")
|
||||
return
|
||||
}
|
||||
if req.All {
|
||||
req.Endpoint = ""
|
||||
}
|
||||
n, err := s.st.DeletePushDevice(r.Context(), id.SteamID, req.Endpoint)
|
||||
if err != nil {
|
||||
internal(w, r, err)
|
||||
return
|
||||
}
|
||||
msg := "Notifications are off for this device."
|
||||
if req.All {
|
||||
msg = "Notifications are off on all your devices."
|
||||
if n == 1 {
|
||||
msg = "Notifications are off on your 1 device."
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": msg})
|
||||
}
|
||||
|
||||
func (s *Server) testNotification(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
|
||||
if !s.needPush(w) {
|
||||
return
|
||||
}
|
||||
devices, err := s.st.PushDevices(r.Context(), id.SteamID)
|
||||
if err != nil {
|
||||
internal(w, r, err)
|
||||
return
|
||||
}
|
||||
if len(devices) == 0 {
|
||||
fail(w, http.StatusBadRequest, "Turn notifications on for a device first.")
|
||||
return
|
||||
}
|
||||
n := notification{
|
||||
Title: "Test notification",
|
||||
Body: "Notifications from " + s.site.Title + " are working on this device.",
|
||||
Tag: "test",
|
||||
URL: "/admin#notifications",
|
||||
}
|
||||
sent := s.deliver(r.Context(), devices, n)
|
||||
if sent == 0 {
|
||||
fail(w, http.StatusBadGateway, "The push service didn't accept the notification. The panel's log has the details.")
|
||||
return
|
||||
}
|
||||
msg := "Sent to your device."
|
||||
if sent > 1 {
|
||||
msg = "Sent to your " + strconv.Itoa(sent) + " devices."
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": msg})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Sending
|
||||
|
||||
// notification is what the service worker shows (see sw.js).
|
||||
type notification struct {
|
||||
Type string `json:"-"`
|
||||
Actor string `json:"-"` // SteamID64 of whoever caused it; they aren't told about their own actions
|
||||
Urgency string `json:"-"`
|
||||
TTL time.Duration `json:"-"`
|
||||
Topic string `json:"-"`
|
||||
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
URL string `json:"url"`
|
||||
Actions []notifAction `json:"actions,omitempty"`
|
||||
Time int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
type notifAction struct {
|
||||
Action string `json:"action"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// deliver sends one notification to these devices and returns how many accepted it. Devices the
|
||||
// push service says are gone are deleted.
|
||||
func (s *Server) deliver(ctx context.Context, devices []store.PushDevice, n notification) int {
|
||||
if n.Time == 0 {
|
||||
n.Time = time.Now().UnixMilli()
|
||||
}
|
||||
if n.TTL == 0 {
|
||||
n.TTL = 4 * time.Hour
|
||||
}
|
||||
if n.Urgency == "" {
|
||||
n.Urgency = "normal"
|
||||
}
|
||||
payload, _ := json.Marshal(n)
|
||||
msg := push.Message{Payload: payload, TTL: n.TTL, Urgency: n.Urgency, Topic: n.Topic}
|
||||
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, 8)
|
||||
sent := 0
|
||||
for _, d := range devices {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
sctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
err := s.cfg.Push.Send(sctx, push.Subscription{Endpoint: d.Endpoint, P256dh: d.P256dh, Auth: d.Auth}, msg)
|
||||
switch {
|
||||
case errors.Is(err, push.ErrGone):
|
||||
slog.Info("push device unsubscribed, removing it", "steamid", d.SteamID, "device", d.ID)
|
||||
_ = s.st.DeletePushDeviceID(context.WithoutCancel(ctx), d.ID)
|
||||
case err != nil:
|
||||
host := ""
|
||||
if u, perr := url.Parse(d.Endpoint); perr == nil {
|
||||
host = u.Host
|
||||
}
|
||||
slog.Warn("push failed", "steamid", d.SteamID, "device", d.ID, "service", host, "err", err)
|
||||
default:
|
||||
mu.Lock()
|
||||
sent++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return sent
|
||||
}
|
||||
|
||||
// broadcast sends a notification to every staff member who wants its type right now.
|
||||
func (s *Server) broadcast(ctx context.Context, n notification) {
|
||||
devices, err := s.st.PushDevices(ctx, "")
|
||||
if err != nil || len(devices) == 0 {
|
||||
if err != nil {
|
||||
slog.Warn("notifications: couldn't list devices", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
prefs, err := s.st.AllPushPrefs(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("notifications: couldn't read preferences", "err", err)
|
||||
return
|
||||
}
|
||||
x, err := s.staffIdx(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("notifications: couldn't read staff", "err", err)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
var to []store.PushDevice
|
||||
for _, d := range devices {
|
||||
if d.SteamID == n.Actor || !panelStaff(x, d.SteamID) {
|
||||
continue
|
||||
}
|
||||
p, ok := prefs[d.SteamID]
|
||||
if !ok {
|
||||
p = defaultPrefs()
|
||||
}
|
||||
if !slices.Contains(p.Types, n.Type) || inQuietHours(p, now) {
|
||||
continue
|
||||
}
|
||||
to = append(to, d)
|
||||
}
|
||||
if len(to) > 0 {
|
||||
s.deliver(ctx, to, n)
|
||||
}
|
||||
}
|
||||
|
||||
// panelStaff is whether someone may still use the panel (see identity), so people removed from
|
||||
// staff stop getting notifications at once, before their devices are cleaned up.
|
||||
func panelStaff(x *staffIndex, steamid string) bool {
|
||||
a, ok := x.bySteam[steamid]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
flags := x.flags(a)
|
||||
return slices.Contains(flags, "@css/generic") || slices.Contains(flags, "@css/root")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Watching for events
|
||||
|
||||
const (
|
||||
notifyPoll = 5 * time.Second
|
||||
// New rows wait this long before they're sent: when the panel issues a penalty over RCON,
|
||||
// SimpleAdmin writes it as Console and the panel credits the real staff member a few seconds
|
||||
// later (ClaimConsoleRow, ClaimLift).
|
||||
notifySettle = 7 * time.Second
|
||||
// The game server must be unreachable this long before staff are told.
|
||||
serverDownAfter = 30 * time.Second
|
||||
)
|
||||
|
||||
// RunNotifier watches for events to notify staff about, until ctx is done. It does nothing when
|
||||
// push isn't set up.
|
||||
func (s *Server) RunNotifier(ctx context.Context) {
|
||||
if s.cfg.Push == nil {
|
||||
return
|
||||
}
|
||||
w := &watcher{s: s, after: map[string]int64{}, seen: map[string]time.Time{}}
|
||||
for kind, table := range store.PenaltyEventTable {
|
||||
w.after[kind] = s.st.MaxID(ctx, table)
|
||||
}
|
||||
t := time.NewTicker(notifyPoll)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
w.penalties(ctx)
|
||||
w.server(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type watcher struct {
|
||||
s *Server
|
||||
after map[string]int64 // last row handled, per kind
|
||||
seen map[string]time.Time // when an unhandled row was first seen, by kind:id
|
||||
|
||||
serverSeenUp bool
|
||||
downSince time.Time
|
||||
downSent bool
|
||||
}
|
||||
|
||||
func (w *watcher) penalties(ctx context.Context) {
|
||||
now := time.Now()
|
||||
for kind := range store.PenaltyEventTable {
|
||||
events, err := w.s.st.PenaltyEvents(ctx, kind, w.after[kind])
|
||||
if err != nil {
|
||||
slog.Warn("notifications: couldn't read new penalties", "kind", kind, "err", err)
|
||||
continue
|
||||
}
|
||||
waiting := false
|
||||
for _, e := range events {
|
||||
key := kind + ":" + strconv.FormatInt(e.ID, 10)
|
||||
first, ok := w.seen[key]
|
||||
if !ok {
|
||||
w.seen[key] = now
|
||||
first = now
|
||||
}
|
||||
// Keep order: once a row is waiting, later ones wait too, but their clocks still start.
|
||||
if waiting = waiting || now.Sub(first) < notifySettle; waiting {
|
||||
continue
|
||||
}
|
||||
delete(w.seen, key)
|
||||
w.after[kind] = e.ID
|
||||
if n, ok := penaltyNotification(e); ok {
|
||||
w.s.broadcast(ctx, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *watcher) server(ctx context.Context) {
|
||||
st := w.s.poll.Current()
|
||||
if st.Updated.IsZero() {
|
||||
return
|
||||
}
|
||||
if st.Online {
|
||||
if w.downSent {
|
||||
w.s.broadcast(ctx, notification{
|
||||
Type: "server", Urgency: "high", TTL: time.Hour, Topic: "server", Tag: "server",
|
||||
Title: "Server back online",
|
||||
Body: st.Map + ", " + strconv.Itoa(len(st.Humans())) + "/" + strconv.Itoa(st.MaxPlayers) + " players.",
|
||||
Icon: "/static/icon-192.png", URL: "/",
|
||||
})
|
||||
}
|
||||
w.serverSeenUp, w.downSince, w.downSent = true, time.Time{}, false
|
||||
return
|
||||
}
|
||||
// Only a server that was up can go down: no alert when the panel starts with it offline.
|
||||
if !w.serverSeenUp || w.downSent {
|
||||
return
|
||||
}
|
||||
if w.downSince.IsZero() {
|
||||
w.downSince = time.Now()
|
||||
}
|
||||
if time.Since(w.downSince) < serverDownAfter {
|
||||
return
|
||||
}
|
||||
w.downSent = true
|
||||
w.s.broadcast(ctx, notification{
|
||||
Type: "server", Urgency: "high", TTL: time.Hour, Topic: "server", Tag: "server",
|
||||
Title: "Server unreachable",
|
||||
Body: "No answer over RCON since " + w.downSince.In(w.s.st.Location()).Format("15:04") + ". Bans and blocks still save, and apply when players rejoin.",
|
||||
Icon: "/static/icon-192.png", URL: "/admin",
|
||||
})
|
||||
}
|
||||
|
||||
// penaltyNotification describes a new penalty or lift. The link opens the public ban or comm list
|
||||
// searching for the player.
|
||||
func penaltyNotification(e store.PenaltyEvent) (notification, bool) {
|
||||
var key, noun, list string
|
||||
switch e.Kind {
|
||||
case "ban", "unban":
|
||||
key, noun, list = "ban", "Ban", "bans"
|
||||
case "comm", "unmute":
|
||||
key = commKey[e.Type]
|
||||
if key == "" {
|
||||
return notification{}, false
|
||||
}
|
||||
noun, list = strings.ToUpper(key[:1])+key[1:], "comms"
|
||||
default:
|
||||
return notification{}, false
|
||||
}
|
||||
lifted := e.Kind == "unban" || e.Kind == "unmute"
|
||||
verb := "added"
|
||||
if lifted {
|
||||
key, verb = "un"+key, "lifted"
|
||||
}
|
||||
name := e.Name
|
||||
if name == "" {
|
||||
name = e.SteamID
|
||||
}
|
||||
admin := e.AdminName
|
||||
if admin == "" || e.AdminSteamID == "" && strings.EqualFold(admin, "console") {
|
||||
admin = "Console"
|
||||
}
|
||||
parts := []string{admin}
|
||||
if !lifted {
|
||||
if e.Duration == 0 {
|
||||
parts = append(parts, "Permanent")
|
||||
} else {
|
||||
parts = append(parts, fmtMinutes(e.Duration))
|
||||
}
|
||||
}
|
||||
if r := strings.TrimSpace(e.Reason); r != "" {
|
||||
parts = append(parts, r)
|
||||
}
|
||||
n := notification{
|
||||
Type: key,
|
||||
Actor: e.AdminSteamID,
|
||||
Title: noun + " " + verb + ": " + name,
|
||||
Body: strings.Join(parts, " · "),
|
||||
Tag: e.Kind + "-" + strconv.FormatInt(e.ID, 10),
|
||||
URL: "/#" + list + "?q=" + url.QueryEscape(e.SteamID),
|
||||
Actions: []notifAction{{Action: "view", Title: "View player"}},
|
||||
}
|
||||
if e.SteamID != "" {
|
||||
n.Icon = "/avatar/" + e.SteamID
|
||||
}
|
||||
if r := []rune(n.Body); len(r) > 300 {
|
||||
n.Body = string(r[:300]) + "…"
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue