Add Go consent tool and Gitea build workflow

This commit is contained in:
every_holiday
2026-06-24 00:43:46 +09:00
parent fad20a5677
commit bd29099425
6 changed files with 467 additions and 0 deletions

View 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)
}
}

View 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)
}
}

View 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
}
}

View 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))
}

View 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 ""
}

View 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))
}
}