package companion import ( "crypto/sha256" "encoding/hex" "regexp" "strings" "time" ) var scumLoginLogLine = regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}-\d{2}\.\d{2}\.\d{2}: '([0-9.]+) (\d{1,50}):([^']{1,80})\(\d+\)' logged (in|out)(?: .*)?$`) // ConsoleRecord is supplied by Run's stdout/stderr stream, not by the server // execution log. The channel never accepts a file path or a raw log archive. type ConsoleRecord struct { ServerID string Stream string Sequence uint64 OccurredAt time.Time Text string } type SemanticEvent struct { ServerID string Sequence uint64 Type string PlayerID string DisplayName string OccurredAt time.Time NetworkCorrelation string } type EventDiagnostic struct { ServerID string Sequence uint64 Code string } type SemanticEventBatch struct { ServerID string FirstSequence uint64 Events []SemanticEvent Diagnostics []EventDiagnostic } // ParseConsoleRecords accepts only bounded stdout/stderr records. Unknown // formats produce a bounded diagnostic and are skipped; they never fabricate // events or carry raw console text across the plugin boundary. func ParseConsoleRecords(serverID string, records []ConsoleRecord, correlationSecret string) SemanticEventBatch { batch := SemanticEventBatch{ServerID: serverID} if len(records) > 100 { records = records[:100] } for _, record := range records { if record.ServerID != serverID || (record.Stream != "stdout" && record.Stream != "stderr") || record.Sequence == 0 || record.OccurredAt.IsZero() || len(record.Text) > 1024 { batch.Diagnostics = appendDiagnostic(batch.Diagnostics, EventDiagnostic{ServerID: serverID, Sequence: record.Sequence, Code: "invalid-console-record"}) continue } if batch.FirstSequence == 0 { batch.FirstSequence = record.Sequence } event, ok := parseConsoleRecord(record, correlationSecret) if !ok { batch.Diagnostics = appendDiagnostic(batch.Diagnostics, EventDiagnostic{ServerID: serverID, Sequence: record.Sequence, Code: "unknown-console-format"}) continue } batch.Events = append(batch.Events, event) } return batch } func parseConsoleRecord(record ConsoleRecord, secret string) (SemanticEvent, bool) { if event, ok := parseLoginLogRecord(record, secret); ok { return event, true } fields := strings.Fields(record.Text) if len(fields) < 3 || fields[0] != "SCUM" || (fields[1] != "LOGIN" && fields[1] != "LOGOUT") || !steamID64(fields[2]) { return SemanticEvent{}, false } eventType := "scum.login" if fields[1] == "LOGOUT" { eventType = "scum.logout" } event := SemanticEvent{ServerID: record.ServerID, Sequence: record.Sequence, Type: eventType, PlayerID: fields[2], OccurredAt: record.OccurredAt} if len(fields) == 4 && secret != "" { event.NetworkCorrelation = networkCorrelation(record.ServerID, fields[3], secret) } return event, true } func parseLoginLogRecord(record ConsoleRecord, secret string) (SemanticEvent, bool) { match := scumLoginLogLine.FindStringSubmatch(record.Text) if match == nil || !steamID64(match[2]) { return SemanticEvent{}, false } eventType := "scum.login" if match[4] == "out" { eventType = "scum.logout" } event := SemanticEvent{ServerID: record.ServerID, Sequence: record.Sequence, Type: eventType, PlayerID: match[2], DisplayName: match[3], OccurredAt: record.OccurredAt} if secret != "" { event.NetworkCorrelation = networkCorrelation(record.ServerID, match[1], secret) } return event, true } func networkCorrelation(serverID, value, secret string) string { digest := sha256.Sum256([]byte(serverID + "\x00" + secret + "\x00" + value)) return hex.EncodeToString(digest[:]) } func appendDiagnostic(existing []EventDiagnostic, diagnostic EventDiagnostic) []EventDiagnostic { if len(existing) >= 32 { return existing } return append(existing, diagnostic) } func VerifiedSemanticEventProducer() SemanticEventProducerAvailability { return SemanticEventProducerAvailability{Available: true, Reason: "Run stdout/stderr semantic parser is available"} } type SemanticEventProducerAvailability struct { Available bool Reason string }