215 lines
4.8 KiB
Go
215 lines
4.8 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"vrc_osc_go/internal/common"
|
|
)
|
|
|
|
const joinLeaveEventsFileName = "join_leave_events.json"
|
|
|
|
var joinLeaveEventLinePattern = regexp.MustCompile(`^\[(join|leave)\]\s+(.+?)\s+\((\d+)\)$`)
|
|
|
|
type JoinLeaveEvent struct {
|
|
At time.Time `json:"at"`
|
|
Kind string `json:"kind"`
|
|
Name string `json:"name"`
|
|
Count int `json:"count,omitempty"`
|
|
}
|
|
|
|
type joinLeaveEventsSnapshot struct {
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Events []JoinLeaveEvent `json:"events"`
|
|
}
|
|
|
|
type JoinLeaveEventTracker struct {
|
|
mu sync.RWMutex
|
|
events []JoinLeaveEvent
|
|
}
|
|
|
|
var joinLeaveEventTracker *JoinLeaveEventTracker
|
|
|
|
func InitJoinLeaveEventTracker(t *JoinLeaveEventTracker) {
|
|
joinLeaveEventTracker = t
|
|
}
|
|
|
|
func GetJoinLeaveEventTracker() *JoinLeaveEventTracker {
|
|
return joinLeaveEventTracker
|
|
}
|
|
|
|
func NewJoinLeaveEventTracker() *JoinLeaveEventTracker {
|
|
t := &JoinLeaveEventTracker{}
|
|
_ = t.load()
|
|
return t
|
|
}
|
|
|
|
func (t *JoinLeaveEventTracker) load() error {
|
|
dir := filepath.Join(common.RootDir(), "runtime")
|
|
path := filepath.Join(dir, joinLeaveEventsFileName)
|
|
b, err := os.ReadFile(path)
|
|
if err == nil && len(b) > 0 {
|
|
if events, ok := decodeJoinLeaveEventsSnapshot(b); ok {
|
|
t.events = dedupeJoinLeaveEvents(events)
|
|
return nil
|
|
}
|
|
}
|
|
events, err := loadJoinLeaveEventsFromTextLog(filepath.Join(dir, "join_leave.log"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
t.events = dedupeJoinLeaveEvents(events)
|
|
if len(t.events) > 0 {
|
|
_ = t.persistLocked()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func decodeJoinLeaveEventsSnapshot(b []byte) ([]JoinLeaveEvent, bool) {
|
|
var snap joinLeaveEventsSnapshot
|
|
if err := json.Unmarshal(b, &snap); err == nil && len(snap.Events) > 0 {
|
|
return snap.Events, true
|
|
}
|
|
var events []JoinLeaveEvent
|
|
if err := json.Unmarshal(b, &events); err == nil {
|
|
return events, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func loadJoinLeaveEventsFromTextLog(path string) ([]JoinLeaveEvent, error) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil || len(b) == 0 {
|
|
return nil, err
|
|
}
|
|
events := make([]JoinLeaveEvent, 0, 256)
|
|
var currentAt time.Time
|
|
for _, raw := range strings.Split(string(b), "\n") {
|
|
line := strings.TrimSpace(strings.TrimRight(raw, "\r"))
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if strings.Contains(line, "VRC JOIN/LEAVE") {
|
|
if len(line) >= 21 {
|
|
if at, err := time.Parse("2006-01-02 15:04:05", line[1:20]); err == nil {
|
|
currentAt = at
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
m := joinLeaveEventLinePattern.FindStringSubmatch(line)
|
|
if len(m) != 4 {
|
|
continue
|
|
}
|
|
count, _ := strconv.Atoi(m[3])
|
|
events = append(events, JoinLeaveEvent{
|
|
At: currentAt,
|
|
Kind: m[1],
|
|
Name: strings.TrimSpace(m[2]),
|
|
Count: count,
|
|
})
|
|
}
|
|
return events, nil
|
|
}
|
|
|
|
func (t *JoinLeaveEventTracker) Record(kind, name string, count int, at time.Time) {
|
|
if t == nil {
|
|
return
|
|
}
|
|
kind = strings.TrimSpace(kind)
|
|
name = strings.TrimSpace(name)
|
|
if kind != "join" && kind != "leave" {
|
|
return
|
|
}
|
|
if name == "" {
|
|
return
|
|
}
|
|
if at.IsZero() {
|
|
at = time.Now()
|
|
}
|
|
ev := JoinLeaveEvent{At: at, Kind: kind, Name: name, Count: count}
|
|
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
if containsJoinLeaveEvent(t.events, ev) {
|
|
return
|
|
}
|
|
t.events = append(t.events, ev)
|
|
_ = t.persistLocked()
|
|
}
|
|
|
|
func (t *JoinLeaveEventTracker) Snapshot() []JoinLeaveEvent {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
t.mu.RLock()
|
|
defer t.mu.RUnlock()
|
|
out := append([]JoinLeaveEvent(nil), t.events...)
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
if out[i].At.Equal(out[j].At) {
|
|
if out[i].Kind == out[j].Kind {
|
|
return out[i].Name < out[j].Name
|
|
}
|
|
return out[i].Kind < out[j].Kind
|
|
}
|
|
return out[i].At.Before(out[j].At)
|
|
})
|
|
return out
|
|
}
|
|
|
|
func (t *JoinLeaveEventTracker) persistLocked() error {
|
|
dir := filepath.Join(common.RootDir(), "runtime")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
f, err := os.Create(filepath.Join(dir, joinLeaveEventsFileName))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
enc := json.NewEncoder(f)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(joinLeaveEventsSnapshot{
|
|
UpdatedAt: time.Now(),
|
|
Events: append([]JoinLeaveEvent(nil), t.events...),
|
|
})
|
|
}
|
|
|
|
func dedupeJoinLeaveEvents(events []JoinLeaveEvent) []JoinLeaveEvent {
|
|
if len(events) <= 1 {
|
|
return events
|
|
}
|
|
seen := make(map[string]struct{}, len(events))
|
|
out := make([]JoinLeaveEvent, 0, len(events))
|
|
for _, ev := range events {
|
|
key := joinLeaveEventKey(ev)
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
out = append(out, ev)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func containsJoinLeaveEvent(events []JoinLeaveEvent, candidate JoinLeaveEvent) bool {
|
|
key := joinLeaveEventKey(candidate)
|
|
for _, ev := range events {
|
|
if joinLeaveEventKey(ev) == key {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func joinLeaveEventKey(ev JoinLeaveEvent) string {
|
|
return ev.At.UTC().Format(time.RFC3339Nano) + "|" + ev.Kind + "|" + ev.Name
|
|
}
|