diff --git a/VRWT_Tool/VRC_OSC/cmd/vrc_osc/main.go b/VRWT_Tool/VRC_OSC/cmd/vrc_osc/main.go new file mode 100644 index 0000000..6cb986e --- /dev/null +++ b/VRWT_Tool/VRC_OSC/cmd/vrc_osc/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "flag" + "log" + "os" + + "vrc_osc_go/internal/app" +) + +func main() { + var configPath string + var mode string + flag.StringVar(&configPath, "config", "config/config.toml", "path to config.toml") + flag.StringVar(&mode, "mode", "", "generate-json or extract-log") + flag.Parse() + + if err := app.Run(configPath, mode); err != nil { + log.SetOutput(os.Stderr) + log.Fatalf("vrc_osc_go: %v", err) + } +} diff --git a/VRWT_Tool/VRC_OSC/cmd/vrwt_tool/main.go b/VRWT_Tool/VRC_OSC/cmd/vrwt_tool/main.go new file mode 100644 index 0000000..dda9087 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/cmd/vrwt_tool/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "vrc_osc_go/internal/consenttool" +) + +func main() { + var cfgPath string + var mode string + + flag.StringVar(&cfgPath, "config", "config/consent.toml", "path to consent tool config") + flag.StringVar(&mode, "mode", "", "generate-json or extract-log") + flag.Parse() + + if mode == "" && flag.NArg() > 0 { + mode = flag.Arg(0) + } + + if mode == "" { + fmt.Fprintln(os.Stderr, "missing mode: generate-json or extract-log") + os.Exit(2) + } + + if err := consenttool.Run(cfgPath, mode); err != nil { + log.SetOutput(os.Stderr) + log.Fatalf("vrwt_tool: %v", err) + } +} diff --git a/VRWT_Tool/VRC_OSC/internal/app/app.go b/VRWT_Tool/VRC_OSC/internal/app/app.go new file mode 100644 index 0000000..f9f173b --- /dev/null +++ b/VRWT_Tool/VRC_OSC/internal/app/app.go @@ -0,0 +1,41 @@ +package app + +import ( + "fmt" + "os" + "os/signal" + "syscall" + + "vrc_osc_go/internal/config" + "vrc_osc_go/internal/consenttool" + "vrc_osc_go/internal/osc" +) + +func Run(configPath string, mode string) error { + if mode != "" { + return consenttool.Run(configPath, mode) + } + + cfg, err := config.Load(configPath) + if err != nil { + return err + } + + server := osc.NewServer(cfg.OSC.Host, cfg.OSC.Port) + server.Map("DiscordSend", func(_ string, args []osc.Value) error { + return fmt.Errorf("discord mute not implemented yet") + }) + + errCh := make(chan error, 1) + go func() { errCh <- server.Serve() }() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + select { + case err := <-errCh: + return err + case <-sigCh: + server.Close() + return nil + } +} diff --git a/VRWT_Tool/VRC_OSC/internal/consenttool/config.go b/VRWT_Tool/VRC_OSC/internal/consenttool/config.go new file mode 100644 index 0000000..702f2ca --- /dev/null +++ b/VRWT_Tool/VRC_OSC/internal/consenttool/config.go @@ -0,0 +1,144 @@ +package consenttool + +import ( + "bufio" + "os" + "path/filepath" + "strconv" + "strings" +) + +type Config struct { + Consent ConsentConfig + Log LogConfig +} + +type ConsentConfig struct { + InputCSV string + OutputJSON string + DisplayFields []string + ConsentFields []string + ConsentValues []string + NormalizeCase bool + AllowDuplicates bool +} + +type LogConfig struct { + InputPath string + OutputPath string + IncludeRegex string + KeepNonMatch bool +} + +func LoadConfig(path string) (*Config, error) { + cfg := &Config{ + Consent: ConsentConfig{ + InputCSV: "config/consent.csv", + OutputJSON: "config/consent_list.json", + DisplayFields: []string{"display_name", "vrchat_name", "name"}, + ConsentFields: []string{"consent", "agree", "agreed", "checked"}, + ConsentValues: []string{"1", "true", "yes", "y", "on", "checked"}, + NormalizeCase: false, + AllowDuplicates: false, + }, + Log: LogConfig{ + InputPath: "runtime/output_log.txt", + OutputPath: "runtime/research_log.txt", + IncludeRegex: `\[[A-Z0-9:_ -]+\]`, + KeepNonMatch: false, + }, + } + + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + var section string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + line = strings.TrimPrefix(line, "\ufeff") + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + section = strings.Trim(line, "[]") + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + val = strings.Trim(val, "\"'") + + switch section { + case "consent": + switch key { + case "input_csv": + cfg.Consent.InputCSV = val + case "output_json": + cfg.Consent.OutputJSON = val + case "display_fields": + cfg.Consent.DisplayFields = parseList(val) + case "consent_fields": + cfg.Consent.ConsentFields = parseList(val) + case "consent_values": + cfg.Consent.ConsentValues = parseList(val) + case "normalize_case": + cfg.Consent.NormalizeCase = parseBool(val) + case "allow_duplicates": + cfg.Consent.AllowDuplicates = parseBool(val) + } + case "log": + switch key { + case "input_path": + cfg.Log.InputPath = val + case "output_path": + cfg.Log.OutputPath = val + case "include_regex": + cfg.Log.IncludeRegex = val + case "keep_non_match": + cfg.Log.KeepNonMatch = parseBool(val) + } + } + } + return cfg, scanner.Err() +} + +func parseList(value string) []string { + value = strings.TrimSpace(value) + value = strings.TrimPrefix(value, "[") + value = strings.TrimSuffix(value, "]") + if value == "" { + return nil + } + items := strings.Split(value, ",") + out := make([]string, 0, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + item = strings.Trim(item, "\"'") + if item != "" { + out = append(out, item) + } + } + return out +} + +func parseBool(value string) bool { + n, err := strconv.ParseBool(strings.TrimSpace(value)) + return err == nil && n +} + +func resolvePath(baseDir, value string) string { + if value == "" { + return "" + } + if filepath.IsAbs(value) { + return value + } + return filepath.Clean(filepath.Join(baseDir, value)) +} diff --git a/VRWT_Tool/VRC_OSC/internal/consenttool/tool.go b/VRWT_Tool/VRC_OSC/internal/consenttool/tool.go new file mode 100644 index 0000000..1a58dd2 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/internal/consenttool/tool.go @@ -0,0 +1,183 @@ +package consenttool + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" +) + +func Run(configPath string, mode string) error { + cfg, err := LoadConfig(configPath) + if err != nil { + return err + } + baseDir := filepath.Dir(configPath) + + switch mode { + case "generate-json": + return generateJSON(cfg, baseDir) + case "extract-log": + return extractLog(cfg, baseDir) + default: + return fmt.Errorf("unknown mode %q", mode) + } +} + +func generateJSON(cfg *Config, baseDir string) error { + inputPath := resolvePath(baseDir, cfg.Consent.InputCSV) + outputPath := resolvePath(baseDir, cfg.Consent.OutputJSON) + + rows, err := readCSVRows(inputPath) + if err != nil { + return err + } + + names := collectConsentedNames(rows, cfg.Consent) + body, err := json.MarshalIndent(names, "", " ") + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + return err + } + return os.WriteFile(outputPath, append(body, '\n'), 0o644) +} + +func extractLog(cfg *Config, baseDir string) error { + inputPath := resolvePath(baseDir, cfg.Log.InputPath) + outputPath := resolvePath(baseDir, cfg.Log.OutputPath) + + data, err := os.ReadFile(inputPath) + if err != nil { + return err + } + + re, err := regexp.Compile(cfg.Log.IncludeRegex) + if err != nil { + return err + } + + var kept []string + for _, line := range strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") { + if re.MatchString(line) { + kept = append(kept, line) + continue + } + if cfg.Log.KeepNonMatch { + kept = append(kept, line) + } + } + + body := strings.Join(kept, "\n") + if body != "" { + body += "\n" + } + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + return err + } + return os.WriteFile(outputPath, []byte(body), 0o644) +} + +func readCSVRows(path string) ([]map[string]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + r := csv.NewReader(file) + headers, err := r.Read() + if err != nil { + return nil, err + } + for i, header := range headers { + headers[i] = strings.TrimPrefix(strings.TrimSpace(header), "\ufeff") + } + + var rows []map[string]string + for { + record, err := r.Read() + if err != nil { + if err == io.EOF { + break + } + return nil, err + } + row := make(map[string]string, len(headers)) + for i, header := range headers { + if i < len(record) { + row[strings.TrimSpace(header)] = strings.TrimPrefix(strings.TrimSpace(record[i]), "\ufeff") + } + } + rows = append(rows, row) + } + return rows, nil +} + +func collectConsentedNames(rows []map[string]string, cfg ConsentConfig) []string { + displayFields := cfg.DisplayFields + if len(displayFields) == 0 { + displayFields = []string{"display_name", "vrchat_name", "name"} + } + consentFields := cfg.ConsentFields + if len(consentFields) == 0 { + consentFields = []string{"consent", "agree", "agreed", "checked"} + } + accepted := make(map[string]struct{}) + var names []string + + for _, row := range rows { + if !rowIsConsented(row, consentFields, cfg.ConsentValues) { + continue + } + name := firstNonEmpty(row, displayFields) + if name == "" { + continue + } + key := name + if cfg.NormalizeCase { + key = strings.ToLower(key) + } + if !cfg.AllowDuplicates { + if _, ok := accepted[key]; ok { + continue + } + accepted[key] = struct{}{} + } + names = append(names, name) + } + return names +} + +func rowIsConsented(row map[string]string, fields []string, values []string) bool { + if len(values) == 0 { + values = []string{"1", "true", "yes", "y", "on", "checked"} + } + allowed := make(map[string]struct{}, len(values)) + for _, v := range values { + allowed[strings.ToLower(strings.TrimSpace(v))] = struct{}{} + } + for _, field := range fields { + if v, ok := row[field]; ok { + if _, ok := allowed[strings.ToLower(strings.TrimSpace(v))]; ok { + return true + } + } + } + return false +} + +func firstNonEmpty(row map[string]string, fields []string) string { + for _, field := range fields { + if v := strings.TrimSpace(row[field]); v != "" { + return v + } + } + return "" +} diff --git a/VRWT_Tool/VRC_OSC/internal/consenttool/tool_test.go b/VRWT_Tool/VRC_OSC/internal/consenttool/tool_test.go new file mode 100644 index 0000000..a7ef4b6 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/internal/consenttool/tool_test.go @@ -0,0 +1,44 @@ +package consenttool + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCollectConsentedNames(t *testing.T) { + rows := []map[string]string{ + {"display_name": "Alice", "consent": "true"}, + {"display_name": "Bob", "consent": "false"}, + {"display_name": "Alice", "consent": "yes"}, + } + + names := collectConsentedNames(rows, ConsentConfig{}) + if len(names) != 1 || names[0] != "Alice" { + t.Fatalf("unexpected names: %#v", names) + } +} + +func TestExtractLog(t *testing.T) { + dir := t.TempDir() + cfg := &Config{ + Log: LogConfig{ + InputPath: "input.log", + OutputPath: "out.log", + IncludeRegex: `ID:[0-9]+`, + }, + } + if err := os.WriteFile(filepath.Join(dir, "input.log"), []byte("x\nID:1 hello\nnope\n"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + if err := extractLog(cfg, dir); err != nil { + t.Fatalf("extractLog: %v", err) + } + out, err := os.ReadFile(filepath.Join(dir, "out.log")) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(out) != "ID:1 hello\n" { + t.Fatalf("unexpected output: %q", string(out)) + } +}