Add Go consent tool and Gitea build workflow
This commit is contained in:
22
VRWT_Tool/VRC_OSC/cmd/vrc_osc/main.go
Normal file
22
VRWT_Tool/VRC_OSC/cmd/vrc_osc/main.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
33
VRWT_Tool/VRC_OSC/cmd/vrwt_tool/main.go
Normal file
33
VRWT_Tool/VRC_OSC/cmd/vrwt_tool/main.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
41
VRWT_Tool/VRC_OSC/internal/app/app.go
Normal file
41
VRWT_Tool/VRC_OSC/internal/app/app.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
144
VRWT_Tool/VRC_OSC/internal/consenttool/config.go
Normal file
144
VRWT_Tool/VRC_OSC/internal/consenttool/config.go
Normal file
@@ -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))
|
||||
}
|
||||
183
VRWT_Tool/VRC_OSC/internal/consenttool/tool.go
Normal file
183
VRWT_Tool/VRC_OSC/internal/consenttool/tool.go
Normal file
@@ -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 ""
|
||||
}
|
||||
44
VRWT_Tool/VRC_OSC/internal/consenttool/tool_test.go
Normal file
44
VRWT_Tool/VRC_OSC/internal/consenttool/tool_test.go
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user