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