Files
panel/agent/cmd/region-medic/autoheal_extra.go
T
2026-07-14 19:19:43 -07:00

201 lines
6.3 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
_ "time/tzdata" // embed the IANA tz database so America/Los_Angeles resolves
// even in debian:12-slim, which ships no /usr/share/zoneinfo.
)
// resolveRegionDir finds the active world's Region dir under a 7DTD saves root
// (the dir mounted as the saves volume — holds serverconfig.xml + .local/...).
// Prefers serverconfig GameWorld/GameName when that world has real data
// (main.ttw); else falls back to the most-recently-written Saves/*/<GameName>
// with data — handles RWG seed-named worlds.
func resolveRegionDir(savesRoot string) (string, error) {
raw, err := os.ReadFile(filepath.Join(savesRoot, "serverconfig.xml"))
if err != nil {
return "", fmt.Errorf("read serverconfig.xml: %w", err)
}
gw := xmlProp(string(raw), "GameWorld")
gn := xmlProp(string(raw), "GameName")
base := filepath.Join(savesRoot, ".local", "share", "7DaysToDie", "Saves")
hasWorld := func(d string) bool {
if _, e := os.Stat(filepath.Join(d, "main.ttw")); e == nil {
return true
}
fi, e := os.Stat(filepath.Join(d, "Region"))
return e == nil && fi.IsDir()
}
if gw != "" && gn != "" {
lit := filepath.Join(base, gw, gn)
if hasWorld(lit) {
return filepath.Join(lit, "Region"), nil
}
}
if gn != "" { // RWG fallback: newest world dir with this GameName.
worlds, _ := os.ReadDir(base)
var newest string
var newestT time.Time
for _, w := range worlds {
if !w.IsDir() {
continue
}
cand := filepath.Join(base, w.Name(), gn)
if !hasWorld(cand) {
continue
}
if fi, e := os.Stat(cand); e == nil && fi.ModTime().After(newestT) {
newestT, newest = fi.ModTime(), cand
}
}
if newest != "" {
return filepath.Join(newest, "Region"), nil
}
}
return "", fmt.Errorf("could not resolve active Region dir (GameWorld=%q GameName=%q)", gw, gn)
}
// xmlProp pulls the value from <property name="X" value="Y" /> (tolerant of
// attribute order + quote style; skips comments).
func xmlProp(content, name string) string {
for _, raw := range strings.Split(content, "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "<!--") {
continue
}
if !strings.Contains(line, `name="`+name+`"`) {
continue
}
i := strings.Index(line, "value=")
if i < 0 {
continue
}
rest := line[i+len("value="):]
if len(rest) < 1 {
continue
}
q := rest[0]
rest = rest[1:]
if end := strings.IndexByte(rest, q); end >= 0 {
return strings.TrimSpace(rest[:end])
}
}
return ""
}
// postDiscordHealReport posts a rich embed heal summary to a Discord channel via
// a bot token read from tokenFile. No-op when token/channel missing or nothing
// healed. All times are rendered in Pacific (America/Los_Angeles → PST/PDT).
func postDiscordHealReport(tokenFile, channel, label string, rep ahReport) error {
if tokenFile == "" || channel == "" || len(rep.Healed) == 0 {
return nil
}
tb, err := os.ReadFile(tokenFile)
if err != nil {
return fmt.Errorf("read discord token: %w", err)
}
token := strings.TrimSpace(string(tb))
if token == "" {
return nil
}
if label == "" {
label = "7DTD server"
}
loc, lerr := time.LoadLocation("America/Los_Angeles")
if lerr != nil || loc == nil {
loc = time.UTC
}
pst := func(rfc string) string {
if t, e := time.Parse(time.RFC3339, rfc); e == nil {
return t.In(loc).Format("Jan 2, 3:04 PM MST")
}
return rfc
}
srcLabel := func(s string) string {
switch {
case strings.HasPrefix(s, "rolling:"):
return "rolling snapshot"
case strings.HasPrefix(s, "backup:"):
return "panel backup"
default:
return s
}
}
var lines []string
for _, h := range rep.Healed {
lines = append(lines, fmt.Sprintf("• `%s` ← %s _(saved %s)_", h.Region, srcLabel(h.Source), pst(h.SourceTime)))
}
color := 0x57F287 // green — everything healed
fields := []map[string]any{{
"name": fmt.Sprintf("✅ Healed %d region(s)", len(rep.Healed)),
"value": strings.Join(lines, "\n"),
"inline": false,
}}
if len(rep.Unhealable) > 0 {
color = 0xED4245 // red — some couldn't be healed
fields = append(fields, map[string]any{
"name": fmt.Sprintf("⚠️ Could NOT heal %d (no clean copy anywhere)", len(rep.Unhealable)),
"value": "`" + strings.Join(rep.Unhealable, "`, `") + "`",
"inline": false,
})
}
nowPST := time.Now().In(loc).Format("Mon Jan 2, 3:04 PM MST")
embed := map[string]any{
"title": "🩺 Region Medic — " + label,
"description": fmt.Sprintf("Detected and repaired **%d** corrupt region file(s) on reboot, before the world loaded.", rep.CorruptFound),
"color": color,
"fields": fields,
"footer": map[string]any{"text": "Region Medic · " + nowPST},
}
body, _ := json.Marshal(map[string]any{"embeds": []any{embed}})
req, err := http.NewRequest(http.MethodPost, "https://discord.com/api/v10/channels/"+channel+"/messages", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bot "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "RefugeBot-RegionMedic (https://refugegaming.org, 1.0)")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("discord HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
return nil
}
// savesMedicConfig is the per-server medic config the panel writes (via FsWrite,
// no container recreate) into the saves volume at region-medic.json. Enabled is a
// pointer so an absent field defaults to ON.
type savesMedicConfig struct {
Enabled *bool `json:"enabled"`
Keep int `json:"keep"`
World string `json:"world"` // "<World>/<GameName>"; empty = auto-detect active world
DiscordChannel string `json:"discord_channel"`
}
// readSavesMedicConfig reads <savesRoot>/region-medic.json; ok=false if absent or
// unparseable (the caller then keeps its flag defaults).
func readSavesMedicConfig(savesRoot string) (savesMedicConfig, bool) {
b, err := os.ReadFile(filepath.Join(savesRoot, "region-medic.json"))
if err != nil {
return savesMedicConfig{}, false
}
var c savesMedicConfig
if json.Unmarshal(b, &c) != nil {
return savesMedicConfig{}, false
}
return c, true
}