diff --git a/.gitea/workflows/build-windows-exe.yml b/.gitea/workflows/build-windows-exe.yml new file mode 100644 index 0000000..6d8f9f6 --- /dev/null +++ b/.gitea/workflows/build-windows-exe.yml @@ -0,0 +1,79 @@ +name: build-windows-exe + +on: + push: + tags: + - "v*" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + shell: bash + run: | + git clone --depth 1 --branch "${GITHUB_REF_NAME}" https://git.vrcworldtour.com/every_holiday/VRCWT-OSC.git . + + - name: Install Go + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y golang-go + go version + + - name: Test + shell: bash + run: | + cd VRWT_Tool/VRC_OSC + GO111MODULE=on go test ./... + + - name: Build executables + shell: bash + run: | + cd VRWT_Tool/VRC_OSC + mkdir -p dist + VERSION="${GITHUB_REF_NAME}" + BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc.exe ./cmd/vrc_osc + GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc_launcher.exe ./cmd/vrc_osc_launcher + GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrwt_tool.exe ./cmd/vrwt_tool + + - name: Package executables + shell: bash + run: | + cd VRWT_Tool/VRC_OSC + cp config/config.example.toml dist/config.example.toml + cp config/secrets.example.toml dist/secrets.example.toml + cp config/guests.example.txt dist/guests.example.txt + (cd dist && zip -r vrc_osc-windows.zip vrc_osc.exe vrc_osc_launcher.exe vrwt_tool.exe config.example.toml secrets.example.toml guests.example.txt) + + - name: Create Release + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_BASE_URL: ${{ secrets.GITEA_BASE_URL }} + TAG_NAME: ${{ github.ref_name }} + shell: bash + run: | + set -euo pipefail + api="${GITEA_BASE_URL:-https://git.vrcworldtour.com}/api/v1" + owner_repo="every_holiday/VRCWT-OSC" + release_json=$(curl -fsSL \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + "${api}/repos/${owner_repo}/releases/tags/${TAG_NAME}" || true) + if [ -n "${release_json}" ]; then + release_id=$(printf '%s' "${release_json}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') + else + release_json=$(curl -fsSL -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"${TAG_NAME}\",\"name\":\"${TAG_NAME}\",\"draft\":false,\"prerelease\":false}" \ + "${api}/repos/${owner_repo}/releases") + release_id=$(printf '%s' "${release_json}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') + fi + asset_path="VRWT_Tool/VRC_OSC/dist/vrc_osc-windows.zip" + curl -fsSL -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -F "attachment=@${asset_path}" \ + "${api}/repos/${owner_repo}/releases/${release_id}/assets?name=vrc_osc-windows.zip" diff --git a/VRWT_Tool/.gitea/workflows/build-windows-exe.yml b/VRWT_Tool/.gitea/workflows/build-windows-exe.yml new file mode 100644 index 0000000..91d0fcb --- /dev/null +++ b/VRWT_Tool/.gitea/workflows/build-windows-exe.yml @@ -0,0 +1,50 @@ +name: build-windows-exe + +on: + push: + +jobs: + build: + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Go + shell: pwsh + run: | + choco install golang --version=1.22.0 -y --no-progress + $env:PATH = "C:\Program Files\Go\bin;$env:PATH" + go version + + - name: Test + working-directory: VRC_OSC + shell: pwsh + run: | + $env:PATH = "C:\Program Files\Go\bin;$env:PATH" + go test ./... + + - name: Build executables + working-directory: VRC_OSC + shell: pwsh + run: | + $env:PATH = "C:\Program Files\Go\bin;$env:PATH" + go build -o dist\vrc_osc.exe ./cmd/vrc_osc + go build -o dist\vrwt_tool.exe ./cmd/vrwt_tool + + - name: Package executables + working-directory: VRC_OSC + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path dist | Out-Null + Copy-Item config\config.example.toml dist\config.example.toml + Copy-Item config\secrets.example.toml dist\secrets.example.toml + Copy-Item config\guests.example.txt dist\guests.example.txt + Compress-Archive -Path dist\vrc_osc.exe,dist\vrwt_tool.exe,dist\config.example.toml,dist\secrets.example.toml,dist\guests.example.txt -DestinationPath dist\vrc_osc-windows.zip -Force + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: vrc_osc-windows + path: VRC_OSC/dist/vrc_osc-windows.zip diff --git a/VRWT_Tool/VRC_OSC/.gitignore b/VRWT_Tool/VRC_OSC/.gitignore new file mode 100644 index 0000000..20a9c46 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +*.py[cod] + +config/config.toml +config/secrets.toml +config/guests.txt +runtime/ +.vrchat_cookie.txt +src/.vrchat_cookie.txt + +*.bak +*.bak_* +backup_*/ diff --git a/VRWT_Tool/VRC_OSC/assets/icon.ico b/VRWT_Tool/VRC_OSC/assets/icon.ico new file mode 100644 index 0000000..49294f2 Binary files /dev/null and b/VRWT_Tool/VRC_OSC/assets/icon.ico differ diff --git a/VRWT_Tool/VRC_OSC/assets/icon.png b/VRWT_Tool/VRC_OSC/assets/icon.png new file mode 100644 index 0000000..981b1f6 Binary files /dev/null and b/VRWT_Tool/VRC_OSC/assets/icon.png differ diff --git a/VRWT_Tool/VRC_OSC/bin/vrc_osc.ps1 b/VRWT_Tool/VRC_OSC/bin/vrc_osc.ps1 new file mode 100644 index 0000000..647fa3e --- /dev/null +++ b/VRWT_Tool/VRC_OSC/bin/vrc_osc.ps1 @@ -0,0 +1,7 @@ +$ErrorActionPreference = "Stop" + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Resolve-Path (Join-Path $ScriptDir "..") +$EntryPoint = Join-Path $RootDir "bin\vrc_osc.py" + +python $EntryPoint 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..67bca30 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/cmd/vrc_osc/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "flag" + "log" + "os" + "os/exec" + "path/filepath" + + "vrc_osc_go/internal/app" +) + +func main() { + var configPath string + var mode string + var noGUI bool + flag.StringVar(&configPath, "config", "config/config.toml", "path to config.toml") + flag.StringVar(&mode, "mode", "", "generate-json or extract-log") + flag.BoolVar(&noGUI, "no-gui", false, "do not launch the GUI companion") + flag.Parse() + + if mode == "" && !noGUI { + launchGUI() + } + + if err := app.Run(configPath, mode); err != nil { + log.SetOutput(os.Stderr) + log.Fatalf("vrc_osc_go: %v", err) + } +} + +func launchGUI() { + exe, err := os.Executable() + if err != nil { + return + } + gui := filepath.Join(filepath.Dir(exe), "vrc_osc_gui.exe") + if _, err := os.Stat(gui); err != nil { + return + } + _ = exec.Command(gui).Start() +} diff --git a/VRWT_Tool/VRC_OSC/cmd/vrc_osc_gui/main.go b/VRWT_Tool/VRC_OSC/cmd/vrc_osc_gui/main.go new file mode 100644 index 0000000..6f8b833 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/cmd/vrc_osc_gui/main.go @@ -0,0 +1,198 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" +) + +func runtimeDir() string { + exe, err := os.Executable() + if err != nil { + return "runtime" + } + return filepath.Join(filepath.Dir(exe), "runtime") +} + +func main() { + mux := http.NewServeMux() + mux.HandleFunc("/", handleIndex) + mux.HandleFunc("/api/state", handleState) + + addr := "127.0.0.1:18181" + go openBrowser("http://" + addr) + log.Printf("gui listening on http://%s", addr) + log.Fatal(http.ListenAndServe(addr, mux)) +} + +func handleIndex(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + html := ` + + + + + VRTW_Tool + + + +

VRTW_Tool

+
loading...
+ + +` + _, _ = fmt.Fprint(w, html) +} + +func handleState(w http.ResponseWriter, r *http.Request) { + statePath := filepath.Join(runtimeDir(), "state.json") + body, err := os.ReadFile(statePath) + if err != nil { + _ = json.NewEncoder(w).Encode(map[string]any{ + "world": "", + "discord_muted": false, + "ocr": "", + "translate": "", + "guests": []any{}, + "guest_count": 0, + "error": err.Error(), + }) + return + } + var state map[string]any + if err := json.Unmarshal(body, &state); err != nil { + _ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error()}) + return + } + guests := loadGuestSnapshot() + state["guests"] = guests + state["guest_count"] = len(guests) + _ = json.NewEncoder(w).Encode(state) +} + +func loadGuestSnapshot() []map[string]any { + statePath := filepath.Join(runtimeDir(), "guest_snapshot.json") + body, err := os.ReadFile(statePath) + if err != nil { + return []map[string]any{} + } + var guests []map[string]any + if err := json.Unmarshal(body, &guests); err != nil { + return []map[string]any{} + } + return guests +} + +func openBrowser(url string) { + switch runtime.GOOS { + case "windows": + _ = exec.Command("cmd", "/c", "start", "", url).Start() + default: + _ = exec.Command("xdg-open", url).Start() + } +} diff --git a/VRWT_Tool/VRC_OSC/cmd/vrc_osc_launcher/main.go b/VRWT_Tool/VRC_OSC/cmd/vrc_osc_launcher/main.go new file mode 100644 index 0000000..87a681f --- /dev/null +++ b/VRWT_Tool/VRC_OSC/cmd/vrc_osc_launcher/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "flag" + "log" + "os" + "path/filepath" + + "vrc_osc_go/internal/buildinfo" + "vrc_osc_go/internal/common" + "vrc_osc_go/internal/update" +) + +func main() { + var baseURL string + var ownerRepo string + var assetName string + flag.StringVar(&baseURL, "base-url", "https://git.vrcworldtour.com", "Gitea base URL") + flag.StringVar(&ownerRepo, "repo", "every_holiday/VRCWT-OSC", "owner/repo") + flag.StringVar(&assetName, "asset", "vrc_osc-windows.zip", "release asset name") + flag.Parse() + + client := &update.Client{BaseURL: baseURL} + mgr := &update.Manager{ + Client: client, + OwnerRepo: ownerRepo, + AssetName: assetName, + CurrentLabel: buildinfo.Version, + } + if _, err := mgr.CheckAndUpdate(); err != nil { + log.Printf("auto update skipped: %v", err) + } + exe := filepath.Join(common.RootDir(), "vrc_osc.exe") + if _, err := os.Stat(exe); err != nil { + log.Fatalf("missing exe: %v", err) + } + if err := update.Launch(exe, os.Args[1:]); err != nil { + log.Fatalf("launch failed: %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/config/config.example.toml b/VRWT_Tool/VRC_OSC/config/config.example.toml new file mode 100644 index 0000000..e154d9e --- /dev/null +++ b/VRWT_Tool/VRC_OSC/config/config.example.toml @@ -0,0 +1,22 @@ +[self] +name = "" + +[staff] +names = [] + +[guest] +file = "config/guests.txt" + +[notice] +missing_count = 0 + +[ocr.crop] +left = 0.05 +top = 0.35 +right = 0.95 +bottom = 0.95 + +[ocr] +preprocess = true +scale = 1.5 +use_angle_cls = false diff --git a/VRWT_Tool/VRC_OSC/config/guests.example.txt b/VRWT_Tool/VRC_OSC/config/guests.example.txt new file mode 100644 index 0000000..3124eb2 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/config/guests.example.txt @@ -0,0 +1,3 @@ +GuestUserA +GuestUserB +GuestUserC diff --git a/VRWT_Tool/VRC_OSC/config/secrets.example.toml b/VRWT_Tool/VRC_OSC/config/secrets.example.toml new file mode 100644 index 0000000..8cc68c2 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/config/secrets.example.toml @@ -0,0 +1,6 @@ +[discord] +webhook_url = "" + +[vrchat] +username = "" +password = "" diff --git a/VRWT_Tool/VRC_OSC/go.mod b/VRWT_Tool/VRC_OSC/go.mod new file mode 100644 index 0000000..ea7c930 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/go.mod @@ -0,0 +1,4 @@ +module vrc_osc_go + +go 1.22 + 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..5960fb1 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/internal/app/app.go @@ -0,0 +1,75 @@ +package app + +import ( + "log" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "vrc_osc_go/internal/config" + "vrc_osc_go/internal/consenttool" + "vrc_osc_go/internal/osc" +) + +type discordMuteState struct { + mu sync.Mutex + muted bool + buttonPressed bool + lastAction time.Time +} + +func Run(configPath string, mode string) error { + if mode != "" { + return consenttool.Run(configPath, mode) + } + + cfg, err := config.Load(configPath) + if err != nil { + return err + } + InitGuestTracker(NewGuestTracker(cfg.VrcLog.GuestNames)) + log.Printf("vrc_osc_go starting osc=%s:%d config=%s", cfg.OSC.Host, cfg.OSC.Port, configPath) + + server := osc.NewServer(cfg.OSC.Host, cfg.OSC.Port) + discordState := &discordMuteState{muted: true} + SetDiscordMuted(true) + server.Map("DiscordSend", func(_ string, args []osc.Value) error { + log.Printf("received DiscordSend args=%+v", args) + if err := handleDiscordSend(discordState, args); err != nil { + log.Printf("discord mute failed: %v", err) + return err + } + SetDiscordMuted(discordState.muted) + return nil + }) + server.Map("ocrEnabled", func(_ string, args []osc.Value) error { + log.Printf("received ocrEnabled args=%+v", args) + if err := runPythonOcrFromScreen(); err != nil { + log.Printf("ocr failed: %v", err) + return err + } + return nil + }) + + errCh := make(chan error, 1) + go func() { + log.Printf("osc server listening on %s:%d", cfg.OSC.Host, cfg.OSC.Port) + errCh <- server.Serve() + }() + go watchVrchatLog() + startSelfMonitor(cfg, discordState) + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + select { + case err := <-errCh: + log.Printf("osc server stopped: %v", err) + return err + case <-sigCh: + log.Printf("shutdown signal received") + server.Close() + return nil + } +} diff --git a/VRWT_Tool/VRC_OSC/internal/app/discord.go b/VRWT_Tool/VRC_OSC/internal/app/discord.go new file mode 100644 index 0000000..39bd677 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/internal/app/discord.go @@ -0,0 +1,84 @@ +package app + +import ( + "fmt" + "log" + "os/exec" +) + +func pressDiscordMuteHotkey() error { + script := ` +$ErrorActionPreference = "Stop" + +function Get-DiscordWindows { + $windows = foreach ($p in Get-Process) { + $title = ($p.MainWindowTitle | Out-String).Trim() + if (-not $title) { continue } + if (-not $title.ToLower().Contains('discord')) { continue } + $p + } + if (-not $windows) { return @() } + return $windows +} + +function Get-BestDiscordWindow { + $windows = Get-DiscordWindows + if (-not $windows -or $windows.Count -eq 0) { return $null } + + $browserKeywords = @('google chrome', 'microsoft edge', 'mozilla firefox', 'brave', 'opera') + $ranked = $windows | Sort-Object { + $title = ($_.MainWindowTitle | Out-String).Trim().ToLower() + $score = 0 + foreach ($keyword in $browserKeywords) { + if ($title.Contains($keyword)) { $score += 10 } + } + if ($title -match '^\(\d+\)\s*discord') { $score += 20 } + if ($title.Contains('discord')) { $score += 5 } + $score + } -Descending + + return $ranked | Select-Object -First 1 +} + +function Get-VRChatWindow { + $windows = Get-Process | Where-Object { + $_.MainWindowTitle -and ( + $_.MainWindowTitle -eq 'VRChat' -or $_.MainWindowTitle.ToLower().StartsWith('vrchat ') + ) + } + if (-not $windows) { return $null } + return $windows | Sort-Object { $_.MainWindowHandle } -Descending | Select-Object -First 1 +} + +function Activate-Window($p) { + if ($null -eq $p) { return } + $wshell = New-Object -ComObject WScript.Shell + try { $null = $wshell.AppActivate($p.Id) } catch {} + Start-Sleep -Milliseconds 200 +} + +$discord = Get-BestDiscordWindow +if ($null -eq $discord) { exit 2 } + +Activate-Window $discord +$wshell = New-Object -ComObject WScript.Shell +Start-Sleep -Milliseconds 200 +$wshell.SendKeys('^+m') +Start-Sleep -Milliseconds 200 + +$vrchat = Get-VRChatWindow +if ($null -ne $vrchat) { + Activate-Window $vrchat +} +` + + cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script) + out, err := cmd.CombinedOutput() + if len(out) > 0 { + log.Printf("discord mute output: %s", string(out)) + } + if err != nil { + return fmt.Errorf("discord mute failed: %w", err) + } + return nil +} diff --git a/VRWT_Tool/VRC_OSC/internal/app/discord_mute.go b/VRWT_Tool/VRC_OSC/internal/app/discord_mute.go new file mode 100644 index 0000000..7165d9d --- /dev/null +++ b/VRWT_Tool/VRC_OSC/internal/app/discord_mute.go @@ -0,0 +1,89 @@ +package app + +import ( + "fmt" + "log" + "strings" + "time" + + "vrc_osc_go/internal/osc" +) + +const discordDebounce = 300 * time.Millisecond + +func handleDiscordSend(state *discordMuteState, args []osc.Value) error { + state.mu.Lock() + defer state.mu.Unlock() + + if len(args) == 0 { + return fmt.Errorf("OSC args is empty") + } + isPressed, err := toBool(args[0]) + if err != nil { + return err + } + + now := time.Now() + if !state.lastAction.IsZero() && now.Sub(state.lastAction) < discordDebounce { + return nil + } + + if isPressed && !state.buttonPressed { + state.buttonPressed = true + if state.muted { + log.Printf("ACTION VRChat button pressed -> Discord unmute hotkey") + if err := pressDiscordMuteHotkey(); err != nil { + return err + } + state.muted = false + SetDiscordMuted(state.muted) + state.lastAction = now + } else { + log.Printf("INFO already unmuted") + } + return nil + } + + if !isPressed && state.buttonPressed { + state.buttonPressed = false + if !state.muted { + log.Printf("ACTION VRChat button released -> Discord mute hotkey") + if err := pressDiscordMuteHotkey(); err != nil { + return err + } + state.muted = true + SetDiscordMuted(state.muted) + state.lastAction = now + } else { + log.Printf("INFO already muted") + } + } + + return nil +} + +func toBool(v osc.Value) (bool, error) { + switch v.Type { + case 'T': + return true, nil + case 'F': + return false, nil + case 'i': + return v.Int != 0, nil + case 'f': + return v.Float != 0, nil + case 's': + switch strings.ToLower(strings.TrimSpace(v.Str)) { + case "true", "1", "on", "yes": + return true, nil + case "false", "0", "off", "no": + return false, nil + default: + return false, fmt.Errorf("unsupported OSC value: %q", v.Str) + } + case 0: + return false, fmt.Errorf("unsupported OSC value") + default: + return false, fmt.Errorf("unsupported OSC type: %q", v.Type) + } +} diff --git a/VRWT_Tool/VRC_OSC/internal/app/guest_status.go b/VRWT_Tool/VRC_OSC/internal/app/guest_status.go new file mode 100644 index 0000000..15139fe --- /dev/null +++ b/VRWT_Tool/VRC_OSC/internal/app/guest_status.go @@ -0,0 +1,136 @@ +package app + +import ( + "fmt" + "encoding/json" + "os" + "path/filepath" + "sort" + "sync" + "time" + + "vrc_osc_go/internal/common" +) + +type GuestStatus struct { + Name string `json:"name"` + Present bool `json:"present"` + LastJoin time.Time `json:"last_join"` + LastLeave time.Time `json:"last_leave"` + AbsentFor string `json:"absent_for"` +} + +type GuestTracker struct { + mu sync.RWMutex + current string + items map[string]*GuestStatus +} + +func NewGuestTracker(names []string) *GuestTracker { + items := make(map[string]*GuestStatus, len(names)) + for _, name := range names { + items[name] = &GuestStatus{Name: name} + } + return &GuestTracker{items: items} +} + +func (t *GuestTracker) SetCurrentInstance(label string) { + t.mu.Lock() + defer t.mu.Unlock() + t.current = label +} + +func (t *GuestTracker) CurrentInstance() string { + t.mu.RLock() + defer t.mu.RUnlock() + return t.current +} + +func (t *GuestTracker) MarkJoin(name string, at time.Time) { + t.mu.Lock() + defer t.mu.Unlock() + s := t.ensure(name) + s.Present = true + s.LastJoin = at + s.AbsentFor = "" + _ = t.persistLocked() +} + +func (t *GuestTracker) MarkLeave(name string, at time.Time) { + t.mu.Lock() + defer t.mu.Unlock() + s := t.ensure(name) + s.Present = false + s.LastLeave = at + if !s.LastJoin.IsZero() { + s.AbsentFor = humanSince(at) + } + _ = t.persistLocked() +} + +func (t *GuestTracker) Snapshot(now time.Time) []GuestStatus { + t.mu.RLock() + defer t.mu.RUnlock() + out := make([]GuestStatus, 0, len(t.items)) + for _, s := range t.items { + cp := *s + if !cp.Present && !cp.LastLeave.IsZero() { + cp.AbsentFor = humanSince(cp.LastLeave, now) + } + out = append(out, cp) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +func (t *GuestTracker) persistLocked() error { + dir := filepath.Join(common.RootDir(), "runtime") + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + snapshot := make([]GuestStatus, 0, len(t.items)) + for _, s := range t.items { + cp := *s + snapshot = append(snapshot, cp) + } + f, err := os.Create(filepath.Join(dir, "guest_snapshot.json")) + if err != nil { + return err + } + defer f.Close() + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + return enc.Encode(snapshot) +} + +func (t *GuestTracker) ensure(name string) *GuestStatus { + if s, ok := t.items[name]; ok { + return s + } + s := &GuestStatus{Name: name} + t.items[name] = s + return s +} + +func humanSince(at time.Time, now ...time.Time) string { + ref := time.Now() + if len(now) > 0 { + ref = now[0] + } + if at.IsZero() { + return "" + } + d := ref.Sub(at) + if d < time.Minute { + return "1分未満" + } + m := int(d.Minutes()) + if m < 60 { + return fmt.Sprintf("%d分前", m) + } + h := m / 60 + if h < 24 { + return fmt.Sprintf("%d時間前", h) + } + return fmt.Sprintf("%d日前", h/24) +} diff --git a/VRWT_Tool/VRC_OSC/internal/app/ocr.go b/VRWT_Tool/VRC_OSC/internal/app/ocr.go new file mode 100644 index 0000000..6f6e2ca --- /dev/null +++ b/VRWT_Tool/VRC_OSC/internal/app/ocr.go @@ -0,0 +1,30 @@ +package app + +import ( + "fmt" + "log" + "os/exec" +) + +func runPythonOcrFromScreen() error { + script := ` +import sys +from pathlib import Path +root = Path(r"C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC") +sys.path.insert(0, str(root / "src")) +from ocr.ocr_actions import runOcrFromScreen +runOcrFromScreen() +` + cmd := exec.Command("python", "-c", script) + cmd.Dir = `C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC` + out, err := cmd.CombinedOutput() + if len(out) > 0 { + log.Printf("ocr output: %s", string(out)) + SetOCRText(string(out)) + SetTranslateText("翻訳: 未実装") + } + if err != nil { + return fmt.Errorf("ocr failed: %w", err) + } + return nil +} diff --git a/VRWT_Tool/VRC_OSC/internal/app/runtime.go b/VRWT_Tool/VRC_OSC/internal/app/runtime.go new file mode 100644 index 0000000..cc4df2b --- /dev/null +++ b/VRWT_Tool/VRC_OSC/internal/app/runtime.go @@ -0,0 +1,48 @@ +package app + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "vrc_osc_go/internal/common" +) + +func appendRuntimeLog(title, text string) error { + dir := filepath.Join(common.RootDir(), "runtime") + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + path := filepath.Join(dir, "runtime.log") + body := text + if body == "" { + body = "(empty)" + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + _, err = fmt.Fprintf(f, "\n[%s] %s\n%s\n", time.Now().Format("2006-01-02 15:04:05"), title, body) + return err +} + +func appendJoinLeaveLog(text string) error { + dir := filepath.Join(common.RootDir(), "runtime") + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + path := filepath.Join(dir, "join_leave.log") + body := text + if body == "" { + body = "(empty)" + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + _, err = fmt.Fprintf(f, "\n[%s] VRC JOIN/LEAVE\n%s\n", time.Now().Format("2006-01-02 15:04:05"), body) + return err +} diff --git a/VRWT_Tool/VRC_OSC/internal/app/self_monitor.go b/VRWT_Tool/VRC_OSC/internal/app/self_monitor.go new file mode 100644 index 0000000..6ac10a6 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/internal/app/self_monitor.go @@ -0,0 +1,69 @@ +package app + +import ( + "log" + "os" + "regexp" + "strings" + "time" + + "vrc_osc_go/internal/config" + "vrc_osc_go/internal/osc" +) + +var ( + selfJoinPattern = regexp.MustCompile(`OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`) + selfLeftPattern = regexp.MustCompile(`OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`) +) + +func startSelfMonitor(cfg *config.Config, state *discordMuteState) { + if cfg.VrcLog.SelfName == "" { + return + } + go func() { + lastState := "" + for { + path, err := findLatestVrchatLog() + if err != nil { + time.Sleep(2 * time.Second) + continue + } + text, err := os.ReadFile(path) + if err != nil { + time.Sleep(2 * time.Second) + continue + } + nextState := extractLatestSelfLogState(string(text), cfg.VrcLog.SelfName) + if nextState != "" && nextState != lastState { + if nextState == "joined" { + if err := handleDiscordSend(state, []osc.Value{{Type: 'T', Bool: true}}); err != nil { + log.Printf("self monitor discord mute failed: %v", err) + } + } else if nextState == "left" { + if err := handleDiscordSend(state, []osc.Value{{Type: 'F', Bool: false}}); err != nil { + log.Printf("self monitor discord mute failed: %v", err) + } + } + lastState = nextState + } + time.Sleep(2 * time.Second) + } + }() +} + +func extractLatestSelfLogState(text, selfName string) string { + if selfName == "" { + return "" + } + state := "" + for _, rawLine := range strings.Split(text, "\n") { + if m := selfJoinPattern.FindStringSubmatch(rawLine); len(m) == 2 && strings.TrimSpace(m[1]) == selfName { + state = "joined" + continue + } + if m := selfLeftPattern.FindStringSubmatch(rawLine); len(m) == 2 && strings.TrimSpace(m[1]) == selfName { + state = "left" + } + } + return state +} diff --git a/VRWT_Tool/VRC_OSC/internal/app/state.go b/VRWT_Tool/VRC_OSC/internal/app/state.go new file mode 100644 index 0000000..4240c98 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/internal/app/state.go @@ -0,0 +1,89 @@ +package app + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + "time" + + "vrc_osc_go/internal/common" +) + +type RuntimeState struct { + mu sync.RWMutex + DiscordMuted bool + LastOCRText string + LastTranslate string + CurrentWorld string +} + +var runtimeState = &RuntimeState{} +var guestTracker *GuestTracker + +func SetGuestTracker(t *GuestTracker) { guestTracker = t } +func InitGuestTracker(t *GuestTracker) { + guestTracker = t + if guestTracker != nil { + guestTracker.mu.Lock() + _ = guestTracker.persistLocked() + guestTracker.mu.Unlock() + } +} +func GetGuestTracker() *GuestTracker { return guestTracker } + +func SetDiscordMuted(v bool) { + runtimeState.mu.Lock() + runtimeState.DiscordMuted = v + runtimeState.mu.Unlock() + _ = persistRuntimeState() +} + +func SetOCRText(v string) { + runtimeState.mu.Lock() + runtimeState.LastOCRText = v + runtimeState.mu.Unlock() + _ = persistRuntimeState() +} + +func SetTranslateText(v string) { + runtimeState.mu.Lock() + runtimeState.LastTranslate = v + runtimeState.mu.Unlock() + _ = persistRuntimeState() +} + +func SetCurrentWorld(v string) { + runtimeState.mu.Lock() + runtimeState.CurrentWorld = v + runtimeState.mu.Unlock() + _ = persistRuntimeState() +} + +func SnapshotRuntime() RuntimeState { + runtimeState.mu.RLock() + defer runtimeState.mu.RUnlock() + return *runtimeState +} + +func persistRuntimeState() error { + s := SnapshotRuntime() + dir := filepath.Join(common.RootDir(), "runtime") + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + f, err := os.Create(filepath.Join(dir, "state.json")) + if err != nil { + return err + } + defer f.Close() + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + return enc.Encode(map[string]any{ + "discord_muted": s.DiscordMuted, + "ocr": s.LastOCRText, + "translate": s.LastTranslate, + "world": s.CurrentWorld, + "updated_at": time.Now().Format(time.RFC3339), + }) +} diff --git a/VRWT_Tool/VRC_OSC/internal/app/vrc_log.go b/VRWT_Tool/VRC_OSC/internal/app/vrc_log.go new file mode 100644 index 0000000..9a1fdf8 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/internal/app/vrc_log.go @@ -0,0 +1,345 @@ +package app + +import ( + "bufio" + "fmt" + "log" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +var ( + joinPattern = regexp.MustCompile(`OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`) + leftPattern = regexp.MustCompile(`OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`) + worldIdPattern = regexp.MustCompile(`worldId=(wrld_[0-9a-fA-F-]+)`) + instanceIdPattern = regexp.MustCompile(`instanceId=([^,}\s]+)`) + worldNamePattern = regexp.MustCompile(`worldName=([^,}]+)`) + worldLocationPattern = regexp.MustCompile(`worldId=(wrld_[0-9a-fA-F-]+):([^\s,\]\)\"']+)`) + worldPattern = regexp.MustCompile(`(wrld_[0-9a-fA-F-]+(?::[^\s\]\)\"']+)?)`) + enteringRoomPattern = regexp.MustCompile(`\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$`) +) + +type vrcLogState struct { + location string + worldID string + instanceID string + worldName string + pendingWorldName string + presentSet map[string]struct{} + initialized bool +} + +func getVrchatLogDir() (string, error) { + userProfile := os.Getenv("USERPROFILE") + if userProfile == "" { + return "", os.ErrNotExist + } + return filepath.Join(userProfile, "AppData", "LocalLow", "VRChat", "VRChat"), nil +} + +func findLatestVrchatLog() (string, error) { + dir, err := getVrchatLogDir() + if err != nil { + return "", err + } + + entries, err := os.ReadDir(dir) + if err != nil { + return "", err + } + + var latest string + var latestMod time.Time + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasSuffix(strings.ToLower(name), ".log") && !strings.HasSuffix(strings.ToLower(name), ".txt") { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + if info.ModTime().After(latestMod) { + latestMod = info.ModTime() + latest = filepath.Join(dir, name) + } + } + if latest == "" { + return "", os.ErrNotExist + } + return latest, nil +} + +func watchVrchatLog() { + lastPath := "" + lastSize := int64(0) + state := &vrcLogState{ + presentSet: map[string]struct{}{}, + } + for { + path, err := findLatestVrchatLog() + if err != nil { + time.Sleep(2 * time.Second) + continue + } + info, err := os.Stat(path) + if err != nil { + time.Sleep(2 * time.Second) + continue + } + if path != lastPath { + log.Printf("vrchat log watching %s", path) + lastPath = path + lastSize = 0 + if err := scanExistingVrchatLog(path, state); err != nil { + log.Printf("vrchat log initial scan failed: %v", err) + } + if info2, err := os.Stat(path); err == nil { + lastSize = info2.Size() + } else { + lastSize = info.Size() + } + time.Sleep(2 * time.Second) + continue + } + + if info.Size() < lastSize { + lastSize = 0 + } + if info.Size() > lastSize { + f, err := os.Open(path) + if err != nil { + time.Sleep(2 * time.Second) + continue + } + _, _ = f.Seek(lastSize, 0) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if err := appendRuntimeLog("VRC LOG", line); err != nil { + log.Printf("append runtime log failed: %v", err) + } + if changed, worldLabel := updateWorldState(state, line); changed { + log.Printf("world changed: %s", worldLabel) + state.presentSet = map[string]struct{}{} + SetCurrentWorld(worldLabel) + if tracker := GetGuestTracker(); tracker != nil { + tracker.SetCurrentInstance(worldLabel) + } + _ = appendRuntimeLog("VRC WORLD", worldLabel) + } + at := extractLineTime(line) + if m := joinPattern.FindStringSubmatch(line); len(m) == 2 { + name := strings.TrimSpace(m[1]) + state.presentSet[name] = struct{}{} + if tracker := GetGuestTracker(); tracker != nil { + tracker.MarkJoin(name, at) + } + out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet)) + log.Print(out) + _ = appendJoinLeaveLog(out) + continue + } + if m := leftPattern.FindStringSubmatch(line); len(m) == 2 { + name := strings.TrimSpace(m[1]) + delete(state.presentSet, name) + if tracker := GetGuestTracker(); tracker != nil { + tracker.MarkLeave(name, at) + } + out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet)) + log.Print(out) + _ = appendJoinLeaveLog(out) + continue + } + } + lastSize = info.Size() + _ = f.Close() + } + time.Sleep(2 * time.Second) + } +} + +func scanExistingVrchatLog(path string, state *vrcLogState) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + state.presentSet = map[string]struct{}{} + state.location = "" + state.worldID = "" + state.instanceID = "" + state.worldName = "" + state.pendingWorldName = "" + state.initialized = false + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if changed, _ := updateWorldState(state, line); changed { + state.presentSet = map[string]struct{}{} + } + if m := joinPattern.FindStringSubmatch(line); len(m) == 2 { + name := strings.TrimSpace(m[1]) + state.presentSet[name] = struct{}{} + if tracker := GetGuestTracker(); tracker != nil { + tracker.MarkJoin(name, extractLineTime(line)) + } + continue + } + if m := leftPattern.FindStringSubmatch(line); len(m) == 2 { + name := strings.TrimSpace(m[1]) + delete(state.presentSet, name) + if tracker := GetGuestTracker(); tracker != nil { + tracker.MarkLeave(name, extractLineTime(line)) + } + continue + } + } + if err := scanner.Err(); err != nil { + return err + } + state.initialized = true + if state.worldName != "" { + log.Printf("world changed: %s", state.worldName) + SetCurrentWorld(state.worldName) + if tracker := GetGuestTracker(); tracker != nil { + tracker.SetCurrentInstance(state.worldName) + } + _ = appendRuntimeLog("VRC WORLD", state.worldName) + } + return nil +} + +func updateWorldState(state *vrcLogState, line string) (bool, string) { + worldID := "" + instanceID := "" + worldName := "" + roomTitle := "" + + if m := enteringRoomPattern.FindStringSubmatch(line); len(m) == 2 { + roomTitle = strings.TrimSpace(m[1]) + state.pendingWorldName = roomTitle + } + if m := worldLocationPattern.FindStringSubmatch(line); len(m) == 3 { + worldID = strings.TrimSpace(m[1]) + instanceID = strings.TrimSpace(m[2]) + } + + if m := worldIdPattern.FindStringSubmatch(line); len(m) == 2 { + worldID = strings.TrimSpace(m[1]) + } + if m := instanceIdPattern.FindStringSubmatch(line); len(m) == 2 { + instanceID = strings.TrimSpace(m[1]) + } + if m := worldNamePattern.FindStringSubmatch(line); len(m) == 2 { + worldName = strings.TrimSpace(m[1]) + } + + if m := enteringRoomPattern.FindStringSubmatch(line); len(m) == 2 && worldID == "" { + if worldMatch := worldPattern.FindStringSubmatch(m[1]); len(worldMatch) == 2 { + worldID = worldMatch[1] + } + } + + nextLocation := "" + if worldID != "" { + nextLocation = worldID + if instanceID != "" { + nextLocation = worldID + ":" + instanceID + } + } + + if nextLocation != "" && nextLocation != state.location { + state.location = nextLocation + state.worldID = worldID + state.instanceID = instanceID + if worldName != "" { + state.worldName = worldName + } else if shouldUseRoomTitlePreferLatest(roomTitle, state.worldName) { + state.worldName = roomTitle + } + label := state.worldName + if label == "" { + label = state.worldID + } + if label == "" { + label = nextLocation + } + if state.initialized { + log.Printf("world changed: %s", label) + _ = appendRuntimeLog("VRC WORLD", label) + } + return true, label + } + + if worldName != "" { + state.worldName = worldName + state.pendingWorldName = "" + return false, "" + } + if shouldUseRoomTitlePreferLatest(roomTitle, state.worldName) { + state.worldName = roomTitle + } + + return false, "" +} + +func shouldUseRoomTitle(roomTitle, currentWorldName string) bool { + if roomTitle == "" { + return false + } + if strings.Contains(roomTitle, "Home Location") { + return false + } + if roomTitle == "Holiday-Cottage" && currentWorldName != "" && currentWorldName != roomTitle { + return false + } + if currentWorldName == "" { + return true + } + if roomTitle == currentWorldName { + return false + } + if len(roomTitle) > len(currentWorldName) { + return true + } + if strings.Contains(roomTitle, "「") || strings.Contains(roomTitle, "」") || strings.Contains(roomTitle, "[") { + return true + } + return false +} + +func shouldUseRoomTitlePreferLatest(roomTitle, currentWorldName string) bool { + if roomTitle == "" { + return false + } + if strings.Contains(roomTitle, "Home Location") { + return false + } + if currentWorldName == "" { + return true + } + if roomTitle == currentWorldName { + return false + } + return true +} + +func extractLineTime(line string) time.Time { + if len(line) < 19 { + return time.Now() + } + t, err := time.ParseInLocation("2006.01.02 15:04:05", line[:19], time.Local) + if err == nil { + return t + } + return time.Now() +} diff --git a/VRWT_Tool/VRC_OSC/internal/buildinfo/buildinfo.go b/VRWT_Tool/VRC_OSC/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..f63e477 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/internal/buildinfo/buildinfo.go @@ -0,0 +1,6 @@ +package buildinfo + +var ( + Version = "dev" + BuildTime = "unknown" +) diff --git a/VRWT_Tool/VRC_OSC/internal/common/project_paths.go b/VRWT_Tool/VRC_OSC/internal/common/project_paths.go new file mode 100644 index 0000000..76b587c --- /dev/null +++ b/VRWT_Tool/VRC_OSC/internal/common/project_paths.go @@ -0,0 +1,16 @@ +package common + +import ( + "os" + "path/filepath" +) + +func RuntimeBaseDir() string { + exe, err := os.Executable() + if err == nil { + return filepath.Dir(exe) + } + return "." +} + +func RootDir() string { return RuntimeBaseDir() } diff --git a/VRWT_Tool/VRC_OSC/internal/config/config.go b/VRWT_Tool/VRC_OSC/internal/config/config.go new file mode 100644 index 0000000..e584686 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/internal/config/config.go @@ -0,0 +1,146 @@ +package config + +import ( + "bufio" + "os" + "path/filepath" + "strconv" + "strings" + + "vrc_osc_go/internal/common" +) + +type Config struct { + OSC OSCConfig + VrcLog VrcLogConfig +} + +type OSCConfig struct { + Host string + Port int +} + +type VrcLogConfig struct { + SelfName string + GuestNames []string + GuestFile string + StaffNames []string + MissingCount int + LogPatterns []string +} + +func Load(path string) (*Config, error) { + cfg := &Config{ + OSC: OSCConfig{Host: "127.0.0.1", Port: 9001}, + } + if path == "" { + path = filepath.Join(common.RootDir(), "config", "config.toml") + } else if !filepath.IsAbs(path) { + path = filepath.Join(common.RootDir(), path) + } + file, err := os.Open(path) + if err != nil { + return cfg, nil + } + 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 "osc": + switch key { + case "host": + cfg.OSC.Host = val + case "port": + if n, err := strconv.Atoi(val); err == nil { + cfg.OSC.Port = n + } + } + case "self": + if key == "name" { + cfg.VrcLog.SelfName = val + } + case "notice": + if key == "missing_count" { + if n, err := strconv.Atoi(val); err == nil { + cfg.VrcLog.MissingCount = n + } + } + case "vrc_log": + if key == "patterns" { + cfg.VrcLog.LogPatterns = parseList(val) + } + case "staff": + if key == "names" { + cfg.VrcLog.StaffNames = parseList(val) + } + case "guest": + switch key { + case "names": + cfg.VrcLog.GuestNames = parseList(val) + case "file": + cfg.VrcLog.GuestFile = val + } + } + } + if cfg.VrcLog.GuestFile != "" { + if !filepath.IsAbs(cfg.VrcLog.GuestFile) { + cfg.VrcLog.GuestFile = filepath.Join(common.RootDir(), cfg.VrcLog.GuestFile) + } + if names, err := loadLines(cfg.VrcLog.GuestFile); err == nil { + cfg.VrcLog.GuestNames = names + } + } + 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 + } + parts := strings.Split(value, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(strings.Trim(part, "\"'")) + if part != "" { + out = append(out, part) + } + } + return out +} + +func loadLines(path string) ([]string, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + lines := strings.Split(string(data), "\n") + out := make([]string, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(strings.TrimPrefix(line, "\ufeff")) + if line != "" && !strings.HasPrefix(line, "#") { + out = append(out, line) + } + } + return out, 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)) + } +} diff --git a/VRWT_Tool/VRC_OSC/internal/osc/server.go b/VRWT_Tool/VRC_OSC/internal/osc/server.go new file mode 100644 index 0000000..2488509 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/internal/osc/server.go @@ -0,0 +1,141 @@ +package osc + +import ( + "bytes" + "encoding/binary" + "fmt" + "math" + "net" + "strings" + "sync" +) + +type Value struct { + Type byte + Float float32 + Int int32 + Bool bool + Str string +} + +type Handler func(address string, args []Value) error + +type Server struct { + addr string + conn *net.UDPConn + handlers map[string]Handler + mu sync.RWMutex +} + +func NewServer(host string, port int) *Server { + return &Server{ + addr: fmt.Sprintf("%s:%d", host, port), + handlers: map[string]Handler{}, + } +} + +func (s *Server) Map(param string, h Handler) { s.handlers["/avatar/parameters/"+param] = h } +func (s *Server) Close() error { + if s.conn != nil { + return s.conn.Close() + } + return nil +} + +func (s *Server) Serve() error { + addr, err := net.ResolveUDPAddr("udp", s.addr) + if err != nil { + return err + } + conn, err := net.ListenUDP("udp", addr) + if err != nil { + return err + } + s.conn = conn + buf := make([]byte, 2048) + for { + n, _, err := conn.ReadFromUDP(buf) + if err != nil { + return err + } + a, v, err := parseMessage(buf[:n]) + if err != nil { + continue + } + s.mu.RLock() + h := s.handlers[a] + s.mu.RUnlock() + if h != nil { + _ = h(a, v) + } + } +} + +func parseMessage(b []byte) (string, []Value, error) { + address, offset, err := parsePaddedString(b, 0) + if err != nil { + return "", nil, err + } + if !strings.HasPrefix(address, "/") { + return "", nil, fmt.Errorf("invalid address") + } + if offset >= len(b) { + return address, nil, nil + } + typetags, offset, err := parsePaddedString(b, offset) + if err != nil { + return "", nil, err + } + if !strings.HasPrefix(typetags, ",") { + return address, nil, nil + } + var values []Value + for _, tag := range typetags[1:] { + switch tag { + case 'i': + if offset+4 > len(b) { + return "", nil, fmt.Errorf("invalid int") + } + values = append(values, Value{Type: 'i', Int: int32(binary.BigEndian.Uint32(b[offset : offset+4]))}) + offset += 4 + case 'f': + if offset+4 > len(b) { + return "", nil, fmt.Errorf("invalid float") + } + bits := binary.BigEndian.Uint32(b[offset : offset+4]) + values = append(values, Value{Type: 'f', Float: math.Float32frombits(bits)}) + offset += 4 + case 's': + s, next, err := parsePaddedString(b, offset) + if err != nil { + return "", nil, err + } + values = append(values, Value{Type: 's', Str: s}) + offset = next + case 'T': + values = append(values, Value{Type: 'T', Bool: true}) + case 'F': + values = append(values, Value{Type: 'F', Bool: false}) + } + } + return address, values, nil +} + +func parsePaddedString(b []byte, offset int) (string, int, error) { + if offset >= len(b) { + return "", offset, fmt.Errorf("invalid osc string") + } + end := bytes.IndexByte(b[offset:], 0) + if end < 0 { + return "", offset, fmt.Errorf("invalid osc string") + } + s := string(b[offset : offset+end]) + next := offset + end + 1 + for next%4 != 0 { + next++ + } + if next > len(b) { + next = len(b) + } + return s, next, nil +} diff --git a/VRWT_Tool/VRC_OSC/internal/update/manager.go b/VRWT_Tool/VRC_OSC/internal/update/manager.go new file mode 100644 index 0000000..e377fab --- /dev/null +++ b/VRWT_Tool/VRC_OSC/internal/update/manager.go @@ -0,0 +1,106 @@ +package update + +import ( + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + + "vrc_osc_go/internal/buildinfo" + "vrc_osc_go/internal/common" +) + +type Manager struct { + Client *Client + OwnerRepo string + AssetName string + CurrentLabel string +} + +func (m *Manager) CheckAndUpdate() (bool, error) { + if m.Client == nil || m.OwnerRepo == "" || m.AssetName == "" { + return false, nil + } + latest, err := m.Client.LatestRelease(m.OwnerRepo) + if err != nil { + return false, err + } + if latest.TagName == "" || latest.TagName == m.CurrentLabel { + return false, nil + } + assetURL := "" + for _, a := range latest.Assets { + if a.Name == m.AssetName { + assetURL = a.URL + break + } + } + if assetURL == "" { + return false, fmt.Errorf("asset %q not found in release %s", m.AssetName, latest.TagName) + } + base := common.RootDir() + tmpDir := filepath.Join(base, "update", latest.TagName) + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return false, err + } + zipPath := filepath.Join(tmpDir, m.AssetName) + log.Printf("update available: %s -> %s", m.CurrentLabel, latest.TagName) + if err := m.Client.DownloadAsset(assetURL, zipPath); err != nil { + return false, err + } + if err := ExtractZip(zipPath, tmpDir); err != nil { + return false, err + } + exePath := filepath.Join(base, CurrentExeName()) + newExe := filepath.Join(tmpDir, CurrentExeName()) + if _, err := os.Stat(newExe); err != nil { + return false, err + } + backup := exePath + ".bak" + _ = os.Remove(backup) + if err := os.Rename(exePath, backup); err != nil { + return false, err + } + if err := CopyFile(newExe, exePath); err != nil { + _ = os.Rename(backup, exePath) + return false, err + } + _ = os.Remove(backup) + return true, nil +} + +func CopyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} + +func Launch(exe string, args []string) error { + cmd := exec.Command(exe, args...) + cmd.Dir = filepath.Dir(exe) + return cmd.Start() +} + +func Version() string { return buildinfo.Version } + +func CurrentExeName() string { + if runtime.GOOS == "windows" { + return "vrc_osc.exe" + } + return "vrc_osc" +} diff --git a/VRWT_Tool/VRC_OSC/internal/update/release.go b/VRWT_Tool/VRC_OSC/internal/update/release.go new file mode 100644 index 0000000..8fff9f1 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/internal/update/release.go @@ -0,0 +1,125 @@ +package update + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +type ReleaseInfo struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` + } `json:"assets"` +} + +type Client struct { + BaseURL string + HTTP *http.Client +} + +func (c *Client) LatestRelease(ownerRepo string) (*ReleaseInfo, error) { + req, err := http.NewRequest(http.MethodGet, c.apiURL(ownerRepo, "/releases/latest"), nil) + if err != nil { + return nil, err + } + var info ReleaseInfo + if err := c.doJSON(req, &info); err != nil { + return nil, err + } + return &info, nil +} + +func (c *Client) DownloadAsset(url, dst string) error { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := c.httpClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download failed: %s", resp.Status) + } + f, err := os.Create(dst) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(f, resp.Body) + return err +} + +func ExtractZip(zipPath, dstDir string) error { + zr, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer zr.Close() + root := filepath.Clean(dstDir) + string(os.PathSeparator) + for _, f := range zr.File { + target := filepath.Clean(filepath.Join(dstDir, f.Name)) + if target != filepath.Clean(dstDir) && !strings.HasPrefix(target, root) { + return fmt.Errorf("zip path escapes destination: %s", f.Name) + } + if f.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return err + } + out, err := os.Create(target) + if err != nil { + rc.Close() + return err + } + _, copyErr := io.Copy(out, rc) + out.Close() + rc.Close() + if copyErr != nil { + return copyErr + } + } + return nil +} + +func (c *Client) apiURL(ownerRepo, suffix string) string { + base := strings.TrimRight(c.BaseURL, "/") + return base + "/api/v1/repos/" + strings.Trim(ownerRepo, "/") + suffix +} + +func (c *Client) doJSON(req *http.Request, out any) error { + resp, err := c.httpClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return fmt.Errorf("request failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + return json.NewDecoder(resp.Body).Decode(out) +} + +func (c *Client) httpClient() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + return &http.Client{Timeout: 30 * time.Second} +} diff --git a/VRWT_Tool/VRC_OSC/readme.txt b/VRWT_Tool/VRC_OSC/readme.txt new file mode 100644 index 0000000..ad91257 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/readme.txt @@ -0,0 +1,238 @@ +# VRC_OSC + +VRChat の OSC パラメータを受けて、Discord ミュート、VRChat ログ監視、OCR、翻訳を実行するローカルツール。 + +## 起動 + +依存関係を入れる。 + +```powershell +python -m pip install -r requirements.txt +``` + +PowerShell から起動する。 + +```powershell +bin\vrc_osc.ps1 +``` + +Python から直接起動する場合。 + +```powershell +python src\bin\vrc_osc.py +``` + +OSC は `127.0.0.1:9001` で待ち受ける。 + +## 構成 + +```text +bin/ PowerShell 起動スクリプト +config/ example とローカル設定置き場 +src/app.py アプリ本体のエントリ +src/bin/ Python 起動スクリプト +src/common/ パス、設定ロード、runtime ログなどの共通基盤 +src/discord_control/ Discord ウィンドウ操作 +src/ocr/ OCR 実行 +src/osc/ OSC gateway +src/screen/ VRChat ウィンドウ検出とキャプチャ +src/tools/ 単体実行用の補助ツール +src/translate/ 翻訳 +src/vrc_log/ VRChat ログ解析と名簿照合 +``` + +## 設定ファイル + +実設定は `config/config.toml` に置く。このファイルは Git に入れない。 + +雛形は `config/config.example.toml`。 + +```toml +[self] +name = "自分のVRChat表示名" + +[staff] +names = [ + "StaffUserA" +] + +[guest] +file = "config/guests.txt" + +[notice] +missing_count = 3 + +[ocr.crop] +left = 0.05 +top = 0.35 +right = 0.95 +bottom = 0.95 + +[ocr] +preprocess = true +scale = 1.5 +use_angle_cls = false +``` + +`self.name` は Discord 自動ミュート判定に使う。 + +`staff.names` は表示対象だが guest 人数には含めない。 + +`guest.file` は guest 名簿ファイル。guest 人数の母数と在室判定に使う。 + +`notice.missing_count` は「あと何人以内で全員集合か」の通知範囲。例えば guest が 23 人で `missing_count = 3` の場合、20〜22 人のときに未入室 guest を出す。 + +OCR 設定は速度と精度の調整用。 + +`ocr.crop` は VRChat ウィンドウ内の相対座標。`0.0` から `1.0` で指定する。デフォルトは画面下側の中央寄りだけを読む。 + +フル画面 OCR に戻す場合。 + +```toml +[ocr.crop] +left = 0 +top = 0 +right = 1 +bottom = 1 +``` + +`ocr.preprocess = true` はグレースケール化、コントラスト補正、シャープ化、拡大を行う。 + +`ocr.scale` は前処理時の拡大率。大きいほど小さい文字に強くなるが遅くなる。 + +`ocr.use_angle_cls = false` は角度分類を切って高速化する。縦書きや回転文字を読む必要がある場合だけ `true` にする。 + +## guest 名簿 + +`config/guests.txt` に 1 行 1 名で書く。このファイルは Git に入れない。 + +雛形は `config/guests.example.txt`。 + +```text +GuestUserA +GuestUserB +GuestUserC +``` + +空行と `#` で始まる行は無視する。 + +## 秘密情報 + +秘密情報は `config/secrets.toml` に置く。このファイルは Git に入れない。 + +雛形は `config/secrets.example.toml`。 + +```toml +[discord] +webhook_url = "" + +[vrchat] +username = "" +password = "" +``` + +`discord.webhook_url` は VRC ログ結果の Discord Webhook 通知に使う。 + +`vrchat.username` / `vrchat.password` は `src/tools/resolve_vrchat_user.py` を使う場合だけ必要。 + +## OSC パラメータ + +Avatar Parameters 側の名前と一致させる。 + +| Parameter | 動作 | +| --- | --- | +| `DiscordSend` | 押した瞬間に Discord の mute hotkey を送る | +| `vrc_log` | VRC ログ監視の on/off | +| `ocrEnabled` | VRChat 画面を撮って OCR | +| `translation` | OCR して日本語翻訳 | + +## VRC ログ監視 + +`vrc_log` を押すと、現在の VRChat ログを一度解析して Discord Webhook に送る。その後はログファイル末尾を追い、新しい join / leave / world 系ログが追記されたタイミングで即再解析する。 + +以前のような `output_log = 10` の秒数ポーリング設定は使わない。 + +解析対象は昼 12:00 以降。実行時刻が昼 12:00 前なら前日 12:00 以降を見る。 + +インスタンスが変わったら在室状態はリセットする。 + +出力例。 + +```text +ワールド入室: Midnight Rooftop +現在Guest: 0/3 +[01:20] [join] GuestUserA 1/3 +[01:21] [join] GuestUserB 2/3 +[01:25] [leave] GuestUserA 1/3 +[01:26] [join] [staff] StaffUserA 1/3 +``` + +notice 条件に入った場合。 + +```text +現在Guest: 2/3 +未入室Guest: +- GuestUserC +``` + +## Discord 自動ミュート + +常時 self monitor が VRChat ログの新規追記だけを見る。 + +新しく追記されたログの中で、自分の最後のイベントが `OnPlayerJoined` なら Discord を mute、`OnPlayerLeft` なら unmute する。 + +過去ログ全体の最後が self leave / join だっただけでは反応しない。 + +`setDiscordMuted()` にはロックがあり、同時実行で mute hotkey が連続送信されないようにしている。 + +## OCR / 翻訳 + +`ocrEnabled` は VRChat ウィンドウを撮影して PaddleOCR にかける。結果はクリップボードへコピーする。 + +`translation` は OCR 結果を翻訳し、翻訳結果をクリップボードへコピーする。 + +OCR はデフォルトで画面全体ではなく、画面下側の中央寄りを切り出して読む。会話ログや字幕を読む用途では速く、余計な UI 文字を拾いにくい。 + +## runtime + +新規の実行ログは基本的に以下へまとめる。 + +```text +runtime/runtime.log +runtime/latest_ocr.png +runtime/latest_screenshot.png +``` + +`runtime/` は Git に入れない。 + +OCR text、翻訳 text、VRC ログ結果は `runtime/runtime.log` に追記する。 + +画像は最新ファイルに上書きする。日時別ファイルを増やさない。 + +## ユーザー名照合 + +`src/vrc_log/member_name_matcher.py` は TOML 登録名と VRChat 表示名を安全な順番で紐づける補助実装。 + +照合順。 + +1. 完全一致 +2. 正規化一致 +3. 高信頼 fuzzy +4. guest のみ低信頼 fuzzy +5. pending / ambiguous / unmatched + +完全一致・正規化一致で確定した名前は fuzzy 候補から除外する。 + +## Git に入れないもの + +以下はローカル専用。 + +- `config/config.toml` +- `config/secrets.toml` +- `config/guests.txt` +- `runtime/` +- `__pycache__/` +- `*.pyc` +- `.vrchat_cookie.txt` + +秘密情報を含む可能性があるものはコミットしない。 diff --git a/VRWT_Tool/VRC_OSC/requirements.txt b/VRWT_Tool/VRC_OSC/requirements.txt new file mode 100644 index 0000000..ccef526 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/requirements.txt @@ -0,0 +1,10 @@ +deep-translator +paddleocr +pillow +pyautogui +pygetwindow +pykakasi +pyperclip +python-osc +rapidfuzz +vrchatapi diff --git a/VRWT_Tool/VRC_OSC/src/app.py b/VRWT_Tool/VRC_OSC/src/app.py new file mode 100644 index 0000000..6b25eba --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/app.py @@ -0,0 +1,114 @@ +import os +from pathlib import Path +import sys + +os.environ["FLAGS_enable_pir_api"] = "0" +os.environ["FLAGS_use_onednn"] = "0" +os.environ["FLAGS_use_mkldnn"] = "0" +os.environ["FLAGS_use_onednn_bfloat16"] = "0" +os.environ["ONEDNN_VERBOSE"] = "0" + +SRC_DIR = Path(__file__).resolve().parents[1] +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +from osc.gateway import VrcOscGateway +from discord_control.actions import setDiscordMute +from ocr.ocr_actions import runOcrFromScreen +from translate.translate_actions import saveTranslationText +from translate.translate_actions import translateTextToJapanese +from vrc_log.log_actions import collectVrchatLog +from vrc_log.log_actions import startSelfMonitor +from vrc_log.log_actions import startVrcLogMonitor +from vrc_log.log_actions import stopSelfMonitor +from vrc_log.log_actions import stopVrcLogMonitor + +PARAM_DISCORD_MUTE = "DiscordSend" +PARAM_LOG_GREP = "vrc_log" +PARAM_OCR = "ocrEnabled" +PARAM_TRANSLATE_OCR = "translation" + + +def create_gateway(): + gateway = VrcOscGateway(host="127.0.0.1", port=9001) + + def on_discord_mute(address, *args): + if not args: + gateway.log("ERROR", "DiscordSend args empty") + return + + if gateway.is_rising_edge(address, args[0]): + gateway.log("INFO", f"received {address} args={args}") + setDiscordMute() + + vrc_log_enabled = False + def on_log_grep(address, *args): + nonlocal vrc_log_enabled + + if not args: + gateway.log("ERROR", "vrc_log args empty") + return + + if gateway.is_rising_edge(address, args[0]): + vrc_log_enabled = not vrc_log_enabled + gateway.log("INFO", f"received {address} args={args} enabled={vrc_log_enabled}") + + if vrc_log_enabled: + collectVrchatLog(notify_changed=True) + startVrcLogMonitor() + else: + stopVrcLogMonitor() + + def on_ocr(address, *args): + if not args: + gateway.log("ERROR", "ocrEnabled args empty") + return + + if gateway.is_rising_edge(address, args[0]): + gateway.log("INFO", f"received {address} args={args}") + runOcrFromScreen() + + def on_translate_ocr(address, *args): + if not args: + gateway.log("ERROR", "translateOcr args empty") + return + + if gateway.is_rising_edge(address, args[0]): + gateway.log("INFO", f"received {address} args={args}") + + result = runOcrFromScreen() + if not result: + gateway.log("ERROR", "OCR result is None") + return + + text = result.get("text", "") + if not text.strip(): + gateway.log("INFO", "OCR text is empty. translation skipped") + return + + translated = translateTextToJapanese(text) + if not translated.strip(): + gateway.log("INFO", "translation result is empty") + return + + image_path = result.get("image_path") + stem = image_path.stem if image_path else "translation" + saved = saveTranslationText(stem, translated) + gateway.log("INFO", f"translation saved={saved}") + + gateway.map(PARAM_DISCORD_MUTE, on_discord_mute) + gateway.map(PARAM_LOG_GREP, on_log_grep) + gateway.map(PARAM_OCR, on_ocr) + gateway.map(PARAM_TRANSLATE_OCR, on_translate_ocr) + gateway.set_default_debug(enabled=False) + return gateway + + +def main(): + gateway = create_gateway() + startSelfMonitor() + gateway.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/VRWT_Tool/VRC_OSC/src/bin/vrc_osc.py b/VRWT_Tool/VRC_OSC/src/bin/vrc_osc.py new file mode 100644 index 0000000..bc7e369 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/bin/vrc_osc.py @@ -0,0 +1,13 @@ +from pathlib import Path +import sys + +SRC_DIR = Path(__file__).resolve().parents[1] + +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +from app import main + + +if __name__ == "__main__": + main() diff --git a/VRWT_Tool/VRC_OSC/src/common/config_loader.py b/VRWT_Tool/VRC_OSC/src/common/config_loader.py new file mode 100644 index 0000000..364ea60 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/common/config_loader.py @@ -0,0 +1,130 @@ +from pathlib import Path +import tomllib + +from common.project_paths import ROOT_DIR + +CONFIG_DIR = ROOT_DIR / "config" +CONFIG_FILE = CONFIG_DIR / "config.toml" +SECRETS_FILE = CONFIG_DIR / "secrets.toml" +DEFAULT_EVENT_FILE = CONFIG_DIR / "event.toml" +DEFAULT_GUEST_FILE = CONFIG_DIR / "guests.txt" + + +def loadTomlFile(path): + if not path.exists(): + return {} + + return tomllib.loads(path.read_text(encoding="utf-8")) + + +def resolveConfigPath(value, default_path): + if not value: + return default_path + + candidate = Path(value) + if candidate.is_absolute(): + return candidate + + return (ROOT_DIR / candidate).resolve() + + +def loadNameListFile(path): + if not path.exists(): + return [] + + names = [] + + for line in path.read_text(encoding="utf-8-sig", errors="replace").splitlines(): + name = line.strip() + if not name or name.startswith("#"): + continue + names.append(name) + + return names + + +def loadSecretConfig(): + return loadTomlFile(SECRETS_FILE) + + +def getSecretValue(section_name, key, default=""): + secrets = loadSecretConfig() + section = secrets.get(section_name, {}) + return str(section.get(key, default)).strip() + + +def loadOcrConfig(): + config = loadTomlFile(CONFIG_FILE) + ocr = config.get("ocr", {}) + crop = ocr.get("crop", {}) + + return { + "crop_left": float(crop.get("left", 0.05)), + "crop_top": float(crop.get("top", 0.35)), + "crop_right": float(crop.get("right", 0.95)), + "crop_bottom": float(crop.get("bottom", 0.95)), + "preprocess": bool(ocr.get("preprocess", True)), + "scale": float(ocr.get("scale", 1.5)), + "use_angle_cls": bool(ocr.get("use_angle_cls", False)), + } + + +def loadVrcLogConfig(): + config = loadTomlFile(CONFIG_FILE) + vrc_log = config.get("vrc_log", {}) + self_section = config.get("self", {}) + event_file_value = ( + vrc_log.get("event_toml") + or vrc_log.get("event_file") + or config.get("event_toml") + or config.get("event_file") + ) + + event_file = resolveConfigPath(event_file_value, DEFAULT_EVENT_FILE) + event_config = loadTomlFile(event_file) + + if not event_config: + event_config = vrc_log or config + + guest_section = event_config.get("guest", {}) + guest_names = guest_section.get("names", []) + guest_file_value = ( + guest_section.get("file") + or event_config.get("guest_file") + or config.get("guest_file") + ) + guest_file = resolveConfigPath(guest_file_value, DEFAULT_GUEST_FILE) + + if not guest_names: + guest_names = loadNameListFile(guest_file) + + staff_names = event_config.get("staff", {}).get("names", []) + + if not guest_names and isinstance(event_config.get("guest_names"), list): + guest_names = event_config.get("guest_names", []) + if not staff_names and isinstance(event_config.get("staff_names"), list): + staff_names = event_config.get("staff_names", []) + + notice_section = event_config.get("notice", {}) + missing_count = notice_section.get("missing_count", 0) + + if not missing_count: + missing_count = event_config.get("missing_count", 0) + + self_name = ( + self_section.get("name") + or config.get("self_name") + or vrc_log.get("self_name") + or event_config.get("self", {}).get("name") + or "" + ) + + return { + "config_file": CONFIG_FILE, + "event_file": event_file, + "guest_file": guest_file, + "guest_names": [str(name).strip() for name in guest_names if str(name).strip()], + "staff_names": [str(name).strip() for name in staff_names if str(name).strip()], + "missing_count": int(missing_count), + "self_name": str(self_name).strip(), + } diff --git a/VRWT_Tool/VRC_OSC/src/common/project_paths.py b/VRWT_Tool/VRC_OSC/src/common/project_paths.py new file mode 100644 index 0000000..fa38746 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/common/project_paths.py @@ -0,0 +1,6 @@ +from pathlib import Path + +SRC_DIR = Path(__file__).resolve().parents[1] +ROOT_DIR = SRC_DIR.parent +RUNTIME_DIR = ROOT_DIR / "runtime" +RUNTIME_LOG_FILE = RUNTIME_DIR / "runtime.log" diff --git a/VRWT_Tool/VRC_OSC/src/common/runtime_log.py b/VRWT_Tool/VRC_OSC/src/common/runtime_log.py new file mode 100644 index 0000000..862cec0 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/common/runtime_log.py @@ -0,0 +1,19 @@ +from datetime import datetime + +from common.project_paths import RUNTIME_LOG_FILE + + +def appendRuntimeLog(title, text): + RUNTIME_LOG_FILE.parent.mkdir(parents=True, exist_ok=True) + + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + body = str(text).strip() + if not body: + body = "(empty)" + + with RUNTIME_LOG_FILE.open("a", encoding="utf-8") as file: + file.write(f"\n[{now}] {title}\n") + file.write(body) + file.write("\n") + + return RUNTIME_LOG_FILE diff --git a/VRWT_Tool/VRC_OSC/src/discord_control/actions.py b/VRWT_Tool/VRC_OSC/src/discord_control/actions.py new file mode 100644 index 0000000..9d1b68f --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/discord_control/actions.py @@ -0,0 +1,203 @@ +from datetime import datetime +import ctypes +import threading +import time + +import pyautogui +import pygetwindow as gw + +BROWSER_KEYWORDS = [ + "google chrome", + "microsoft edge", + "mozilla firefox", +] + +_discord_muted = True +_discord_mute_lock = threading.Lock() + + +def log(level, message): + now = datetime.now().strftime("%H:%M:%S") + print(f"[{level}] {now} {message}", flush=True) + + +def findDiscordWindow(): + candidates = [] + + for window in gw.getAllWindows(): + title = (window.title or "").strip() + if not title: + continue + if "discord" not in title.lower(): + continue + candidates.append(window) + + if not candidates: + raise RuntimeError("Discordウィンドウが見つかりません") + + candidates.sort(key=lambda window: -(window.width * window.height)) + return candidates[0] + + +def findBrowserWindow(): + candidates = [] + + for window in gw.getAllWindows(): + title = (window.title or "").strip() + if not title: + continue + lowered = title.lower() + if not any(keyword in lowered for keyword in BROWSER_KEYWORDS): + continue + candidates.append(window) + + if not candidates: + raise RuntimeError("ブラウザウィンドウが見つかりません") + + candidates.sort(key=lambda window: -(window.width * window.height)) + return candidates[0] + + +def findDiscordBrowserWindow(): + candidates = [] + + for window in gw.getAllWindows(): + title = (window.title or "").strip() + if not title: + continue + lowered = title.lower() + if "discord" not in lowered: + continue + if not any(keyword in lowered for keyword in BROWSER_KEYWORDS): + continue + candidates.append(window) + + if not candidates: + return None + + candidates.sort(key=lambda window: -(window.width * window.height)) + return candidates[0] + + +def activateDiscordWindow(): + window = findDiscordWindow() + hwnd = getattr(window, "_hWnd", None) + + try: + if window.isMinimized: + window.restore() + except Exception: + pass + + if hwnd: + try: + ctypes.windll.user32.ShowWindow(hwnd, 9) + except Exception: + pass + + try: + window.activate() + except Exception as e: + log("ERROR", f"Discord activate() failed detail={e}") + + if hwnd: + try: + ctypes.windll.user32.BringWindowToTop(hwnd) + ctypes.windll.user32.SetForegroundWindow(hwnd) + except Exception as e: + log("ERROR", f"Discord SetForegroundWindow failed detail={e}") + + time.sleep(0.15) + + log("INFO", f"Discord window title={window.title}") + return window + + +def findVrchatWindow(): + candidates = [] + + for window in gw.getAllWindows(): + title = (window.title or "").strip() + if not title: + continue + lowered = title.lower() + if lowered == "vrchat": + candidates.append((0, window)) + continue + if lowered.startswith("vrchat "): + candidates.append((1, window)) + continue + if lowered.startswith("vrchat"): + candidates.append((2, window)) + + if not candidates: + raise RuntimeError("VRChatウィンドウが見つかりません") + + candidates.sort(key=lambda item: (item[0], -(item[1].width * item[1].height))) + return candidates[0][1] + + +def activateWindow(window, label): + if not window: + return + + hwnd = getattr(window, "_hWnd", None) + + try: + if window.isMinimized: + window.restore() + except Exception: + pass + + if hwnd: + try: + ctypes.windll.user32.ShowWindow(hwnd, 9) + except Exception: + pass + + try: + window.activate() + except Exception as e: + log("ERROR", f"{label} activate() failed detail={e}") + + if hwnd: + try: + ctypes.windll.user32.BringWindowToTop(hwnd) + ctypes.windll.user32.SetForegroundWindow(hwnd) + except Exception as e: + log("ERROR", f"{label} SetForegroundWindow failed detail={e}") + + time.sleep(0.15) + try: + pyautogui.click(window.left + window.width // 2, window.top + window.height // 2) + except Exception: + pass + time.sleep(0.1) + log("INFO", f"{label} window title={window.title}") + + +def setDiscordMute(): + log("ACTION", "setDiscordMute called") + try: + activateDiscordWindow() + except Exception as e: + log("ERROR", f"Discord window unavailable detail={e}") + browser = findDiscordBrowserWindow() or findBrowserWindow() + activateWindow(browser, "Browser") + log("INFO", "Discord hotkey Ctrl+Shift+M") + pyautogui.hotkey("ctrl", "shift", "m") + activateWindow(findVrchatWindow(), "VRChat") + + +def setDiscordMuted(muted): + global _discord_muted + + muted = bool(muted) + + with _discord_mute_lock: + if _discord_muted == muted: + log("INFO", f"Discord mute already {'on' if muted else 'off'}") + return + + setDiscordMute() + _discord_muted = muted diff --git a/VRWT_Tool/VRC_OSC/src/ocr/ocr_actions.py b/VRWT_Tool/VRC_OSC/src/ocr/ocr_actions.py new file mode 100644 index 0000000..56f2a4a --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/ocr/ocr_actions.py @@ -0,0 +1,186 @@ +import os +from datetime import datetime + +os.environ["FLAGS_enable_pir_api"] = "0" +os.environ["FLAGS_use_onednn"] = "0" +os.environ["FLAGS_use_mkldnn"] = "0" +os.environ["FLAGS_use_onednn_bfloat16"] = "0" +os.environ["MKLDNN_DISABLE_WORKSPACE"] = "1" +os.environ["ONEDNN_VERBOSE"] = "0" + +import pyperclip +from paddleocr import PaddleOCR +from PIL import Image +from PIL import ImageFilter +from PIL import ImageOps + +from common.config_loader import loadOcrConfig +from common.runtime_log import appendRuntimeLog +from screen.screen_actions import captureOcrRegion + +_ocr_engine = None + + +def log(level, message): + now = datetime.now().strftime("%H:%M:%S") + print(f"[{level}] {now} {message}", flush=True) + + +def getOcrEngine(): + global _ocr_engine + + if _ocr_engine is None: + log("INFO", "PaddleOCR initializing") + config = loadOcrConfig() + options = { + "lang": "japan", + "use_angle_cls": config["use_angle_cls"], + "show_log": False, + } + + try: + _ocr_engine = PaddleOCR(**options) + except (TypeError, ValueError): + options.pop("show_log", None) + _ocr_engine = PaddleOCR(**options) + + return _ocr_engine + + +def saveOcrText(image_path, text): + return appendRuntimeLog( + "OCR TEXT", + f"image={image_path}\n{text}", + ) + + +def extractTextFromAny(obj): + lines = [] + + def walk(x): + if x is None: + return + + if isinstance(x, dict): + for key in ("rec_texts", "texts"): + value = x.get(key) + if isinstance(value, list): + for item in value: + text = str(item).strip() + if text: + lines.append(text) + + for key in ("text", "rec_text"): + value = x.get(key) + if isinstance(value, str) and value.strip(): + lines.append(value.strip()) + + for value in x.values(): + walk(value) + return + + if isinstance(x, (list, tuple)): + if len(x) >= 2 and isinstance(x[1], (list, tuple)) and len(x[1]) >= 1: + candidate = x[1][0] + if isinstance(candidate, str) and candidate.strip(): + lines.append(candidate.strip()) + + for item in x: + walk(item) + + walk(obj) + + unique_lines = [] + seen = set() + for line in lines: + if line not in seen: + unique_lines.append(line) + seen.add(line) + + return "\n".join(unique_lines) + + +def printOcrResult(text): + cleaned = text.strip() + if not cleaned: + log("INFO", "OCR結果は空です") + return + + log("CHECK", "OCR結果 full") + for line in cleaned.splitlines(): + print(line, flush=True) + + +def preprocessOcrImage(image_path): + config = loadOcrConfig() + enabled = config["preprocess"] + if not enabled: + return image_path + + try: + image = Image.open(image_path) + image = image.convert("L") + image = ImageOps.autocontrast(image) + image = image.filter(ImageFilter.SHARPEN) + + scale = max(1.0, min(2.0, config["scale"])) + if scale > 1.0: + width, height = image.size + resampling = getattr( + getattr(Image, "Resampling", Image), + "LANCZOS", + ) + image = image.resize( + (int(width * scale), int(height * scale)), + resampling, + ) + + image.save(image_path) + log("INFO", f"OCR image preprocessed={image_path}") + except Exception as e: + log("ERROR", f"OCR preprocess failed detail={e}") + + return image_path + + +def runPaddleOcr(engine, image_path): + if hasattr(engine, "predict"): + return engine.predict(str(image_path)) + return engine.ocr(str(image_path)) + + +def runOcrFromImage(image_path, show_result=True): + log("ACTION", f"PaddleOCR start image={image_path}") + + try: + image_path = preprocessOcrImage(image_path) + engine = getOcrEngine() + result = runPaddleOcr(engine, image_path) + except Exception as e: + log("ERROR", f"PaddleOCR failed detail={e}") + return None + + text = extractTextFromAny(result) + text_path = saveOcrText(image_path, text) + + try: + pyperclip.copy(text) + log("INFO", "OCR結果をクリップボードへコピーしました") + except Exception as e: + log("ERROR", f"clipboard copy failed detail={e}") + + log("INFO", f"OCR text appended={text_path}") + if show_result: + printOcrResult(text) + + return { + "image_path": image_path, + "text_path": text_path, + "text": text, + } + + +def runOcrFromScreen(): + log("ACTION", "runOcrFromScreen called") + image_path = captureOcrRegion() + return runOcrFromImage(image_path, show_result=True) diff --git a/VRWT_Tool/VRC_OSC/src/osc/gateway.py b/VRWT_Tool/VRC_OSC/src/osc/gateway.py new file mode 100644 index 0000000..419d88b --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/osc/gateway.py @@ -0,0 +1,85 @@ +from pythonosc.dispatcher import Dispatcher +from pythonosc.osc_server import BlockingOSCUDPServer +from datetime import datetime +import sys + +class VrcOscGateway: + def __init__(self, host="127.0.0.1", port=9001): + self.host = host + self.port = port + self.dispatcher = Dispatcher() + self.last_values = {} + + def log(self, level, message): + now = datetime.now().strftime("%H:%M:%S") + print(f"[{level}] {now} {message}", flush=True) + + def to_bool(self, value): + if isinstance(value, bool): + return value + + if isinstance(value, int) or isinstance(value, float): + return value != 0 + + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in ("true", "1", "on"): + return True + if lowered in ("false", "0", "off"): + return False + + raise ValueError(f"unsupported OSC value: {value}") + + def is_rising_edge(self, address, value): + current = self.to_bool(value) + previous = self.last_values.get(address, False) + self.last_values[address] = current + return current and not previous + + def is_changed(self, address, value): + current = self.to_bool(value) + previous = self.last_values.get(address, None) + + if previous == current: + return False + + self.last_values[address] = current + return True + + def map(self, parameter_name, handler): + address = f"/avatar/parameters/{parameter_name}" + self.dispatcher.map(address, handler) + self.log("INFO", f"mapped {address}") + + def set_default_debug(self, enabled=False): + if not enabled: + return + + def on_any(address, *args): + if address.startswith("/avatar/parameters/"): + self.log("INFO", f"other address={address} args={args}") + + self.dispatcher.set_default_handler(on_any) + + def serve_forever(self): + self.log("CHECK", "STEP=1 状態確認") + self.log("INFO", f"listen={self.host}:{self.port}") + + self.log("CHECK", "STEP=2 想定状態判定") + self.log("INFO", "VRChat OSC Enabled") + self.log("INFO", "Python は Windows PowerShell 側で実行") + self.log("INFO", "Unity側 MA Parameters と OSC名が一致していること") + + + try: + server = BlockingOSCUDPServer((self.host, self.port), self.dispatcher) + server.serve_forever() + except KeyboardInterrupt: + self.log("INFO", "Ctrl+C detected") + except Exception as e: + self.log("FATAL", f"OSC server error STEP=3 detail={e}") + input("Press Enter to exit...") + sys.exit(1) + + self.log("CHECK", "STEP=4 結果確認") + self.log("INFO", "終了しました") diff --git a/VRWT_Tool/VRC_OSC/src/screen/screen_actions.py b/VRWT_Tool/VRC_OSC/src/screen/screen_actions.py new file mode 100644 index 0000000..da0cf0a --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/screen/screen_actions.py @@ -0,0 +1,167 @@ +import pyautogui +import pygetwindow as gw + +from common.config_loader import loadOcrConfig +from common.project_paths import RUNTIME_DIR + +SCREENSHOT_PATH = RUNTIME_DIR / "latest_screenshot.png" +OCR_SCREENSHOT_PATH = RUNTIME_DIR / "latest_ocr.png" + +EXCLUDED_WINDOW_KEYWORDS = [ + "Google Chrome", + "Microsoft Edge", + "Mozilla Firefox", + "Visual Studio Code", + "PowerShell", + "Windows Terminal", + "ChatGPT", +] + + +def log(level, message): + from datetime import datetime + + now = datetime.now().strftime("%H:%M:%S") + print(f"[{level}] {now} {message}", flush=True) + + +def findVrchatWindow(): + windows = gw.getAllWindows() + candidates = [] + + for window in windows: + title = window.title or "" + normalized = title.strip().lower() + if not normalized: + continue + if any(keyword.lower() in normalized for keyword in EXCLUDED_WINDOW_KEYWORDS): + continue + + if normalized == "vrchat": + candidates.append((0, window)) + continue + if normalized.startswith("vrchat "): + candidates.append((1, window)) + continue + if normalized.startswith("vrchat"): + candidates.append((2, window)) + + if not candidates: + visible_titles = [] + for window in windows: + title = (window.title or "").strip() + if title: + visible_titles.append(title) + + log("ERROR", "VRChatウィンドウが見つかりません") + log("CHECK", "現在検出できるウィンドウ") + for title in visible_titles[:30]: + print(title, flush=True) + + raise RuntimeError("VRChatウィンドウが見つかりません") + + candidates = sorted( + candidates, + key=lambda item: (item[0], -(item[1].width * item[1].height)), + ) + window = candidates[0][1] + + if window.width <= 0 or window.height <= 0: + raise RuntimeError("VRChatウィンドウサイズが不正です") + + return window + + +def getVrchatRegion(): + window = findVrchatWindow() + + try: + if window.isMinimized: + window.restore() + except Exception: + pass + + left = int(window.left) + top = int(window.top) + width = int(window.width) + height = int(window.height) + + if width <= 0 or height <= 0: + raise RuntimeError("VRChatウィンドウ範囲が不正です") + + log("INFO", f"VRChat window title={window.title}") + log("INFO", f"VRChat region left={left} top={top} width={width} height={height}") + return left, top, width, height + + +def clampRatio(value): + return max(0.0, min(1.0, value)) + + +def getOcrRegion(): + left, top, width, height = getVrchatRegion() + config = loadOcrConfig() + + # Default to the lower central area. It is much faster than OCRing the + # whole VRChat window and usually contains chat/subtitle text. + crop_left = clampRatio(config["crop_left"]) + crop_top = clampRatio(config["crop_top"]) + crop_right = clampRatio(config["crop_right"]) + crop_bottom = clampRatio(config["crop_bottom"]) + + if crop_right <= crop_left: + crop_left = 0.0 + crop_right = 1.0 + + if crop_bottom <= crop_top: + crop_top = 0.0 + crop_bottom = 1.0 + + region_left = left + int(width * crop_left) + region_top = top + int(height * crop_top) + region_width = max(1, int(width * (crop_right - crop_left))) + region_height = max(1, int(height * (crop_bottom - crop_top))) + + log( + "INFO", + f"OCR crop left={region_left} top={region_top} width={region_width} height={region_height}", + ) + return region_left, region_top, region_width, region_height + + +def captureRegionTo(image_path, region): + image_path.parent.mkdir(parents=True, exist_ok=True) + + left, top, width, height = region + + image = pyautogui.screenshot(region=(left, top, width, height)) + image.save(image_path) + + log("ACTION", f"captureVrchatWindow saved={image_path}") + return image_path + + +def captureVrchatWindowTo(image_path): + return captureRegionTo( + image_path, + getVrchatRegion(), + ) + + +def captureVrchatWindow(): + return captureVrchatWindowTo(SCREENSHOT_PATH) + + +def captureOcrRegion(): + return captureRegionTo( + OCR_SCREENSHOT_PATH, + getOcrRegion(), + ) + + +def captureScreen(): + return captureVrchatWindow() + + +def takeScreenshot(): + return captureVrchatWindow() diff --git a/VRWT_Tool/VRC_OSC/src/tools/discord_hold_mute_listener.py b/VRWT_Tool/VRC_OSC/src/tools/discord_hold_mute_listener.py new file mode 100644 index 0000000..03514d2 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/tools/discord_hold_mute_listener.py @@ -0,0 +1,124 @@ +from pythonosc.dispatcher import Dispatcher +from pythonosc.osc_server import BlockingOSCUDPServer +from datetime import datetime +import pyautogui +import time +import sys + +HOST = "127.0.0.1" +PORT = 9001 +PARAMETER = "/avatar/parameters/DiscordSend" + +# Discordの初期状態を「ミュート中」と仮定 +discord_muted = True +button_pressed = False +last_action_time = 0.0 +DEBOUNCE_SECONDS = 0.3 + +def log(level, message): + now = datetime.now().strftime("%H:%M:%S") + print(f"[{level}] {now} {message}", flush=True) + +def wait_exit(): + input("Press Enter to exit...") + +def press_discord_mute_hotkey(): + pyautogui.hotkey("ctrl", "shift", "m") + +def handle_discord_send(address, *args): + global discord_muted + global button_pressed + global last_action_time + + if not args: + log("ERROR", "OSC args is empty") + return + + value = args[0] + now_ts = time.time() + + if isinstance(value, bool): + is_pressed = value + elif isinstance(value, int) or isinstance(value, float): + is_pressed = value != 0 + else: + log("ERROR", f"unsupported value type value={value}") + return + + if now_ts - last_action_time < DEBOUNCE_SECONDS: + return + + # 押した瞬間: ミュート解除 + if is_pressed and not button_pressed: + button_pressed = True + + if discord_muted: + log("ACTION", "VRChat button pressed -> Discord unmute hotkey") + press_discord_mute_hotkey() + discord_muted = False + last_action_time = now_ts + else: + log("INFO", "already unmuted") + + return + + # 離した瞬間: ミュート + if not is_pressed and button_pressed: + button_pressed = False + + if not discord_muted: + log("ACTION", "VRChat button released -> Discord mute hotkey") + press_discord_mute_hotkey() + discord_muted = True + last_action_time = now_ts + else: + log("INFO", "already muted") + + return + +def main(): + log("CHECK", "STEP=1 OSC受信状態確認") + log("INFO", f"host={HOST}") + log("INFO", f"port={PORT}") + log("INFO", f"parameter={PARAMETER}") + + log("CHECK", "STEP=2 想定状態判定") + log("INFO", "Windows PowerShellで実行していること") + log("INFO", "VRChat OSC Enabledであること") + log("INFO", "Discordのミュート切替キーが Ctrl+Shift+M であること") + log("INFO", "Discordの現在状態はミュート中として扱います") + + answer = input("[ACTION] 続行しますか? yes/no: ").strip().lower() + + if answer != "yes": + if answer == "no": + log("FATAL", "ユーザー操作により停止 STEP=2") + wait_exit() + sys.exit(1) + + log("ERROR", "不正入力です STEP=2") + wait_exit() + sys.exit(1) + + log("ACTION", "STEP=3 OSC受信開始") + log("INFO", "VRChatのメニューを押している間だけDiscordミュート解除します") + log("INFO", "停止する場合は Ctrl+C") + + dispatcher = Dispatcher() + dispatcher.map(PARAMETER, handle_discord_send) + + try: + server = BlockingOSCUDPServer((HOST, PORT), dispatcher) + server.serve_forever() + except KeyboardInterrupt: + log("INFO", "Ctrl+C detected") + except Exception as e: + log("FATAL", f"OSC server error STEP=3 detail={e}") + wait_exit() + sys.exit(1) + + log("CHECK", "STEP=4 結果確認") + log("INFO", "終了しました") + +if __name__ == "__main__": + main() diff --git a/VRWT_Tool/VRC_OSC/src/tools/osc_debug_listener.py b/VRWT_Tool/VRC_OSC/src/tools/osc_debug_listener.py new file mode 100644 index 0000000..c21b324 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/tools/osc_debug_listener.py @@ -0,0 +1,21 @@ +from pythonosc.dispatcher import Dispatcher +from pythonosc.osc_server import BlockingOSCUDPServer +from datetime import datetime + +HOST = "127.0.0.1" +PORT = 9001 + +def on_any(address, *args): + now = datetime.now().strftime("%H:%M:%S") + print(f"[INFO] {now} address={address} args={args}", flush=True) + +dispatcher = Dispatcher() +dispatcher.set_default_handler(on_any) + +print("[CHECK] OSC受信開始") +print(f"[INFO] host={HOST}") +print(f"[INFO] port={PORT}") +print("[ACTION] VRChatでメニューを押してください") + +server = BlockingOSCUDPServer((HOST, PORT), dispatcher) +server.serve_forever() diff --git a/VRWT_Tool/VRC_OSC/src/tools/osc_test_print_listener.py b/VRWT_Tool/VRC_OSC/src/tools/osc_test_print_listener.py new file mode 100644 index 0000000..5b67fb4 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/tools/osc_test_print_listener.py @@ -0,0 +1,54 @@ +from pythonosc.dispatcher import Dispatcher +from pythonosc.osc_server import BlockingOSCUDPServer +from datetime import datetime + +HOST = "127.0.0.1" +PORT = 9001 + +def log(msg): + print(msg, flush=True) + +def on_test(address, *args): + now = datetime.now().strftime("%H:%M:%S") + + log("") + log(f"[INFO] time={now}") + log(f"[ACTION] OSC parameter received") + log(f"[INFO] address={address}") + log(f"[INFO] args={args}") + +dispatcher = Dispatcher() + +dispatcher.map( + "/avatar/parameters/TestPrint", + on_test +) + +log("[CHECK] STEP=1 OSC待受開始") +log(f"[INFO] host={HOST}") +log(f"[INFO] port={PORT}") + +try: + server = BlockingOSCUDPServer( + (HOST, PORT), + dispatcher + ) + + log("[INFO] OSC server started") + log("[ACTION] VRChatでTestPrintを押してください") + + server.serve_forever() + +except KeyboardInterrupt: + log("") + log("[INFO] Ctrl+C detected") + log("[INFO] shutdown") + +except Exception as e: + log("") + log("[FATAL] OSC server error") + log(f"[ERROR] detail={e}") + + print("") + input("Press Enter to exit...") + raise SystemExit(1) diff --git a/VRWT_Tool/VRC_OSC/src/tools/resolve_vrchat_user.py b/VRWT_Tool/VRC_OSC/src/tools/resolve_vrchat_user.py new file mode 100644 index 0000000..9841885 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/tools/resolve_vrchat_user.py @@ -0,0 +1,134 @@ +import re +from http.cookiejar import MozillaCookieJar + +import vrchatapi +from vrchatapi.api import authentication_api +from vrchatapi.api import users_api +from vrchatapi.exceptions import UnauthorizedException +from vrchatapi.models.two_factor_auth_code import TwoFactorAuthCode +from vrchatapi.models.two_factor_email_code import TwoFactorEmailCode + +from common.config_loader import getSecretValue + +COOKIE_FILE = ".vrchat_cookie.txt" + +USER_AGENT = ( + "VRC_OSC/1.0 " + "(contact: test@gmail.com)" +) + + +def get_required_secret(section, key): + value = getSecretValue(section, key) + if not value: + raise RuntimeError(f"{section}.{key} is not set in config/secrets.toml") + return value + + +def setup_cookie_jar(api_client): + cookie_jar = MozillaCookieJar(COOKIE_FILE) + + if os.path.exists(COOKIE_FILE): + try: + cookie_jar.load( + ignore_discard=True, + ignore_expires=True, + ) + except Exception: + pass + + api_client.rest_client.cookie_jar = cookie_jar + return cookie_jar + + +def save_cookie_jar(cookie_jar): + try: + cookie_jar.save( + ignore_discard=True, + ignore_expires=True, + ) + except Exception: + pass + + +def login(api_client, cookie_jar): + auth_api = authentication_api.AuthenticationApi(api_client) + + try: + current_user = auth_api.get_current_user() + save_cookie_jar(cookie_jar) + return current_user + + except UnauthorizedException as e: + reason = str(e.reason) + + if "Email 2 Factor Authentication" in reason: + code = input("Email 2FA Code: ").strip() + + auth_api.verify2_fa_email_code( + TwoFactorEmailCode(code) + ) + + elif "2 Factor Authentication" in reason: + code = input("TOTP Code: ").strip() + + auth_api.verify2_fa( + TwoFactorAuthCode(code) + ) + + else: + raise + + current_user = auth_api.get_current_user() + + save_cookie_jar(cookie_jar) + + return current_user + + +def extract_user_id(text): + match = re.search( + r"(usr_[0-9a-fA-F\-]+)", + text, + ) + + if not match: + return None + + return match.group(1) + + +def main(): + text = input("参加者名: ").strip() + + user_id = extract_user_id(text) + + if not user_id: + print(text) + return + + configuration = vrchatapi.Configuration( + username=get_required_secret("vrchat", "username"), + password=get_required_secret("vrchat", "password"), + ) + + with vrchatapi.ApiClient(configuration) as api_client: + api_client.user_agent = USER_AGENT + + api_client.default_headers["User-Agent"] = USER_AGENT + + cookie_jar = setup_cookie_jar(api_client) + + login(api_client, cookie_jar) + + users = users_api.UsersApi(api_client) + + user = users.get_user(user_id) + + print(user.display_name) + + save_cookie_jar(cookie_jar) + + +if __name__ == "__main__": + main() diff --git a/VRWT_Tool/VRC_OSC/src/translate/translate_actions.py b/VRWT_Tool/VRC_OSC/src/translate/translate_actions.py new file mode 100644 index 0000000..6df4117 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/translate/translate_actions.py @@ -0,0 +1,102 @@ +from datetime import datetime +import re + +import pyperclip +from deep_translator import GoogleTranslator + +from common.runtime_log import appendRuntimeLog + +IGNORE_EXACT = { + "戻る", + "翻訳", + "OCR", + "VRCLOG", + "Discord_Mute", + "Screenshot", + "OCR_Toggle", + "DCR_Toggle", + "HI", +} + + +def log(level, message): + now = datetime.now().strftime("%H:%M:%S") + print(f"[{level}] {now} {message}", flush=True) + + +def saveTranslationText(stem, text): + return appendRuntimeLog( + "TRANSLATION TEXT", + f"source={stem}\n{text}", + ) + + +def looksLikeNoise(line): + value = line.strip() + if not value: + return True + if value in IGNORE_EXACT: + return True + if len(value) <= 2: + return True + if "_" in value: + return True + if re.fullmatch(r"[A-Za-z0-9\-_/]+", value): + return True + return False + + +def normalizeSourceText(text): + lines = [] + + for raw_line in text.splitlines(): + line = raw_line.strip() + if looksLikeNoise(line): + continue + lines.append(line) + + unique_lines = [] + seen = set() + for line in lines: + if line not in seen: + unique_lines.append(line) + seen.add(line) + + return "\n".join(unique_lines).strip() + + +def translateTextToJapanese(text): + source = normalizeSourceText(text) + if not source: + log("INFO", "翻訳対象が空です") + return "" + + log("CHECK", "Translation source") + for line in source.splitlines(): + print(line, flush=True) + + try: + translated = GoogleTranslator( + source="auto", + target="ja", + ).translate(source) + except Exception as e: + log("ERROR", f"translation failed detail={e}") + return "" + + translated = translated.strip() + if not translated: + log("INFO", "翻訳結果は空です") + return "" + + try: + pyperclip.copy(translated) + log("INFO", "翻訳結果をクリップボードへコピーしました") + except Exception as e: + log("ERROR", f"clipboard copy failed detail={e}") + + log("CHECK", "Translation result") + for line in translated.splitlines(): + print(line, flush=True) + + return translated diff --git a/VRWT_Tool/VRC_OSC/src/vrc_log/log_actions.py b/VRWT_Tool/VRC_OSC/src/vrc_log/log_actions.py new file mode 100644 index 0000000..e533599 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/vrc_log/log_actions.py @@ -0,0 +1,563 @@ +import os +import re +import subprocess +import tempfile +import threading +import time +from datetime import datetime +from datetime import timedelta +from pathlib import Path + +from common.config_loader import loadVrcLogConfig +from common.config_loader import getSecretValue +from common.runtime_log import appendRuntimeLog +from discord_control.actions import setDiscordMuted + +_monitor_thread = None +_monitor_stop_event = threading.Event() +_last_sent_output = None +_self_monitor_thread = None +_self_monitor_stop_event = threading.Event() +_last_self_state = None +REALTIME_IDLE_SECONDS = 0.2 + +JOIN_PATTERN = re.compile( + r"OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)" +) +LEFT_PATTERN = re.compile( + r"OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)" +) +WORLD_PATTERN = re.compile( + r"(wrld_[0-9a-fA-F-]+(?::[^\s\]\)\"']+)?)" +) +ENTERING_ROOM_PATTERN = re.compile( + r"\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$" +) +WORLD_NAME_PATTERN = re.compile( + r"worldId=(wrld_[0-9a-fA-F-]+)(?::[^,}]*)?,\s*worldName=([^,}]+)" +) +TIME_PATTERN = re.compile( + r"(\d{4}\.\d{2}\.\d{2}\s+\d{2}:\d{2}:\d{2})" +) +RELEVANT_LOG_PATTERN = re.compile( + r"OnPlayerJoined|OnPlayerLeft|Entering Room|Joining or Creating Room|worldId=|wrld_" +) + + +def log(level, message): + now = datetime.now().strftime("%H:%M:%S") + print(f"[{level}] {now} {message}", flush=True) + + +def getWebhookUrl(): + return getSecretValue("discord", "webhook_url") + + +def sendWebhook(text): + webhook_url = getWebhookUrl().strip() + if not webhook_url: + log("ERROR", "Discord webhook URL is not set config=config/secrets.toml") + return False + + message = str(text).strip() + if not message: + log("INFO", "Webhook送信内容が空です") + return False + + if len(message) > 1800: + message = message[-1800:] + + ps_script = r''' +param( + [string]$WebhookUrl, + [string]$Message +) + +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$Payload = @{ content = $Message } | ConvertTo-Json -Compress +$Body = [System.Text.Encoding]::UTF8.GetBytes($Payload) +Invoke-WebRequest -Uri $WebhookUrl -Method Post -ContentType 'application/json; charset=utf-8' -Body $Body -UseBasicParsing | Out-Null +''' + + ps_path = None + + try: + with tempfile.NamedTemporaryFile( + mode="w", + suffix=".ps1", + delete=False, + encoding="utf-8-sig", + ) as tmp: + tmp.write(ps_script) + ps_path = tmp.name + + result = subprocess.run( + [ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + ps_path, + webhook_url, + message, + ], + capture_output=True, + text=True, + timeout=20, + ) + except Exception as e: + log("ERROR", f"Discord webhook PowerShell failed detail={e}") + return False + finally: + if ps_path: + try: + Path(ps_path).unlink(missing_ok=True) + except Exception: + pass + + if result.returncode != 0: + stderr = (result.stderr or "").strip() + stdout = (result.stdout or "").strip() + log("ERROR", f"Discord webhook PowerShell failed code={result.returncode} stderr={stderr} stdout={stdout}") + return False + + log("INFO", "Discord webhook sent via PowerShell") + return True + + +def getVrchatLogDir(): + userprofile = os.environ.get("USERPROFILE") + if not userprofile: + raise RuntimeError("USERPROFILE が取得できません") + return Path(userprofile) / "AppData" / "LocalLow" / "VRChat" / "VRChat" + + +def findLatestVrchatLog(): + log_dir = getVrchatLogDir() + if not log_dir.exists(): + raise RuntimeError(f"VRChatログディレクトリが存在しません: {log_dir}") + + candidates = [] + for pattern in ("output_log_*.txt", "Player.log", "*.log", "*.txt"): + candidates.extend(log_dir.glob(pattern)) + + candidates = [path for path in candidates if path.is_file()] + if not candidates: + raise RuntimeError(f"VRChatログファイルが見つかりません: {log_dir}") + + candidates.sort(key=lambda path: path.stat().st_mtime, reverse=True) + return candidates[0] + + +def readTextSafe(path): + for encoding in ("utf-8", "utf-8-sig", "cp932", "shift_jis"): + try: + return path.read_text(encoding=encoding, errors="replace") + except Exception: + continue + + raise RuntimeError(f"ログファイルを読めません: {path}") + + +def parseLineTime(line): + matched = TIME_PATTERN.search(line) + if not matched: + return None + + return datetime.strptime(matched.group(1), "%Y.%m.%d %H:%M:%S") + + +def formatClock(dt): + return dt.strftime("%H:%M") + + +def getLogStartTime(now): + noon_today = now.replace(hour=12, minute=0, second=0, microsecond=0) + if now >= noon_today: + return noon_today + return noon_today - timedelta(days=1) + + +def splitWorldLocation(location): + if ":" not in location: + return location, "" + + world_id, instance_id = location.split(":", 1) + return world_id, instance_id + + +def extractWorldNameMap(text): + world_name_map = {} + pending_world_name = "" + + for raw_line in text.splitlines(): + room_match = ENTERING_ROOM_PATTERN.search(raw_line) + if room_match: + pending_world_name = room_match.group(1).strip() + + match = WORLD_NAME_PATTERN.search(raw_line) + if match: + world_id = match.group(1).strip() + world_name = match.group(2).strip() + if world_id and world_name: + world_name_map[world_id] = world_name + continue + + world_match = WORLD_PATTERN.search(raw_line) + if world_match and pending_world_name: + location = world_match.group(1).strip() + world_id, _ = splitWorldLocation(location) + if world_id: + world_name_map[world_id] = pending_world_name + pending_world_name = "" + + return world_name_map + + +def formatWorldLabel(world_id, world_name_map): + world_name = world_name_map.get(world_id, "").strip() + if not world_name: + return "不明" + return world_name + + +def classifyUser(name, guest_names, staff_names): + if name in staff_names: + return "staff" + if name in guest_names: + return "guest" + return "other" + + +def isSelf(name, self_name): + return bool(self_name) and name.strip() == self_name.strip() + + +def buildNoticeLines(guest_names, guest_present_set, missing_count): + total_guest = len(guest_names) + current_guest = len(guest_present_set) + threshold = total_guest - missing_count + + if missing_count <= 0: + return [] + if not (threshold <= current_guest < total_guest): + return [] + + missing_guests = [name for name in guest_names if name not in guest_present_set] + if not missing_guests: + return [] + + lines = [f"現在Guest: {current_guest}/{total_guest}", "未入室Guest:"] + lines.extend(f"- {name}" for name in missing_guests) + return lines + + +def appendNoticeIfNeeded(output_lines, state): + notice_lines = buildNoticeLines( + state["guest_names"], + state["guest_present_set"], + state["missing_count"], + ) + notice_key = tuple(notice_lines) + + if not notice_lines: + state["last_notice_key"] = None + return + + if state["last_notice_key"] == notice_key: + return + + output_lines.extend(notice_lines) + state["last_notice_key"] = notice_key + + +def parseVrchatEvents(text, config): + start_time = getLogStartTime(datetime.now()) + guest_names = config["guest_names"] + staff_names = set(config["staff_names"]) + self_name = config.get("self_name", "") + world_name_map = extractWorldNameMap(text) + + state = { + "location": "", + "world_id": "", + "instance_id": "", + "guest_names": guest_names, + "guest_name_set": set(guest_names), + "staff_names": staff_names, + "guest_present_set": set(), + "staff_present_set": set(), + "member_present_set": set(), + "missing_count": config["missing_count"], + "last_notice_key": None, + } + output_lines = [] + self_state = None + + for raw_line in text.splitlines(): + line_time = parseLineTime(raw_line) + if not line_time or line_time < start_time: + continue + + world_match = WORLD_PATTERN.search(raw_line) + if world_match: + location = world_match.group(1).strip() + world_id, instance_id = splitWorldLocation(location) + + if location != state["location"]: + state["location"] = location + state["world_id"] = world_id + state["instance_id"] = instance_id + state["guest_present_set"].clear() + state["staff_present_set"].clear() + state["member_present_set"].clear() + state["last_notice_key"] = None + output_lines = [] + output_lines.append(f"ワールド入室: {formatWorldLabel(world_id, world_name_map)}") + output_lines.append(f"現在Guest: 0/{len(guest_names)}") + + join_match = JOIN_PATTERN.search(raw_line) + if join_match and state["world_id"]: + name = join_match.group(1).strip() + if isSelf(name, self_name): + self_state = "joined" + state["member_present_set"].add(name) + output_lines.append( + f'[{formatClock(line_time)}] [join] [self] {name} {len(state["member_present_set"])}/{len(guest_names)}' + ) + continue + + role = classifyUser(name, state["guest_name_set"], state["staff_names"]) + + if role == "guest": + state["guest_present_set"].add(name) + state["member_present_set"].add(name) + output_lines.append( + f'[{formatClock(line_time)}] [join] {name} {len(state["member_present_set"])}/{len(guest_names)}' + ) + appendNoticeIfNeeded(output_lines, state) + elif role == "staff": + state["staff_present_set"].add(name) + output_lines.append( + f'[{formatClock(line_time)}] [join] [staff] {name} {len(state["member_present_set"])}/{len(guest_names)}' + ) + else: + state["member_present_set"].add(name) + output_lines.append( + f'[{formatClock(line_time)}] [join] {name} {len(state["member_present_set"])}/{len(guest_names)}' + ) + continue + + left_match = LEFT_PATTERN.search(raw_line) + if left_match and state["world_id"]: + name = left_match.group(1).strip() + if isSelf(name, self_name): + self_state = "left" + state["member_present_set"].discard(name) + output_lines.append( + f'[{formatClock(line_time)}] [leave] [self] {name} {len(state["member_present_set"])}/{len(guest_names)}' + ) + continue + + role = classifyUser(name, state["guest_name_set"], state["staff_names"]) + + if role == "guest": + state["guest_present_set"].discard(name) + state["member_present_set"].discard(name) + output_lines.append( + f'[{formatClock(line_time)}] [leave] {name} {len(state["member_present_set"])}/{len(guest_names)}' + ) + appendNoticeIfNeeded(output_lines, state) + elif role == "staff": + state["staff_present_set"].discard(name) + output_lines.append( + f'[{formatClock(line_time)}] [leave] [staff] {name} {len(state["member_present_set"])}/{len(guest_names)}' + ) + else: + state["member_present_set"].discard(name) + output_lines.append( + f'[{formatClock(line_time)}] [leave] {name} {len(state["member_present_set"])}/{len(guest_names)}' + ) + + return output_lines, self_state + + +def extractLatestSelfLogState(text, self_name): + if not self_name: + return None + + latest_state = None + + for raw_line in text.splitlines(): + join_match = JOIN_PATTERN.search(raw_line) + if join_match and isSelf(join_match.group(1).strip(), self_name): + latest_state = "joined" + continue + + left_match = LEFT_PATTERN.search(raw_line) + if left_match and isSelf(left_match.group(1).strip(), self_name): + latest_state = "left" + + return latest_state + + +def grepVrchatLog(pattern=None): + return collectVrchatLog(pattern=pattern, notify_changed=False) + + +def collectVrchatLog(pattern=None, notify_changed=True): + config = loadVrcLogConfig() + latest_log = findLatestVrchatLog() + log("ACTION", f"parseVrchatLog file={latest_log}") + log("INFO", f'event_file={config["event_file"]}') + + text = readTextSafe(latest_log) + lines, self_state = parseVrchatEvents(text, config) + + output_text = "\n".join(lines) if lines else "[INFO] 対象イベントなし" + output_path = appendRuntimeLog("VRC LOG", output_text) + + if notify_changed: + global _last_sent_output + if output_text != _last_sent_output: + try: + ok = sendWebhook(output_text) + if ok: + log("INFO", "Discord webhook sent") + _last_sent_output = output_text + else: + log("ERROR", "Discord webhook send failed") + except Exception as e: + log("ERROR", f"Discord webhook failed detail={e}") + + log("INFO", f"result appended={output_path}") + for line in lines or ["[INFO] 対象イベントなし"]: + print(line, flush=True) + + return output_path + + +def _readNewText(path, offset): + for encoding in ("utf-8", "utf-8-sig", "cp932", "shift_jis"): + try: + with path.open("r", encoding=encoding, errors="replace") as file: + file.seek(offset) + return file.read(), file.tell() + except Exception: + continue + + raise RuntimeError(f"ログファイルを読めません: {path}") + + +def _watchVrchatLogChanges(stop_event, label, on_relevant_change): + log("ACTION", f"{label} realtime watcher started") + + current_log = None + offset = 0 + + while not stop_event.is_set(): + try: + latest_log = findLatestVrchatLog() + latest_size = latest_log.stat().st_size + + if current_log is None: + current_log = latest_log + offset = latest_size + log("INFO", f"{label} watching file={current_log}") + continue + + if latest_log != current_log: + current_log = latest_log + offset = 0 + log("INFO", f"{label} watching new file={current_log}") + + if latest_size < offset: + offset = 0 + + if latest_size > offset: + text, offset = _readNewText(latest_log, offset) + + if RELEVANT_LOG_PATTERN.search(text): + on_relevant_change(text) + except Exception as e: + log("ERROR", f"{label} realtime watcher failed detail={e}") + + stop_event.wait(REALTIME_IDLE_SECONDS) + + log("INFO", f"{label} realtime watcher stopped") + + +def _monitor_loop(): + def on_relevant_change(_text): + collectVrchatLog(notify_changed=True) + + _watchVrchatLogChanges( + _monitor_stop_event, + "VRC log monitor", + on_relevant_change, + ) + + +def _self_monitor_loop(): + def on_relevant_change(text): + try: + config = loadVrcLogConfig() + self_state = extractLatestSelfLogState( + text, + config.get("self_name", ""), + ) + + global _last_self_state + if self_state and self_state != _last_self_state: + if self_state == "joined": + setDiscordMuted(True) + elif self_state == "left": + setDiscordMuted(False) + _last_self_state = self_state + except Exception as e: + log("ERROR", f"VRC self monitor failed detail={e}") + + _watchVrchatLogChanges( + _self_monitor_stop_event, + "VRC self monitor", + on_relevant_change, + ) + + +def startVrcLogMonitor(_interval_seconds=None): + global _monitor_thread + + if _monitor_thread and _monitor_thread.is_alive(): + log("INFO", "VRC log monitor already running") + return + + _monitor_stop_event.clear() + _monitor_thread = threading.Thread( + target=_monitor_loop, + daemon=True, + ) + _monitor_thread.start() + + +def startSelfMonitor(_interval_seconds=None): + global _self_monitor_thread + + if _self_monitor_thread and _self_monitor_thread.is_alive(): + log("INFO", "VRC self monitor already running") + return + + _self_monitor_stop_event.clear() + _self_monitor_thread = threading.Thread( + target=_self_monitor_loop, + daemon=True, + ) + _self_monitor_thread.start() + + +def stopVrcLogMonitor(): + _monitor_stop_event.set() + + +def stopSelfMonitor(): + _self_monitor_stop_event.set() diff --git a/VRWT_Tool/VRC_OSC/src/vrc_log/member_name_matcher.py b/VRWT_Tool/VRC_OSC/src/vrc_log/member_name_matcher.py new file mode 100644 index 0000000..efe2a34 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/vrc_log/member_name_matcher.py @@ -0,0 +1,623 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +目的: +VRChat表示名とTOML登録名を安全な順番で紐づける + +特徴: +- 完全一致を先に確定して候補から除外 +- 正規化一致を確定して候補から除外 +- 残った候補だけを high confidence fuzzy 判定 +- guest のみ low confidence fuzzy で再選別 +- pending / ambiguous / unmatched 判定 +- 記号除去 +- 全角半角統一 +- 小文字化 +- 日本語 -> ローマ字変換 +- rapidfuzz による類似度 +- テストケース付き + +前提: +pip install rapidfuzz pykakasi + +期待結果: +- 100%一致した名前を fuzzy 候補に残さない +- 似た名前同士の誤リンクを減らす +""" + +import re +import sys +import unicodedata +from dataclasses import dataclass, field +from typing import Dict, Iterable, List, Optional, Tuple + +try: + from rapidfuzz import fuzz +except Exception as e: + if __name__ == "__main__": + print(f"[FATAL] rapidfuzz import failed: {e}") + input("Press Enter to exit...") + sys.exit(1) + raise + +try: + from pykakasi import kakasi +except Exception as e: + if __name__ == "__main__": + print(f"[FATAL] pykakasi import failed: {e}") + input("Press Enter to exit...") + sys.exit(1) + raise + + +# ========================= +# 設定 +# ========================= + +AUTO_LINK_SCORE = 95 +LOW_CONFIDENCE_SCORE = 80 +AMBIGUOUS_DIFF = 10 +LOW_CONFIDENCE_MAX_REMAINING = 3 +WEAK_MATCH_SCORE = 50 + +STATUS_MATCHED = "matched" +STATUS_MATCHED_LOW_CONFIDENCE = "matched_low_confidence" +STATUS_PENDING = "pending" +STATUS_AMBIGUOUS = "ambiguous" +STATUS_UNMATCHED = "unmatched" +STATUS_UNKNOWN_MEMBER = "unknown_member" + +ROLE_GUEST = "guest" +ROLE_SELF = "self" +ROLE_STAFF = "staff" + +TEST_GUESTS = [ + "okada", + "tanaka", + "yamada", + "messykenty", +] + +TEST_CURRENT_USERS = [ + "岡田", + "ikada", + "okada_", + "OKADA", + "tanaka!!!", + "田中", + "やまだ", + "messy_kenty", + "messy.kenty", + "MESSYKENTY", + "山田", +] + + +@dataclass(frozen=True) +class TomlMember: + name: str + role: str = ROLE_GUEST + + +@dataclass +class MatchResult: + toml_name: str + role: str + status: str + vrchat_name: Optional[str] = None + score: Optional[float] = None + second_score: Optional[float] = None + reason: str = "" + candidates: List[Tuple[str, float]] = field(default_factory=list) + + +# ========================= +# ローマ字変換 +# ========================= + +kks = kakasi() + + +def jp_to_romaji(text: str) -> str: + try: + converted = kks.convert(text) + + result = [] + for item in converted: + result.append(item["hepburn"]) + + return "".join(result) + + except Exception as e: + print(f"[ERROR] romaji convert failed: {text} : {e}") + return text + + +# ========================= +# 正規化 +# ========================= + +def normalize_name(name: str) -> str: + """ + 正規化内容: + - 全角半角統一 + - 小文字化 + - 日本語→ローマ字 + - 記号除去 + """ + + # Unicode normalize + name = unicodedata.normalize("NFKC", name) + + # 小文字化 + name = name.lower() + + # 日本語→ローマ字 + name = jp_to_romaji(name) + + # 記号除去 + name = re.sub(r"[^a-z0-9]", "", name) + + return name.strip() + + +# ========================= +# 類似候補検索 +# ========================= + +def find_candidates( + target_guest: str, + current_users: List[str], +) -> List[Tuple[str, float]]: + + normalized_target = normalize_name(target_guest) + + normalized_map = {} + + for user in current_users: + normalized_map[user] = normalize_name(user) + + results = [] + + for original_name, normalized_name in normalized_map.items(): + + score = fuzz.ratio( + normalized_target, + normalized_name, + ) + + results.append((original_name, score)) + + results.sort(key=lambda x: x[1], reverse=True) + + return results + + +def _to_toml_member(member) -> TomlMember: + if isinstance(member, TomlMember): + return member + + if isinstance(member, str): + return TomlMember(name=member) + + if isinstance(member, dict): + return TomlMember( + name=str(member.get("name", "")), + role=str(member.get("role", ROLE_GUEST)), + ) + + name = getattr(member, "name", "") + role = getattr(member, "role", ROLE_GUEST) + + return TomlMember( + name=str(name), + role=str(role), + ) + + +def _make_candidates( + toml_name: str, + current_users: Iterable[str], +) -> List[Tuple[str, float]]: + normalized_toml = normalize_name(toml_name) + results = [] + + for user in current_users: + score = fuzz.ratio( + normalized_toml, + normalize_name(user), + ) + results.append((user, score)) + + results.sort(key=lambda x: x[1], reverse=True) + + return results + + +def _classify_remaining( + member: TomlMember, + current_users: Iterable[str], +) -> MatchResult: + candidates = _make_candidates( + member.name, + current_users, + ) + + if not candidates: + return MatchResult( + toml_name=member.name, + role=member.role, + status=STATUS_UNMATCHED, + reason="no_candidate", + ) + + top_name, top_score = candidates[0] + second_score = candidates[1][1] if len(candidates) >= 2 else 0 + diff = top_score - second_score + + if top_score < WEAK_MATCH_SCORE: + status = STATUS_UNMATCHED + reason = "weak_candidate" + elif diff < AMBIGUOUS_DIFF: + status = STATUS_AMBIGUOUS + reason = "close_candidates" + else: + status = STATUS_PENDING + reason = "below_auto_link_threshold" + + return MatchResult( + toml_name=member.name, + role=member.role, + status=status, + vrchat_name=top_name, + score=top_score, + second_score=second_score, + reason=reason, + candidates=candidates, + ) + + +def _best_fuzzy_match( + member: TomlMember, + current_users: Iterable[str], + min_score: float, +) -> Optional[MatchResult]: + candidates = _make_candidates( + member.name, + current_users, + ) + + if not candidates: + return None + + top_name, top_score = candidates[0] + second_score = candidates[1][1] if len(candidates) >= 2 else 0 + + if ( + top_score >= min_score + and top_score - second_score >= AMBIGUOUS_DIFF + ): + return MatchResult( + toml_name=member.name, + role=member.role, + status=STATUS_MATCHED, + vrchat_name=top_name, + score=top_score, + second_score=second_score, + reason="fuzzy", + candidates=candidates, + ) + + return None + + +def _apply_fuzzy_round( + remaining_members: Dict[str, TomlMember], + remaining_users: set, + min_score: float, + status: str, + reason: str, + eligible_roles: Optional[set] = None, +) -> List[MatchResult]: + results = [] + + while True: + possible_matches = [] + + for key, member in remaining_members.items(): + if ( + eligible_roles is not None + and member.role not in eligible_roles + ): + continue + + match = _best_fuzzy_match( + member, + remaining_users, + min_score, + ) + + if match is None: + continue + + possible_matches.append((key, match)) + + if not possible_matches: + break + + possible_matches.sort( + key=lambda x: ( + x[1].score if x[1].score is not None else 0, + (x[1].score or 0) - (x[1].second_score or 0), + ), + reverse=True, + ) + + key, match = possible_matches[0] + match.status = status + match.reason = reason + + results.append(match) + remaining_members.pop(key) + remaining_users.remove(match.vrchat_name) + + return results + + +def match_username_sets( + toml_members, + current_users: List[str], +) -> Tuple[List[MatchResult], List[str]]: + """ + TOML登録名とVRChat現在ユーザーを段階的に1対1照合する。 + + 戻り値: + - TOML側の照合結果 + - TOMLに紐づかなかったVRChat名 + """ + + members = [_to_toml_member(member) for member in toml_members] + remaining_members = { + f"{idx}:{member.name}": member + for idx, member in enumerate(members) + } + remaining_users = set(current_users) + results = [] + + # 1. 完全一致: fuzzy判定せず即確定して候補から除外 + for key, member in list(remaining_members.items()): + if member.name in remaining_users: + results.append( + MatchResult( + toml_name=member.name, + role=member.role, + status=STATUS_MATCHED, + vrchat_name=member.name, + score=100, + second_score=None, + reason="exact", + ) + ) + remaining_members.pop(key) + remaining_users.remove(member.name) + + # 2. 正規化一致: 一意に対応できるものだけを確定して除外 + normalized_members: Dict[str, List[str]] = {} + normalized_users: Dict[str, List[str]] = {} + + for key, member in remaining_members.items(): + normalized_members.setdefault( + normalize_name(member.name), + [], + ).append(key) + + for user in remaining_users: + normalized_users.setdefault( + normalize_name(user), + [], + ).append(user) + + for normalized_name, member_keys in normalized_members.items(): + users = normalized_users.get(normalized_name, []) + + if len(member_keys) != 1 or len(users) != 1: + continue + + key = member_keys[0] + user = users[0] + member = remaining_members[key] + + results.append( + MatchResult( + toml_name=member.name, + role=member.role, + status=STATUS_MATCHED, + vrchat_name=user, + score=100, + second_score=None, + reason="normalized", + ) + ) + remaining_members.pop(key) + remaining_users.remove(user) + + # 3. 高信頼fuzzy: 残った候補だけで、確定ごとに候補集合を縮める + results.extend( + _apply_fuzzy_round( + remaining_members, + remaining_users, + AUTO_LINK_SCORE, + STATUS_MATCHED, + "high_confidence_fuzzy", + ) + ) + + # 4. 低信頼guest再選別: guestのみ、候補集合が少ない場合だけ + if len(remaining_members) <= LOW_CONFIDENCE_MAX_REMAINING: + results.extend( + _apply_fuzzy_round( + remaining_members, + remaining_users, + LOW_CONFIDENCE_SCORE, + STATUS_MATCHED_LOW_CONFIDENCE, + "guest_low_confidence_fuzzy", + eligible_roles={ROLE_GUEST}, + ) + ) + + # 5. 残りは無理に確定しない + for member in remaining_members.values(): + results.append( + _classify_remaining( + member, + remaining_users, + ) + ) + + unknown_users = sorted(remaining_users) + + return results, unknown_users + + +# ========================= +# 判定 +# ========================= + +def evaluate_guest_match( + guest_name: str, + current_users: List[str], +): + + print("") + print("=" * 60) + print(f"[CHECK] guest={guest_name}") + + normalized_guest = normalize_name(guest_name) + + print(f"[INFO] normalized_guest={normalized_guest}") + + candidates = find_candidates( + guest_name, + current_users, + ) + + if not candidates: + print("[ERROR] no candidates") + return + + print("") + print("[CHECK] candidate list") + + for idx, (name, score) in enumerate(candidates[:5], start=1): + print(f" {idx}. {name:<20} score={score:.2f}") + + top_name, top_score = candidates[0] + + second_score = 0 + + if len(candidates) >= 2: + second_score = candidates[1][1] + + diff = top_score - second_score + + print("") + print(f"[INFO] top_score={top_score:.2f}") + print(f"[INFO] second_score={second_score:.2f}") + print(f"[INFO] diff={diff:.2f}") + + # 自動確定 + if ( + top_score >= AUTO_LINK_SCORE + and diff >= AMBIGUOUS_DIFF + ): + print("") + print("[INFO] AUTO LINK SAFE") + print(f"[INFO] linked => {top_name}") + return + + # 曖昧 + print("") + print("[WARN] ambiguous match") + print("[WARN] auto link disabled") + + print("") + print("[ACTION] manual review required") + + +# ========================= +# main +# ========================= + +def main(): + + print("[CHECK] normalize preview") + + for user in TEST_CURRENT_USERS: + print( + f"{user:<20}" + f" => " + f"{normalize_name(user)}" + ) + + print("") + print("[CHECK] username sync ordered matching test") + + results, unknown_users = match_username_sets( + TEST_GUESTS, + TEST_CURRENT_USERS, + ) + + for result in results: + if result.vrchat_name is None: + print( + f"{result.toml_name:<20}" + f" => " + f"{result.status:<22}" + f" reason={result.reason}" + ) + continue + + score_text = "" + if result.score is not None: + score_text = f" score={result.score:.2f}" + + second_text = "" + if result.second_score is not None: + second_text = f" second={result.second_score:.2f}" + + print( + f"{result.toml_name:<20}" + f" => " + f"{result.vrchat_name:<20}" + f" {result.status:<22}" + f" reason={result.reason}" + f"{score_text}" + f"{second_text}" + ) + + for user in unknown_users: + print( + f"{user:<20}" + f" => " + f"{STATUS_UNKNOWN_MEMBER}" + ) + + print("") + print("[INFO] completed") + + +if __name__ == "__main__": + + print("[CHECK] start") + + answer = input( + "[ACTION] run fuzzy test? (yes/no): " + ).strip().lower() + + if answer != "yes": + print("[FATAL] cancelled") + input("Press Enter to exit...") + sys.exit(1) + + main()