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 }