03a281d009
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
201 lines
6.3 KiB
Go
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
|
|
}
|