Clean project layout and fix release workflow
Some checks failed
build-windows-exe / build (push) Failing after 1m13s

This commit is contained in:
messypy
2026-07-27 23:01:24 +09:00
parent 3ff33e40e3
commit dd165d9301
80 changed files with 2908 additions and 5661 deletions

View File

@@ -25,13 +25,11 @@ jobs:
- name: Test - name: Test
shell: bash shell: bash
run: | run: |
cd VRWT_Tool/VRC_OSC
GO111MODULE=on go test ./... GO111MODULE=on go test ./...
- name: Build executables - name: Build executables
shell: bash shell: bash
run: | run: |
cd VRWT_Tool/VRC_OSC
mkdir -p dist mkdir -p dist
VERSION="${GITHUB_REF_NAME}" VERSION="${GITHUB_REF_NAME}"
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
@@ -43,7 +41,6 @@ jobs:
- name: Package executables - name: Package executables
shell: bash shell: bash
run: | run: |
cd VRWT_Tool/VRC_OSC
cp config/config.example.toml dist/config.example.toml cp config/config.example.toml dist/config.example.toml
cp config/secrets.example.toml dist/secrets.example.toml cp config/secrets.example.toml dist/secrets.example.toml
cp config/guests.example.txt dist/guests.example.txt cp config/guests.example.txt dist/guests.example.txt
@@ -73,7 +70,7 @@ jobs:
"${api}/repos/${owner_repo}/releases") "${api}/repos/${owner_repo}/releases")
release_id=$(printf '%s' "${release_json}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') release_id=$(printf '%s' "${release_json}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
fi fi
asset_path="VRWT_Tool/VRC_OSC/dist/vrc_osc-windows.zip" asset_path="dist/vrc_osc-windows.zip"
curl -fsSL -X POST \ curl -fsSL -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${asset_path}" \ -F "attachment=@${asset_path}" \

19
.gitignore vendored
View File

@@ -5,9 +5,28 @@ config/config.toml
config/secrets.toml config/secrets.toml
config/guests.txt config/guests.txt
runtime/ runtime/
dist/
.gocache/
.gomodcache/
.gotelemetry/
.gotmp/
.telemetry/
.appdata/
.tmp/
.vrchat_cookie.txt .vrchat_cookie.txt
src/.vrchat_cookie.txt src/.vrchat_cookie.txt
*.exe
*.exe~
current-*.png
desktop-shot.png
gui-screenshot*.png
gui-settings-shot.png
gui-window-*.png
pw_*.png
screen-check.png
window-shot.png
*.bak *.bak
*.bak_* *.bak_*
backup_*/ backup_*/

1
.goenv Normal file
View File

@@ -0,0 +1 @@
GOTELEMETRY=off

1
EXE.md
View File

@@ -27,6 +27,7 @@ Release zip を展開したフォルダに以下を置きます。
- `vrc_osc.exe` - `vrc_osc.exe`
- `vrc_osc_launcher.exe` - `vrc_osc_launcher.exe`
- `vrc_osc_gui.exe`
- `vrwt_tool.exe` - `vrwt_tool.exe`
- `config\` - `config\`
- `runtime\` - `runtime\`

View File

@@ -1,50 +0,0 @@
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

View File

@@ -1,13 +0,0 @@
__pycache__/
*.py[cod]
config/config.toml
config/secrets.toml
config/guests.txt
runtime/
.vrchat_cookie.txt
src/.vrchat_cookie.txt
*.bak
*.bak_*
backup_*/

View File

@@ -1,7 +0,0 @@
$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

View File

@@ -1,42 +0,0 @@
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()
}

View File

@@ -1,198 +0,0 @@
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 := `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="3">
<title>VRTW_Tool</title>
<style>
body{font-family:sans-serif;background:#111;color:#eee;margin:20px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.card{background:#1b1b1b;border:1px solid #333;border-radius:12px;padding:16px}
table{width:100%;border-collapse:collapse}
td,th{border-bottom:1px solid #333;padding:6px;text-align:left}
th{cursor:pointer;user-select:none}
th:hover{color:#8ff}
.on{color:#5ff28f}.off{color:#ff8a8a}
</style>
</head>
<body>
<h1>VRTW_Tool</h1>
<div id="root">loading...</div>
<script>
const sortKey = 'vrc_osc_gui_sort';
const defaultSort = { key: 'name', dir: 1 };
let sortState = (() => {
try {
const raw = localStorage.getItem(sortKey);
return raw ? JSON.parse(raw) : defaultSort;
} catch (_) {
return defaultSort;
}
})();
function setSort(key) {
if (sortState.key === key) {
sortState.dir = sortState.dir * -1;
} else {
sortState = { key: key, dir: 1 };
}
localStorage.setItem(sortKey, JSON.stringify(sortState));
load();
}
function compareGuests(a, b) {
if (sortState.key === 'name') {
const v = a.name.localeCompare(b.name, 'ja');
return v * sortState.dir;
}
if (sortState.key === 'presence') {
const av = Number(a.present);
const bv = Number(b.present);
if (av !== bv) {
return (bv - av) * sortState.dir;
}
return a.name.localeCompare(b.name, 'ja');
}
if (sortState.key === 'status') {
const av = Number(!a.present);
const bv = Number(!b.present);
if (av !== bv) {
return (av - bv) * sortState.dir;
}
return a.name.localeCompare(b.name, 'ja');
}
return a.name.localeCompare(b.name, 'ja');
}
async function load(){
const r = await fetch('/api/state');
const j = await r.json();
const guests = [...j.guests].sort(compareGuests);
const presentCount = guests.filter(g => g.present).length;
const rows = guests.map(g => {
const status = g.present ? '在席' : (g.absent_for || '1分未満');
return '<tr><td>' + esc(g.name) + '</td><td>' + esc(status) + '</td></tr>';
}).join('');
document.getElementById('root').innerHTML =
'<div class="grid">' +
'<div class="card">' +
'<h2>状態</h2>' +
'<p>World: ' + esc(j.world || '') + '</p>' +
'<p>Discord: <span class="' + (j.discord_muted ? 'on' : 'off') + '">' + (j.discord_muted ? 'Muted' : 'Unmuted') + '</span></p>' +
'<p>OCR: ' + esc(j.ocr || '') + '</p>' +
'<p>翻訳: ' + esc(j.translate || '未実装') + '</p>' +
'</div>' +
'<div class="card">' +
'<h2>ゲスト (' + presentCount + '/' + j.guest_count + ')</h2>' +
'<table>' +
'<tr>' +
'<th data-sort="name">名前' + sortMark('name') + '</th>' +
'<th data-sort="presence">在席' + sortMark('presence') + '</th>' +
'</tr>' +
rows +
'</table>' +
'</div>' +
'</div>';
document.querySelectorAll('th[data-sort]').forEach(function(el) {
el.style.cursor = 'pointer';
el.onclick = function() {
setSort(el.getAttribute('data-sort'));
};
});
}
function sortMark(key) {
if (sortState.key !== key) return '';
return sortState.dir > 0 ? ' ▲' : ' ▼';
}
function esc(s){ return (s || '').replace(/[&<>"]/g, function(m){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m]; }); }
setInterval(load, 3000);
load();
</script>
</body>
</html>`
_, _ = 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()
}
}

View File

@@ -1,40 +0,0 @@
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)
}
}

View File

@@ -1,33 +0,0 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"vrc_osc_go/internal/consenttool"
)
func main() {
var cfgPath string
var mode string
flag.StringVar(&cfgPath, "config", "config/consent.toml", "path to consent tool config")
flag.StringVar(&mode, "mode", "", "generate-json or extract-log")
flag.Parse()
if mode == "" && flag.NArg() > 0 {
mode = flag.Arg(0)
}
if mode == "" {
fmt.Fprintln(os.Stderr, "missing mode: generate-json or extract-log")
os.Exit(2)
}
if err := consenttool.Run(cfgPath, mode); err != nil {
log.SetOutput(os.Stderr)
log.Fatalf("vrwt_tool: %v", err)
}
}

View File

@@ -1,22 +0,0 @@
[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

View File

@@ -1,3 +0,0 @@
GuestUserA
GuestUserB
GuestUserC

View File

@@ -1,6 +0,0 @@
[discord]
webhook_url = ""
[vrchat]
username = ""
password = ""

View File

@@ -1,4 +0,0 @@
module vrc_osc_go
go 1.22

View File

@@ -1,75 +0,0 @@
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
}
}

View File

@@ -1,84 +0,0 @@
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
}

View File

@@ -1,89 +0,0 @@
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)
}
}

View File

@@ -1,136 +0,0 @@
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)
}

View File

@@ -1,30 +0,0 @@
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
}

View File

@@ -1,48 +0,0 @@
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
}

View File

@@ -1,69 +0,0 @@
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
}

View File

@@ -1,89 +0,0 @@
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),
})
}

View File

@@ -1,345 +0,0 @@
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()
}

View File

@@ -1,6 +0,0 @@
package buildinfo
var (
Version = "dev"
BuildTime = "unknown"
)

View File

@@ -1,16 +0,0 @@
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() }

View File

@@ -1,146 +0,0 @@
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
}

View File

@@ -1,144 +0,0 @@
package consenttool
import (
"bufio"
"os"
"path/filepath"
"strconv"
"strings"
)
type Config struct {
Consent ConsentConfig
Log LogConfig
}
type ConsentConfig struct {
InputCSV string
OutputJSON string
DisplayFields []string
ConsentFields []string
ConsentValues []string
NormalizeCase bool
AllowDuplicates bool
}
type LogConfig struct {
InputPath string
OutputPath string
IncludeRegex string
KeepNonMatch bool
}
func LoadConfig(path string) (*Config, error) {
cfg := &Config{
Consent: ConsentConfig{
InputCSV: "config/consent.csv",
OutputJSON: "config/consent_list.json",
DisplayFields: []string{"display_name", "vrchat_name", "name"},
ConsentFields: []string{"consent", "agree", "agreed", "checked"},
ConsentValues: []string{"1", "true", "yes", "y", "on", "checked"},
NormalizeCase: false,
AllowDuplicates: false,
},
Log: LogConfig{
InputPath: "runtime/output_log.txt",
OutputPath: "runtime/research_log.txt",
IncludeRegex: `\[[A-Z0-9:_ -]+\]`,
KeepNonMatch: false,
},
}
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
var section string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
line = strings.TrimPrefix(line, "\ufeff")
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
section = strings.Trim(line, "[]")
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
val = strings.Trim(val, "\"'")
switch section {
case "consent":
switch key {
case "input_csv":
cfg.Consent.InputCSV = val
case "output_json":
cfg.Consent.OutputJSON = val
case "display_fields":
cfg.Consent.DisplayFields = parseList(val)
case "consent_fields":
cfg.Consent.ConsentFields = parseList(val)
case "consent_values":
cfg.Consent.ConsentValues = parseList(val)
case "normalize_case":
cfg.Consent.NormalizeCase = parseBool(val)
case "allow_duplicates":
cfg.Consent.AllowDuplicates = parseBool(val)
}
case "log":
switch key {
case "input_path":
cfg.Log.InputPath = val
case "output_path":
cfg.Log.OutputPath = val
case "include_regex":
cfg.Log.IncludeRegex = val
case "keep_non_match":
cfg.Log.KeepNonMatch = parseBool(val)
}
}
}
return cfg, scanner.Err()
}
func parseList(value string) []string {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "[")
value = strings.TrimSuffix(value, "]")
if value == "" {
return nil
}
items := strings.Split(value, ",")
out := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
item = strings.Trim(item, "\"'")
if item != "" {
out = append(out, item)
}
}
return out
}
func parseBool(value string) bool {
n, err := strconv.ParseBool(strings.TrimSpace(value))
return err == nil && n
}
func resolvePath(baseDir, value string) string {
if value == "" {
return ""
}
if filepath.IsAbs(value) {
return value
}
return filepath.Clean(filepath.Join(baseDir, value))
}

View File

@@ -1,183 +0,0 @@
package consenttool
import (
"encoding/csv"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
)
func Run(configPath string, mode string) error {
cfg, err := LoadConfig(configPath)
if err != nil {
return err
}
baseDir := filepath.Dir(configPath)
switch mode {
case "generate-json":
return generateJSON(cfg, baseDir)
case "extract-log":
return extractLog(cfg, baseDir)
default:
return fmt.Errorf("unknown mode %q", mode)
}
}
func generateJSON(cfg *Config, baseDir string) error {
inputPath := resolvePath(baseDir, cfg.Consent.InputCSV)
outputPath := resolvePath(baseDir, cfg.Consent.OutputJSON)
rows, err := readCSVRows(inputPath)
if err != nil {
return err
}
names := collectConsentedNames(rows, cfg.Consent)
body, err := json.MarshalIndent(names, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return err
}
return os.WriteFile(outputPath, append(body, '\n'), 0o644)
}
func extractLog(cfg *Config, baseDir string) error {
inputPath := resolvePath(baseDir, cfg.Log.InputPath)
outputPath := resolvePath(baseDir, cfg.Log.OutputPath)
data, err := os.ReadFile(inputPath)
if err != nil {
return err
}
re, err := regexp.Compile(cfg.Log.IncludeRegex)
if err != nil {
return err
}
var kept []string
for _, line := range strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") {
if re.MatchString(line) {
kept = append(kept, line)
continue
}
if cfg.Log.KeepNonMatch {
kept = append(kept, line)
}
}
body := strings.Join(kept, "\n")
if body != "" {
body += "\n"
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return err
}
return os.WriteFile(outputPath, []byte(body), 0o644)
}
func readCSVRows(path string) ([]map[string]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
r := csv.NewReader(file)
headers, err := r.Read()
if err != nil {
return nil, err
}
for i, header := range headers {
headers[i] = strings.TrimPrefix(strings.TrimSpace(header), "\ufeff")
}
var rows []map[string]string
for {
record, err := r.Read()
if err != nil {
if err == io.EOF {
break
}
return nil, err
}
row := make(map[string]string, len(headers))
for i, header := range headers {
if i < len(record) {
row[strings.TrimSpace(header)] = strings.TrimPrefix(strings.TrimSpace(record[i]), "\ufeff")
}
}
rows = append(rows, row)
}
return rows, nil
}
func collectConsentedNames(rows []map[string]string, cfg ConsentConfig) []string {
displayFields := cfg.DisplayFields
if len(displayFields) == 0 {
displayFields = []string{"display_name", "vrchat_name", "name"}
}
consentFields := cfg.ConsentFields
if len(consentFields) == 0 {
consentFields = []string{"consent", "agree", "agreed", "checked"}
}
accepted := make(map[string]struct{})
var names []string
for _, row := range rows {
if !rowIsConsented(row, consentFields, cfg.ConsentValues) {
continue
}
name := firstNonEmpty(row, displayFields)
if name == "" {
continue
}
key := name
if cfg.NormalizeCase {
key = strings.ToLower(key)
}
if !cfg.AllowDuplicates {
if _, ok := accepted[key]; ok {
continue
}
accepted[key] = struct{}{}
}
names = append(names, name)
}
return names
}
func rowIsConsented(row map[string]string, fields []string, values []string) bool {
if len(values) == 0 {
values = []string{"1", "true", "yes", "y", "on", "checked"}
}
allowed := make(map[string]struct{}, len(values))
for _, v := range values {
allowed[strings.ToLower(strings.TrimSpace(v))] = struct{}{}
}
for _, field := range fields {
if v, ok := row[field]; ok {
if _, ok := allowed[strings.ToLower(strings.TrimSpace(v))]; ok {
return true
}
}
}
return false
}
func firstNonEmpty(row map[string]string, fields []string) string {
for _, field := range fields {
if v := strings.TrimSpace(row[field]); v != "" {
return v
}
}
return ""
}

View File

@@ -1,44 +0,0 @@
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))
}
}

View File

@@ -1,141 +0,0 @@
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
}

View File

@@ -1,106 +0,0 @@
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"
}

View File

@@ -1,125 +0,0 @@
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}
}

View File

@@ -1,238 +0,0 @@
# 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`
秘密情報を含む可能性があるものはコミットしない。

View File

@@ -1,10 +0,0 @@
deep-translator
paddleocr
pillow
pyautogui
pygetwindow
pykakasi
pyperclip
python-osc
rapidfuzz
vrchatapi

View File

@@ -1,114 +0,0 @@
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()

View File

@@ -1,13 +0,0 @@
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()

View File

@@ -1,130 +0,0 @@
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(),
}

View File

@@ -1,6 +0,0 @@
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"

View File

@@ -1,19 +0,0 @@
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

View File

@@ -1,203 +0,0 @@
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

View File

@@ -1,186 +0,0 @@
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)

View File

@@ -1,85 +0,0 @@
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", "終了しました")

View File

@@ -1,167 +0,0 @@
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()

View File

@@ -1,124 +0,0 @@
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()

View File

@@ -1,21 +0,0 @@
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()

View File

@@ -1,54 +0,0 @@
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)

View File

@@ -1,134 +0,0 @@
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()

View File

@@ -1,102 +0,0 @@
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

View File

@@ -1,563 +0,0 @@
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()

View File

@@ -1,623 +0,0 @@
#!/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_",
"",
"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()

View File

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 144 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -3,12 +3,14 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
"unsafe" "unsafe"
@@ -59,6 +61,43 @@ type historyQuery struct {
HasTo bool HasTo bool
} }
type joinLeaveEventSnapshot struct {
At time.Time
Kind string
Name string
Count int
}
type historyExportRequest struct {
Day time.Time
Selected map[string]bool
Rows []historyDetailRow
ExportDir string
IncludeTime bool
IncludeWorld bool
IncludeJoinLeave bool
CustomEnabled bool
Custom string
RegexText string
Query historyQuery
}
type historyExportResult struct {
Path string
Err error
Day time.Time
SelectedCount int
}
var historyEventsCacheMu sync.Mutex
var historyEventsCache struct {
jsonMod time.Time
jsonSize int64
logMod time.Time
logSize int64
items []historyEventRecord
}
func ensureHistoryState(app *guiApp) { func ensureHistoryState(app *guiApp) {
if app == nil { if app == nil {
return return
@@ -221,9 +260,37 @@ func historyVisitKey(label string, start, end time.Time, worldID, instanceID str
return key return key
} }
func historyEventsFromLog() []historyEventRecord { func historyEventsFromSnapshot() []historyEventRecord {
p := filepath.Join(runtimeDir(), "join_leave.log") jsonPath := filepath.Join(runtimeDir(), "join_leave_events.json")
b, err := os.ReadFile(p) logPath := filepath.Join(runtimeDir(), "join_leave.log")
jsonInfo, jsonErr := os.Stat(jsonPath)
logInfo, logErr := os.Stat(logPath)
if jsonErr == nil || logErr == nil {
historyEventsCacheMu.Lock()
if historyEventsCache.items != nil &&
((jsonErr == nil && historyEventsCache.jsonMod.Equal(jsonInfo.ModTime()) && historyEventsCache.jsonSize == jsonInfo.Size()) || jsonErr != nil) &&
((logErr == nil && historyEventsCache.logMod.Equal(logInfo.ModTime()) && historyEventsCache.logSize == logInfo.Size()) || logErr != nil) {
out := append([]historyEventRecord(nil), historyEventsCache.items...)
historyEventsCacheMu.Unlock()
return out
}
historyEventsCacheMu.Unlock()
}
if b, err := os.ReadFile(jsonPath); err == nil && len(b) > 0 {
if events := decodeHistoryEventsSnapshot(b); len(events) > 0 {
if jsonErr == nil {
historyEventsCacheMu.Lock()
historyEventsCache.jsonMod = jsonInfo.ModTime()
historyEventsCache.jsonSize = jsonInfo.Size()
historyEventsCache.logMod = time.Time{}
historyEventsCache.logSize = 0
historyEventsCache.items = append([]historyEventRecord(nil), events...)
historyEventsCacheMu.Unlock()
}
return events
}
}
b, err := os.ReadFile(logPath)
if err != nil || len(b) == 0 { if err != nil || len(b) == 0 {
return nil return nil
} }
@@ -251,6 +318,76 @@ func historyEventsFromLog() []historyEventRecord {
Raw: line, Raw: line,
}) })
} }
if logErr == nil {
historyEventsCacheMu.Lock()
historyEventsCache.jsonMod = time.Time{}
historyEventsCache.jsonSize = 0
historyEventsCache.logMod = logInfo.ModTime()
historyEventsCache.logSize = logInfo.Size()
historyEventsCache.items = append([]historyEventRecord(nil), out...)
historyEventsCacheMu.Unlock()
}
return out
}
func decodeHistoryEventsSnapshot(b []byte) []historyEventRecord {
type snapshot struct {
UpdatedAt time.Time `json:"updated_at"`
Events []struct {
At time.Time `json:"at"`
Kind string `json:"kind"`
Name string `json:"name"`
Count int `json:"count"`
} `json:"events"`
}
var snap snapshot
if err := json.Unmarshal(b, &snap); err == nil && len(snap.Events) > 0 {
out := make([]historyEventRecord, 0, len(snap.Events))
for _, ev := range snap.Events {
out = append(out, historyEventRecord{
At: ev.At,
Kind: ev.Kind,
Name: ev.Name,
Raw: fmt.Sprintf("[%s] %s %s", ev.At.Format("15:04"), ev.Kind, ev.Name),
})
}
return dedupeHistoryEventRecords(out)
}
var flat []struct {
At time.Time `json:"at"`
Kind string `json:"kind"`
Name string `json:"name"`
Count int `json:"count"`
}
if err := json.Unmarshal(b, &flat); err == nil && len(flat) > 0 {
out := make([]historyEventRecord, 0, len(flat))
for _, ev := range flat {
out = append(out, historyEventRecord{
At: ev.At,
Kind: ev.Kind,
Name: ev.Name,
Raw: fmt.Sprintf("[%s] %s %s", ev.At.Format("15:04"), ev.Kind, ev.Name),
})
}
return dedupeHistoryEventRecords(out)
}
return nil
}
func dedupeHistoryEventRecords(events []historyEventRecord) []historyEventRecord {
if len(events) <= 1 {
return events
}
seen := make(map[string]struct{}, len(events))
out := make([]historyEventRecord, 0, len(events))
for _, ev := range events {
key := ev.At.UTC().Format(time.RFC3339Nano) + "|" + ev.Kind + "|" + strings.TrimSpace(ev.Name)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, ev)
}
return out return out
} }
@@ -259,9 +396,13 @@ func historyVisitsForDay(app *guiApp, day time.Time) []historyDetailRow {
if !historyInQueryRange(day, q) { if !historyInQueryRange(day, q) {
return nil return nil
} }
dayStart, dayEnd := historyDayBounds(day) events := historyEventsFromSnapshot()
events := historyEventsFromLog()
visits := historyVisitSource() visits := historyVisitSource()
return historyVisitsForDayFromData(app, day, visits, events, q)
}
func historyVisitsForDayFromData(app *guiApp, day time.Time, visits []historyVisitRecord, events []historyEventRecord, q historyQuery) []historyDetailRow {
dayStart, dayEnd := historyDayBounds(day)
rows := make([]historyDetailRow, 0, len(visits)) rows := make([]historyDetailRow, 0, len(visits))
for _, visit := range visits { for _, visit := range visits {
visitStart := visit.Start visitStart := visit.Start
@@ -349,11 +490,45 @@ func historyCalendarCellsForMonth(app *guiApp) []historyCalendarCell {
monthStart = time.Now() monthStart = time.Now()
} }
monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location()) monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location())
events := historyEventsFromSnapshot()
visits := historyVisitSource()
return historyCalendarCellsForMonthFromData(app, monthStart, visits, events, q)
}
func historyCalendarCellsForMonthFromData(app *guiApp, monthStart time.Time, visits []historyVisitRecord, events []historyEventRecord, q historyQuery) []historyCalendarCell {
counts := map[string]int{} counts := map[string]int{}
daysInMonth := monthStart.AddDate(0, 1, -1).Day() daysInMonth := monthStart.AddDate(0, 1, -1).Day()
for day := 1; day <= daysInMonth; day++ { if q.Pattern == nil {
date := time.Date(monthStart.Year(), monthStart.Month(), day, 0, 0, 0, 0, monthStart.Location()) monthEnd := monthStart.AddDate(0, 1, 0)
counts[date.Format("2006-01-02")] = len(historyVisitsForDay(app, date)) for _, visit := range visits {
visitStart := visit.Start
if visitStart.IsZero() {
continue
}
visitEnd := visit.End
if visitEnd.IsZero() {
visitEnd = time.Now()
}
if !visitStart.Before(monthEnd) || !visitEnd.After(monthStart) {
continue
}
dayCursor := time.Date(visitStart.Year(), visitStart.Month(), visitStart.Day(), 0, 0, 0, 0, monthStart.Location())
if dayCursor.Before(monthStart) {
dayCursor = monthStart
}
for dayCursor.Before(monthEnd) {
dayStart, dayEnd := historyDayBounds(dayCursor)
if visitStart.Before(dayEnd) && visitEnd.After(dayStart) {
counts[dayCursor.Format("2006-01-02")]++
}
dayCursor = dayCursor.AddDate(0, 0, 1)
}
}
} else {
for day := 1; day <= daysInMonth; day++ {
date := time.Date(monthStart.Year(), monthStart.Month(), day, 0, 0, 0, 0, monthStart.Location())
counts[date.Format("2006-01-02")] = len(historyVisitsForDayFromData(app, date, visits, events, q))
}
} }
firstWeekday := int(monthStart.Weekday()) firstWeekday := int(monthStart.Weekday())
out := make([]historyCalendarCell, 0, 42) out := make([]historyCalendarCell, 0, 42)
@@ -388,57 +563,119 @@ func sameDay(a, b time.Time) bool {
} }
func exportHistoryDay(app *guiApp, day time.Time, selected map[string]bool) (string, error) { func exportHistoryDay(app *guiApp, day time.Time, selected map[string]bool) (string, error) {
rows := historyVisitsForDay(app, day) req := historyExportRequest{Day: day, Query: historyQueryFromApp(app)}
if len(rows) == 0 { if app != nil {
return "", nil req.Selected = cloneHistorySelection(selected)
req.Rows = cloneHistoryDetailRows(app.historyDetailRows)
req.ExportDir = normalizeHistoryExportDir(app.historyExportDir)
req.IncludeTime = app.historyExportIncludeTime
req.IncludeWorld = app.historyExportIncludeWorld
req.IncludeJoinLeave = app.historyExportIncludeJoinLeave
req.CustomEnabled = app.historyExportCustomEnabled
req.Custom = strings.TrimSpace(app.historyExportCustom)
req.RegexText = strings.TrimSpace(app.historyRegex)
} }
if len(selected) > 0 { return exportHistoryDayFromRequest(req)
}
func exportHistoryDayFromRequest(req historyExportRequest) (string, error) {
rows := append([]historyDetailRow(nil), req.Rows...)
if len(rows) == 0 {
rows = historyVisitsForDayFromData(nil, req.Day, historyVisitSource(), historyEventsFromSnapshot(), req.Query)
}
if len(req.Selected) > 0 {
filtered := rows[:0] filtered := rows[:0]
for _, row := range rows { for _, row := range rows {
if selected[row.Key] { if req.Selected[row.Key] {
filtered = append(filtered, row) filtered = append(filtered, row)
} }
} }
rows = append([]historyDetailRow(nil), filtered...) rows = append([]historyDetailRow(nil), filtered...)
} }
if len(rows) == 0 { customPattern := (*regexp.Regexp)(nil)
return "", nil if req.CustomEnabled {
if s := strings.TrimSpace(req.Custom); s != "" {
if re, err := regexp.Compile(s); err == nil {
customPattern = re
}
}
} }
var b strings.Builder var b strings.Builder
b.WriteString("Date: ") b.WriteString("Date: ")
b.WriteString(day.Format("2006-01-02")) b.WriteString(req.Day.Format("2006-01-02"))
b.WriteString("\n") b.WriteString("\n")
if q := historyQueryFromApp(app); q.Pattern != nil { if len(req.Selected) > 0 {
b.WriteString("Selection: ")
b.WriteString(fmt.Sprintf("%d rows", len(req.Selected)))
b.WriteString("\n")
}
if req.Query.Pattern != nil {
b.WriteString("Filter: ") b.WriteString("Filter: ")
b.WriteString(strings.TrimSpace(app.historyRegex)) if strings.TrimSpace(req.RegexText) != "" {
b.WriteString(strings.TrimSpace(req.RegexText))
} else {
b.WriteString(req.Query.Pattern.String())
}
b.WriteString("\n")
}
if req.CustomEnabled && customPattern != nil {
b.WriteString("Custom: ")
b.WriteString(strings.TrimSpace(req.Custom))
b.WriteString("\n") b.WriteString("\n")
} }
b.WriteString("\n") b.WriteString("\n")
if len(rows) == 0 {
if len(req.Selected) > 0 {
b.WriteString("(no selected visits)\n")
} else {
b.WriteString("(no visits)\n")
}
}
for _, row := range rows { for _, row := range rows {
b.WriteString("\nWorld: ") rowLines := append([]string(nil), row.Lines...)
b.WriteString(strings.TrimSpace(row.WorldLabel)) if customPattern != nil {
b.WriteString("\n") filtered := rowLines[:0]
b.WriteString("Time: ") for _, line := range rowLines {
b.WriteString(row.Start.Format("15:04")) if customPattern.MatchString(line) || customPattern.MatchString(row.WorldLabel) || customPattern.MatchString(strings.Join(row.Users, " ")) {
b.WriteString(" - ") filtered = append(filtered, line)
b.WriteString(row.End.Format("15:04")) }
b.WriteString("\n") }
rowLines = append([]string(nil), filtered...)
}
if req.IncludeWorld {
b.WriteString("\nWorld: ")
b.WriteString(strings.TrimSpace(row.WorldLabel))
b.WriteString("\n")
}
if req.IncludeTime {
b.WriteString("Time: ")
b.WriteString(historyTimeRangeLabel(row.Start, row.End, row.Current))
b.WriteString("\n")
}
if len(row.Users) > 0 { if len(row.Users) > 0 {
b.WriteString("Users: ") b.WriteString("Users: ")
b.WriteString(strings.Join(row.Users, ", ")) b.WriteString(strings.Join(row.Users, ", "))
b.WriteString("\n") b.WriteString("\n")
} }
if len(row.Lines) == 0 { if req.IncludeJoinLeave {
b.WriteString("(no matches)\n") if len(rowLines) == 0 {
continue b.WriteString("(no matches)\n")
} }
for _, line := range row.Lines { for _, line := range rowLines {
b.WriteString(line) b.WriteString(line)
b.WriteString("\n") b.WriteString("\n")
}
} }
} }
path := filepath.Join(runtimeDir(), "visit_"+day.Format("20060102")+".log") exportDir := runtimeDir()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { if strings.TrimSpace(req.ExportDir) != "" {
exportDir = normalizeHistoryExportDir(req.ExportDir)
if !filepath.IsAbs(exportDir) {
exportDir = filepath.Join(runtimeDir(), exportDir)
}
}
path := filepath.Join(exportDir, "visit_"+req.Day.Format("20060102")+".log")
if err := os.MkdirAll(exportDir, 0o755); err != nil {
return "", err return "", err
} }
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
@@ -447,6 +684,89 @@ func exportHistoryDay(app *guiApp, day time.Time, selected map[string]bool) (str
return path, nil return path, nil
} }
func cloneHistorySelection(selected map[string]bool) map[string]bool {
if len(selected) == 0 {
return nil
}
out := make(map[string]bool, len(selected))
for key, ok := range selected {
if ok {
out[key] = true
}
}
return out
}
func cloneHistoryDetailRows(rows []historyDetailRow) []historyDetailRow {
if len(rows) == 0 {
return nil
}
out := make([]historyDetailRow, 0, len(rows))
for _, row := range rows {
cloned := row
if len(row.Users) > 0 {
cloned.Users = append([]string(nil), row.Users...)
}
if len(row.Lines) > 0 {
cloned.Lines = append([]string(nil), row.Lines...)
}
out = append(out, cloned)
}
return out
}
func refreshHistoryCache(app *guiApp, force bool) bool {
if app == nil {
return false
}
ensureHistoryState(app)
if !force && app.historyCacheLoaded && !app.historyReloadPending {
return false
}
events := historyEventsFromSnapshot()
visits := historyVisitSource()
q := historyQueryFromApp(app)
app.historyRows = historyRows()
if app.historyRows == nil {
app.historyRows = []guiWorldVisitRow{}
}
monthStart := app.historyMonth
if monthStart.IsZero() {
monthStart = time.Now()
}
monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location())
app.historyCalendarCells = historyCalendarCellsForMonthFromData(app, monthStart, visits, events, q)
if app.historyCalendarCells == nil {
app.historyCalendarCells = []historyCalendarCell{}
}
app.historyDetailRows = historyVisitsForDayFromData(app, app.historySelectedDate, visits, events, q)
if app.historyDetailRows == nil {
app.historyDetailRows = []historyDetailRow{}
}
height := int32(132)
for _, row := range app.historyDetailRows {
height += historyDetailRowHeight(row) + 6
}
if height < 120 {
height = 120
}
app.historyDetailContentHeightPx = height
app.historyReloadPending = false
app.historyCacheLoaded = true
app.historyStatus = "reloaded: " + time.Now().Format("15:04:05")
return true
}
func requestHistoryRefresh(app *guiApp, reason string) {
if app == nil {
return
}
app.historyReloadPending = true
if strings.TrimSpace(reason) != "" {
app.historyStatus = reason
}
}
func paintHistoryCalendarPane(hwnd uintptr, app *guiApp) uintptr { func paintHistoryCalendarPane(hwnd uintptr, app *guiApp) uintptr {
if app == nil { if app == nil {
return 0 return 0
@@ -479,14 +799,17 @@ func paintHistoryCalendarPane(hwnd uintptr, app *guiApp) uintptr {
} }
cellH := int32(56) cellH := int32(56)
startX := int32(12) startX := int32(12)
startY := int32(80) startY := int32(102)
for i, wd := range weekdays { for i, wd := range weekdays {
x := startX + int32(i)*cellW x := startX + int32(i)*cellW
drawPaneText(hdc, x+6, startY-18, wd, app.hFont, clrMutedText) drawPaneText(hdc, x+6, startY-18, wd, app.hFont, clrMutedText)
} }
cells := historyCalendarCellsForMonth(app) cells := app.historyCalendarCells
app.historyCalendarCells = app.historyCalendarCells[:0] if len(cells) == 0 {
drawPaneText(hdc, 24, 118, "履歴カレンダーを読み込み中", app.joinBoldHFont, clrMutedText)
drawPaneText(hdc, 24, 146, "しばらくしても出ない場合は再読み込みを押してください", app.hFont, clrMutedText)
}
for i, cell := range cells { for i, cell := range cells {
if cell.Date.IsZero() { if cell.Date.IsZero() {
continue continue
@@ -500,7 +823,7 @@ func paintHistoryCalendarPane(hwnd uintptr, app *guiApp) uintptr {
Bottom: startY + int32(row+1)*cellH - 4, Bottom: startY + int32(row+1)*cellH - 4,
} }
cell.Rect = rect cell.Rect = rect
app.historyCalendarCells = append(app.historyCalendarCells, cell) app.historyCalendarCells[i].Rect = rect
fill := uint32(0x00261a11) fill := uint32(0x00261a11)
border := uint32(clrPaneBorder) border := uint32(clrPaneBorder)
textColor := uint32(clrText) textColor := uint32(clrText)
@@ -533,6 +856,12 @@ func paintHistoryDetailPane(hwnd uintptr, app *guiApp) uintptr {
if app == nil { if app == nil {
return 0 return 0
} }
start := time.Now()
defer func() {
if d := time.Since(start); d > 80*time.Millisecond {
guiLog(fmt.Sprintf("paintHistoryDetailPane slow=%s rows=%d scroll=%d", d, len(app.historyDetailRows), app.historyScrollPos))
}
}()
ensureHistoryState(app) ensureHistoryState(app)
hdc, done := beginPanePaint(hwnd) hdc, done := beginPanePaint(hwnd)
if hdc == 0 { if hdc == 0 {
@@ -544,15 +873,14 @@ func paintHistoryDetailPane(hwnd uintptr, app *guiApp) uintptr {
getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc))) getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc)))
fillSolid(hdc, rc, clrPaneBgAlt) fillSolid(hdc, rc, clrPaneBgAlt)
rows := historyVisitsForDay(app, app.historySelectedDate) rows := app.historyDetailRows
app.historyDetailRows = app.historyDetailRows[:0]
title := app.historySelectedDate.Format("2006年01月02日") title := app.historySelectedDate.Format("2006年01月02日")
drawPaneText(hdc, 18, 18, title, app.joinBoldHFont, clrAccentGreen) drawPaneText(hdc, 18, 52, title, app.joinBoldHFont, clrAccentGreen)
summary := fmt.Sprintf("%d件", len(rows)) summary := fmt.Sprintf("%d件", len(rows))
drawPaneText(hdc, 18, 44, summary, app.hFont, clrMutedText) drawPaneText(hdc, 18, 78, summary, app.hFont, clrMutedText)
if strings.TrimSpace(app.historyRegex) != "" { if strings.TrimSpace(app.historyRegex) != "" {
drawPaneText(hdc, 96, 44, "grep: "+app.historyRegex, app.hFont, clrMutedText) drawPaneText(hdc, 96, 78, "grep: "+app.historyRegex, app.hFont, clrMutedText)
} }
if strings.TrimSpace(app.historyFromDate) != "" || strings.TrimSpace(app.historyToDate) != "" { if strings.TrimSpace(app.historyFromDate) != "" || strings.TrimSpace(app.historyToDate) != "" {
rangeLabel := strings.TrimSpace(app.historyFromDate) rangeLabel := strings.TrimSpace(app.historyFromDate)
@@ -565,10 +893,13 @@ func paintHistoryDetailPane(hwnd uintptr, app *guiApp) uintptr {
} else { } else {
rangeLabel += "..." rangeLabel += "..."
} }
drawPaneText(hdc, 260, 44, rangeLabel, app.hFont, clrMutedText) drawPaneText(hdc, 260, 78, rangeLabel, app.hFont, clrMutedText)
} }
app.historyExportRect = winRect{Left: rc.Right - 132, Top: 14, Right: rc.Right - 16, Bottom: 42} app.historyReloadRect = winRect{Left: rc.Right - 256, Top: 10, Right: rc.Right - 140, Bottom: 38}
drawSettingsBox(hdc, app.historyReloadRect, 0x0038281a, clrPaneBorder)
drawCenteredPaneText(hdc, app.historyReloadRect, "Reload", app.hFont, clrText)
app.historyExportRect = winRect{Left: rc.Right - 132, Top: 10, Right: rc.Right - 16, Bottom: 38}
drawSettingsBox(hdc, app.historyExportRect, 0x0038281a, clrAccentGreen) drawSettingsBox(hdc, app.historyExportRect, 0x0038281a, clrAccentGreen)
drawCenteredPaneText(hdc, app.historyExportRect, "Export", app.hFont, clrText) drawCenteredPaneText(hdc, app.historyExportRect, "Export", app.hFont, clrText)
if app.historyStatus != "" { if app.historyStatus != "" {
@@ -583,11 +914,11 @@ func paintHistoryDetailPane(hwnd uintptr, app *guiApp) uintptr {
} }
applyPaneScroll(hwnd, app.historyScrollPos, contentHeight, pageHeight) applyPaneScroll(hwnd, app.historyScrollPos, contentHeight, pageHeight)
y := int32(82 - app.historyScrollPos) y := int32(126 - app.historyScrollPos)
for _, row := range rows { for i, row := range rows {
rowHeight := historyDetailRowHeight(row) rowHeight := historyDetailRowHeight(row)
row.Rect = winRect{Left: 14, Top: y, Right: rc.Right - 14, Bottom: y + rowHeight - 4} row.Rect = winRect{Left: 14, Top: y, Right: rc.Right - 14, Bottom: y + rowHeight - 4}
app.historyDetailRows = append(app.historyDetailRows, row) app.historyDetailRows[i].Rect = row.Rect
if row.Rect.Bottom < 72 { if row.Rect.Bottom < 72 {
y += rowHeight y += rowHeight
continue continue
@@ -605,8 +936,9 @@ func paintHistoryDetailPane(hwnd uintptr, app *guiApp) uintptr {
border = clrAccentGreen border = clrAccentGreen
} }
drawSettingsBox(hdc, row.Rect, fill, border) drawSettingsBox(hdc, row.Rect, fill, border)
drawPaneText(hdc, row.Rect.Left+12, y+6, row.Start.Format("15:04")+" - "+row.End.Format("15:04"), app.hFont, clrMutedText) drawPaneText(hdc, row.Rect.Left+12, y+6, "入室 "+historyEntryTimeLabel(row.Start, false, row.Current), app.hFont, clrMutedText)
drawPaneText(hdc, row.Rect.Left+122, y+6, humanDurationLabel(row.Start, row.End, row.Current), app.hFont, clrMutedText) drawPaneText(hdc, row.Rect.Left+146, y+6, "退室 "+historyEntryTimeLabel(row.End, true, row.Current), app.hFont, clrMutedText)
drawPaneText(hdc, row.Rect.Right-110, y+6, humanDurationLabel(row.Start, row.End, row.Current), app.hFont, clrMutedText)
label := ellipsizeTextToWidth(hdc, row.WorldLabel, row.Rect.Right-row.Rect.Left-150, app.joinBoldHFont) label := ellipsizeTextToWidth(hdc, row.WorldLabel, row.Rect.Right-row.Rect.Left-150, app.joinBoldHFont)
if label == "" { if label == "" {
label = "(unknown)" label = "(unknown)"
@@ -635,16 +967,18 @@ func historyDetailContentHeight(hwnd uintptr, app *guiApp) int32 {
if app == nil || getClientRectProc == nil { if app == nil || getClientRectProc == nil {
return 0 return 0
} }
var rc winRect if app.historyDetailContentHeightPx > 0 {
getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc))) return app.historyDetailContentHeightPx
rows := historyVisitsForDay(app, app.historySelectedDate) }
height := int32(90) rows := app.historyDetailRows
height := int32(132)
for _, row := range rows { for _, row := range rows {
height += historyDetailRowHeight(row) + 6 height += historyDetailRowHeight(row) + 6
} }
if height < 120 { if height < 120 {
height = 120 height = 120
} }
app.historyDetailContentHeightPx = height
return height return height
} }
@@ -666,7 +1000,55 @@ func historyDetailRowHeight(row historyDetailRow) int32 {
return height return height
} }
func handleHistoryPaneClick(app *guiApp, hwnd uintptr, x, y int32, setWindowText *syscall.LazyProc) bool { func historyTimeRangeLabel(start, end time.Time, current bool) string {
if start.IsZero() {
return ""
}
if end.IsZero() {
end = time.Now()
}
if end.Before(start) {
end = start
}
startLabel := start.Format("15:04")
endLabel := end.Format("15:04")
if !sameDay(start, end) {
startLabel = start.Format("01/02 15:04")
if !current && isMidnight(end) {
endLabel = "23:59"
} else {
endLabel = end.Format("01/02 15:04")
}
} else if !current && isMidnight(end) {
endLabel = "23:59"
}
if current {
return startLabel + " - now"
}
return startLabel + " - " + endLabel
}
func isMidnight(t time.Time) bool {
return t.Hour() == 0 && t.Minute() == 0 && t.Second() == 0 && t.Nanosecond() == 0
}
func historyEntryTimeLabel(t time.Time, isEnd bool, current bool) string {
if t.IsZero() {
if current {
return "now"
}
return ""
}
if current {
return "now"
}
if isEnd && isMidnight(t) {
return "23:59"
}
return t.Format("15:04")
}
func handleHistoryPaneClick(app *guiApp, hwnd uintptr, x, y int32, getWindowText, setWindowText *syscall.LazyProc) bool {
if app == nil { if app == nil {
return false return false
} }
@@ -689,18 +1071,16 @@ func handleHistoryPaneClick(app *guiApp, hwnd uintptr, x, y int32, setWindowText
return true return true
} }
case app.rightHwnd: case app.rightHwnd:
if pointInRect(x, y, app.historyExportRect) { if pointInRect(x, y, app.historyReloadRect) {
path, err := exportHistoryDay(app, app.historySelectedDate, app.historySelectedKeys) requestHistoryRefresh(app, "reloading...")
if err != nil {
app.historyStatus = "export failed: " + err.Error()
} else if path != "" {
app.historyStatus = "exported: " + filepath.Base(path)
} else {
app.historyStatus = "exported: nothing selected"
}
invalidateHistoryPanes(app) invalidateHistoryPanes(app)
return true return true
} }
if pointInRect(x, y, app.historyExportRect) {
guiLog(fmt.Sprintf("history export click day=%s status=%q", app.historySelectedDate.Format("2006-01-02"), app.historyStatus))
startHistoryExport(app, getWindowText, setWindowText)
return true
}
for _, row := range app.historyDetailRows { for _, row := range app.historyDetailRows {
if row.Key == "" || !pointInRect(x, y, row.Rect) { if row.Key == "" || !pointInRect(x, y, row.Rect) {
continue continue
@@ -761,6 +1141,7 @@ func historySelectDate(app *guiApp, day time.Time) {
app.historySelectedKeys = map[string]bool{} app.historySelectedKeys = map[string]bool{}
app.historyStatus = "" app.historyStatus = ""
app.historyScrollPos = 0 app.historyScrollPos = 0
requestHistoryRefresh(app, "reloading...")
invalidateHistoryPanes(app) invalidateHistoryPanes(app)
} }

View File

@@ -3,22 +3,95 @@
package main package main
import ( import (
"os"
"log" "log"
"net"
"os"
"os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings"
"vrc_osc_go/internal/app" "vrc_osc_go/internal/app"
"vrc_osc_go/internal/config"
) )
func main() { func main() {
initialTab := parseInitialTab(os.Args[1:])
if exe, err := os.Executable(); err == nil { if exe, err := os.Executable(); err == nil {
_ = app.AppendRuntimeLog("GUI", "starting vrc_osc_gui.exe exe="+exe+" dir="+filepath.Dir(exe)) _ = app.AppendRuntimeLog("GUI", "starting vrc_osc_gui.exe exe="+exe+" dir="+filepath.Dir(exe)+" initial_tab="+initialTab)
} else { } else {
_ = app.AppendRuntimeLog("GUI", "starting vrc_osc_gui.exe exe=unknown err="+err.Error()) _ = app.AppendRuntimeLog("GUI", "starting vrc_osc_gui.exe exe=unknown err="+err.Error())
} }
if err := runNativeGUI(); err != nil { ensureRuntimeStarted()
if err := runNativeGUI(initialTab); err != nil {
_ = app.AppendRuntimeLog("GUI", "runNativeGUI failed: "+err.Error()) _ = app.AppendRuntimeLog("GUI", "runNativeGUI failed: "+err.Error())
log.Fatal(err) log.Fatal(err)
} }
_ = app.AppendRuntimeLog("GUI", "runNativeGUI returned cleanly") _ = app.AppendRuntimeLog("GUI", "runNativeGUI returned cleanly")
} }
func parseInitialTab(args []string) string {
for _, arg := range args {
switch {
case arg == "--settings" || arg == "-settings" || arg == "/settings":
return "settings"
case arg == "--translate" || arg == "-translate" || arg == "/translate":
return "join"
case arg == "--log" || arg == "-log" || arg == "/log":
return "join"
case strings.HasPrefix(arg, "--tab="):
if tab := normalizeInitialTab(strings.TrimPrefix(arg, "--tab=")); tab != "" {
return tab
}
case strings.HasPrefix(arg, "/tab="):
if tab := normalizeInitialTab(strings.TrimPrefix(arg, "/tab=")); tab != "" {
return tab
}
}
}
return "join"
}
func normalizeInitialTab(tab string) string {
switch strings.ToLower(strings.TrimSpace(tab)) {
case "join", "log", "logs":
return "join"
case "translate", "translation":
return "join"
case "settings", "setting", "config":
return "settings"
default:
return ""
}
}
func ensureRuntimeStarted() {
cfg, err := config.Load("")
if err != nil {
_ = app.AppendRuntimeLog("GUI", "runtime autostart skipped config="+err.Error())
return
}
addr := net.JoinHostPort(cfg.OSC.Host, strconv.Itoa(cfg.OSC.Port))
probe, err := net.ListenPacket("udp", addr)
if err != nil {
_ = app.AppendRuntimeLog("GUI", "runtime already listening addr="+addr+" err="+err.Error())
return
}
_ = probe.Close()
exe, err := os.Executable()
if err != nil {
_ = app.AppendRuntimeLog("GUI", "runtime autostart skipped exe="+err.Error())
return
}
runtimeExe := filepath.Join(filepath.Dir(exe), "vrc_osc.exe")
if _, err := os.Stat(runtimeExe); err != nil {
_ = app.AppendRuntimeLog("GUI", "runtime autostart missing exe="+runtimeExe+" err="+err.Error())
return
}
if err := exec.Command(runtimeExe, "--no-gui").Start(); err != nil {
_ = app.AppendRuntimeLog("GUI", "runtime autostart failed exe="+runtimeExe+" err="+err.Error())
return
}
_ = app.AppendRuntimeLog("GUI", "runtime autostart launched exe="+runtimeExe)
}

View File

@@ -0,0 +1,5 @@
//go:build !windows
package main
func main() {}

Binary file not shown.

View File

@@ -13,9 +13,9 @@ import (
) )
const ( const (
wmUser = 0x0400 wmUser = 0x0400
wmTray = wmUser + 1 wmTray = wmUser + 1
wmCommand = 0x0111 wmCommand = 0x0111
wmDestroy = 0x0002 wmDestroy = 0x0002
wmClose = 0x0010 wmClose = 0x0010
wmRButtonUp = 0x0205 wmRButtonUp = 0x0205
@@ -37,7 +37,7 @@ const (
idOpen = 1001 idOpen = 1001
idExit = 1002 idExit = 1002
maxTip = 128 maxTip = 128
maxClassName = 64 maxClassName = 64
) )
type trayApp struct { type trayApp struct {

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -10,6 +10,15 @@ file = "config/guests.txt"
[gui] [gui]
top_most = false top_most = false
font_size = 18 font_size = 18
auto_unmute_on_self_leave = false
refresh_interval_value = 2
refresh_interval_unit = "sec"
history_export_dir = "exports"
history_export_include_time = true
history_export_include_world = true
history_export_include_join_leave = true
history_export_custom_enabled = false
history_export_custom = ""
[notice] [notice]
missing_count = 0 missing_count = 0

BIN
doc/vrcworldtour.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 KiB

BIN
doc/vrcworldtour_new.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
doc/vrcworldtour_new.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -1,70 +1,78 @@
# ワークフロー # Workflow
## 起動から利用まで ## 全体の流れ
```mermaid ```mermaid
flowchart TD flowchart TD
A[起動] --> B[config を読む] A[アプリ起動] --> B[設定読み込み]
B --> C[OSC サーバーを起動] B --> C[VRChat ログの探索]
C --> D[VRChat を開く] C --> D[GUI 起動]
D --> E[ExMenu から機能を選ぶ] D --> E[タイマーで定期更新]
E --> F{どの機能か} E --> F[現在のワールド判定]
E --> G[入退室ログ更新]
E --> H[履歴データ更新]
E --> I[OCR / 翻訳 / 設定反映]
F -->|DiscordSend| G[Discord ミュート操作] G --> J[Discord 送信が必要なら通知]
F -->|vrc_log| H[VRC ログ監視] H --> K[日別履歴表示]
F -->|ocrEnabled| I[OCR 実行] K --> L[選択した訪問を export]
F -->|translation| J[OCR + 翻訳] L --> M[visit_YYYYMMDD.log を保存]
G --> K[VRChat に戻る]
H --> L[Discord Webhook に通知]
I --> M[runtime に保存]
J --> N[クリップボードへコピー]
J --> M
``` ```
## DiscordSend の流れ ## 起動時
```mermaid 1. `config/config.toml` を読み込む。
flowchart TD 2. GUI 設定を反映する。
A[DiscordSend を受信] --> B[Discord ウィンドウを探す] 3. `runtime/` 配下のログや履歴を確認する。
B --> C{Discord が見つかる?} 4. ネイティブ GUI を開く。
C -->|はい| D[Ctrl+Shift+M を送る] 5. タイマーで定期更新を開始する。
C -->|いいえ| E[ブラウザ版 Discord を探す]
E --> D ## ログ収集
D --> F[VRChat に戻す]
1. VRChat の最新ログを探す。
2. ログの末尾を読み、`OnPlayerJoined` / `OnPlayerLeft` / `Entering Room` / `worldId=` などを拾う。
3. 現在のワールド名、人数、入退室イベントを更新する。
4. 必要なら `runtime/join_leave.log` に追記する。
5. 履歴用の `runtime/world_history.json` を参照して、訪問区間を組み立てる。
## GUI 更新
1. 画面はタブごとに描画を切り替える。
2. ログタブは現在の入退室状況を表示する。
3. 翻訳タブは OCR と翻訳結果を表示する。
4. 設定タブは `top most`、文字サイズ、更新間隔、履歴条件を変更できる。
5. 履歴タブは日付カレンダーと、その日の訪問一覧を表示する。
## 履歴タブ
1. 設定で `history_regex``history_from``history_to` を指定する。
2. カレンダーから日付を選ぶ。
3. その日の訪問ワールド一覧を出す。
4. 訪問を複数選択できる。
5. `Export` を押すと `runtime/visit_YYYYMMDD.log` を出力する。
6. 出力内容は選択した訪問のワールド名と、条件に一致した入退室行になる。
## エクスポート例
```text
Date: 2026-06-29
Filter: POPPOHOUSE|Holiday-Cottage
World: POPPOHOUSE
[22:15] join ちりー@
[22:15] join ゆらいか
World: Holiday-Cottage
[20:47] join 毎日がHoliday'
[20:48] leave 毎日がHoliday'
``` ```
## vrc_log の流れ ## 保存されるもの
```mermaid - `runtime/runtime.log`
flowchart TD - `runtime/join_leave.log`
A[vrc_log を受信] --> B[ログ監視 ON/OFF を切り替える] - `runtime/world_history.json`
B --> C{ON か?} - `runtime/visit_YYYYMMDD.log`
C -->|はい| D[最新ログを読む] - `runtime/latest_ocr.png`
D --> E[入室 / 退室 / ワールド変更を解析] - `runtime/latest_screenshot.png`
E --> F{通知対象か?}
F -->|はい| G[Discord Webhook に送る]
F -->|いいえ| H[保存だけする]
C -->|いいえ| I[監視を停止する]
```
## ocrEnabled の流れ
```mermaid
flowchart TD
A[ocrEnabled を受信] --> B[VRChat 画面を読み取る]
B --> C[OCR を実行]
C --> D[認識結果を保存]
```
## translation の流れ
```mermaid
flowchart TD
A[translation を受信] --> B[VRChat 画面を読み取る]
B --> C[OCR を実行]
C --> D[不要な文字を除去]
D --> E[日本語へ翻訳]
E --> F[翻訳結果をクリップボードへコピー]
E --> G[runtime に保存]
```

View File

@@ -8,9 +8,9 @@ import (
"syscall" "syscall"
"time" "time"
"vrc_osc_go/internal/buildinfo"
"vrc_osc_go/internal/config" "vrc_osc_go/internal/config"
"vrc_osc_go/internal/consenttool" "vrc_osc_go/internal/consenttool"
"vrc_osc_go/internal/buildinfo"
"vrc_osc_go/internal/osc" "vrc_osc_go/internal/osc"
) )
@@ -39,6 +39,8 @@ func Run(configPath string, mode string) error {
} }
log.Printf("app version=%s build=%s", buildinfo.Version, buildinfo.BuildTime) log.Printf("app version=%s build=%s", buildinfo.Version, buildinfo.BuildTime)
InitGuestTracker(NewGuestTracker(cfg.VrcLog.GuestNames)) InitGuestTracker(NewGuestTracker(cfg.VrcLog.GuestNames))
InitJoinLeaveEventTracker(NewJoinLeaveEventTracker())
InitWorldHistoryTracker(NewWorldHistoryTracker())
log.Printf("vrc_osc_go starting osc=%s:%d config=%s", cfg.OSC.Host, cfg.OSC.Port, configPath) 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) server := osc.NewServer(cfg.OSC.Host, cfg.OSC.Port)
@@ -89,6 +91,9 @@ func Run(configPath string, mode string) error {
return err return err
case <-sigCh: case <-sigCh:
log.Printf("shutdown signal received") log.Printf("shutdown signal received")
if tracker := GetWorldHistoryTracker(); tracker != nil {
tracker.FinalizeCurrent(time.Now())
}
server.Close() server.Close()
return nil return nil
} }

View File

@@ -3,87 +3,82 @@ package app
import ( import (
"fmt" "fmt"
"log" "log"
"strings" "os/exec"
"syscall"
"unsafe"
)
const (
vkControl = 0x11
vkShift = 0x10
vkM = 0x4D
keyUp = 0x0002
swRestore = 9
) )
func pressDiscordMuteHotkey() error { func pressDiscordMuteHotkey() error {
user32 := syscall.NewLazyDLL("user32.dll") script := `
enumWindows := user32.NewProc("EnumWindows") $ErrorActionPreference = "Stop"
getWindowTextW := user32.NewProc("GetWindowTextW")
isWindowVisible := user32.NewProc("IsWindowVisible")
setForegroundWindow := user32.NewProc("SetForegroundWindow")
showWindow := user32.NewProc("ShowWindow")
getWindowRect := user32.NewProc("GetWindowRect")
keybdEvent := user32.NewProc("keybd_event")
setCursorPos := user32.NewProc("SetCursorPos")
sleep := syscall.NewLazyDLL("kernel32.dll").NewProc("Sleep")
var target uintptr function Get-DiscordWindows {
cb := syscall.NewCallback(func(hwnd uintptr, lparam uintptr) uintptr { $windows = foreach ($p in Get-Process) {
vis, _, _ := isWindowVisible.Call(hwnd) $title = ($p.MainWindowTitle | Out-String).Trim()
if vis == 0 { if (-not $title) { continue }
return 1 if (-not $title.ToLower().Contains('discord')) { continue }
} $p
buf := make([]uint16, 512) }
n, _, _ := getWindowTextW.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf))) if (-not $windows) { return @() }
if n == 0 { return $windows
return 1 }
}
title := strings.ToLower(strings.TrimSpace(syscall.UTF16ToString(buf[:n])))
if !strings.Contains(title, "discord") {
return 1
}
target = hwnd
return 0
})
r1, _, err1 := enumWindows.Call(cb, 0)
if r1 == 0 && target == 0 {
return fmt.Errorf("discord window not found: %v", err1)
}
if target == 0 {
return fmt.Errorf("discord window not found")
}
showWindow.Call(target, swRestore) function Get-BestDiscordWindow {
setForegroundWindow.Call(target) $windows = Get-DiscordWindows
sleep.Call(150) if (-not $windows -or $windows.Count -eq 0) { return $null }
type rect struct { $browserKeywords = @('google chrome', 'microsoft edge', 'mozilla firefox', 'brave', 'opera')
Left int32 $ranked = $windows | Sort-Object {
Top int32 $title = ($_.MainWindowTitle | Out-String).Trim().ToLower()
Right int32 $score = 0
Bottom int32 foreach ($keyword in $browserKeywords) {
} if ($title.Contains($keyword)) { $score += 10 }
var r rect }
if v, _, _ := getWindowRect.Call(target, uintptr(unsafe.Pointer(&r))); v != 0 { if ($title -match '^\(\d+\)\s*discord') { $score += 20 }
x := int((r.Left + r.Right) / 2) if ($title.Contains('discord')) { $score += 5 }
y := int((r.Top + r.Bottom) / 2) $score
setCursorPos.Call(uintptr(x), uintptr(y)) } -Descending
sleep.Call(50)
}
send := func(vk byte, flags uintptr) { return $ranked | Select-Object -First 1
keybdEvent.Call(uintptr(vk), 0, flags, 0) }
}
send(vkControl, 0)
send(vkShift, 0)
send(vkM, 0)
send(vkM, keyUp)
send(vkShift, keyUp)
send(vkControl, keyUp)
sleep.Call(100)
SetDiscordWindow("Discord") function Get-VRChatWindow {
log.Printf("discord hotkey sent via keybd_event") $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 return nil
} }

View File

@@ -54,6 +54,41 @@ func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
return nil return nil
} }
func unmuteDiscordIfMuted(state *discordMuteState, source string) error {
if state == nil {
return fmt.Errorf("discord state is nil")
}
state.mu.Lock()
defer state.mu.Unlock()
if !state.muted {
log.Printf("INFO %s requested Discord unmute but already unmuted", source)
SetDiscordAction("already_unmuted")
SetDiscordSource(source)
return nil
}
now := time.Now()
if !state.lastAction.IsZero() && now.Sub(state.lastAction) < discordDebounce {
return nil
}
log.Printf("ACTION %s -> Discord unmute hotkey", source)
if err := pressDiscordMuteHotkey(); err != nil {
log.Printf("discord unmute hotkey failed: %v", err)
SetDiscordAction("hotkey_failed")
SetDiscordSource(source + "_error")
state.lastAction = now
return err
}
state.muted = false
state.lastAction = now
SetDiscordMuted(false)
SetDiscordAction("unmuted")
SetDiscordSource(source)
return nil
}
func toBool(v osc.Value) (bool, error) { func toBool(v osc.Value) (bool, error) {
switch v.Type { switch v.Type {
case 'T': case 'T':

View File

@@ -1,8 +1,8 @@
package app package app
import ( import (
"fmt"
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -40,6 +40,14 @@ func (t *GuestTracker) SetCurrentInstance(label string) {
t.current = label t.current = label
} }
func (t *GuestTracker) ResetCurrentInstance(label string) {
t.mu.Lock()
defer t.mu.Unlock()
t.current = label
t.items = map[string]*GuestStatus{}
_ = t.persistLocked()
}
func (t *GuestTracker) CurrentInstance() string { func (t *GuestTracker) CurrentInstance() string {
t.mu.RLock() t.mu.RLock()
defer t.mu.RUnlock() defer t.mu.RUnlock()

View File

@@ -0,0 +1,214 @@
package app
import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"vrc_osc_go/internal/common"
)
const joinLeaveEventsFileName = "join_leave_events.json"
var joinLeaveEventLinePattern = regexp.MustCompile(`^\[(join|leave)\]\s+(.+?)\s+\((\d+)\)$`)
type JoinLeaveEvent struct {
At time.Time `json:"at"`
Kind string `json:"kind"`
Name string `json:"name"`
Count int `json:"count,omitempty"`
}
type joinLeaveEventsSnapshot struct {
UpdatedAt time.Time `json:"updated_at"`
Events []JoinLeaveEvent `json:"events"`
}
type JoinLeaveEventTracker struct {
mu sync.RWMutex
events []JoinLeaveEvent
}
var joinLeaveEventTracker *JoinLeaveEventTracker
func InitJoinLeaveEventTracker(t *JoinLeaveEventTracker) {
joinLeaveEventTracker = t
}
func GetJoinLeaveEventTracker() *JoinLeaveEventTracker {
return joinLeaveEventTracker
}
func NewJoinLeaveEventTracker() *JoinLeaveEventTracker {
t := &JoinLeaveEventTracker{}
_ = t.load()
return t
}
func (t *JoinLeaveEventTracker) load() error {
dir := filepath.Join(common.RootDir(), "runtime")
path := filepath.Join(dir, joinLeaveEventsFileName)
b, err := os.ReadFile(path)
if err == nil && len(b) > 0 {
if events, ok := decodeJoinLeaveEventsSnapshot(b); ok {
t.events = dedupeJoinLeaveEvents(events)
return nil
}
}
events, err := loadJoinLeaveEventsFromTextLog(filepath.Join(dir, "join_leave.log"))
if err != nil {
return err
}
t.events = dedupeJoinLeaveEvents(events)
if len(t.events) > 0 {
_ = t.persistLocked()
}
return nil
}
func decodeJoinLeaveEventsSnapshot(b []byte) ([]JoinLeaveEvent, bool) {
var snap joinLeaveEventsSnapshot
if err := json.Unmarshal(b, &snap); err == nil && len(snap.Events) > 0 {
return snap.Events, true
}
var events []JoinLeaveEvent
if err := json.Unmarshal(b, &events); err == nil {
return events, true
}
return nil, false
}
func loadJoinLeaveEventsFromTextLog(path string) ([]JoinLeaveEvent, error) {
b, err := os.ReadFile(path)
if err != nil || len(b) == 0 {
return nil, err
}
events := make([]JoinLeaveEvent, 0, 256)
var currentAt time.Time
for _, raw := range strings.Split(string(b), "\n") {
line := strings.TrimSpace(strings.TrimRight(raw, "\r"))
if line == "" {
continue
}
if strings.Contains(line, "VRC JOIN/LEAVE") {
if len(line) >= 21 {
if at, err := time.Parse("2006-01-02 15:04:05", line[1:20]); err == nil {
currentAt = at
}
}
continue
}
m := joinLeaveEventLinePattern.FindStringSubmatch(line)
if len(m) != 4 {
continue
}
count, _ := strconv.Atoi(m[3])
events = append(events, JoinLeaveEvent{
At: currentAt,
Kind: m[1],
Name: strings.TrimSpace(m[2]),
Count: count,
})
}
return events, nil
}
func (t *JoinLeaveEventTracker) Record(kind, name string, count int, at time.Time) {
if t == nil {
return
}
kind = strings.TrimSpace(kind)
name = strings.TrimSpace(name)
if kind != "join" && kind != "leave" {
return
}
if name == "" {
return
}
if at.IsZero() {
at = time.Now()
}
ev := JoinLeaveEvent{At: at, Kind: kind, Name: name, Count: count}
t.mu.Lock()
defer t.mu.Unlock()
if containsJoinLeaveEvent(t.events, ev) {
return
}
t.events = append(t.events, ev)
_ = t.persistLocked()
}
func (t *JoinLeaveEventTracker) Snapshot() []JoinLeaveEvent {
if t == nil {
return nil
}
t.mu.RLock()
defer t.mu.RUnlock()
out := append([]JoinLeaveEvent(nil), t.events...)
sort.SliceStable(out, func(i, j int) bool {
if out[i].At.Equal(out[j].At) {
if out[i].Kind == out[j].Kind {
return out[i].Name < out[j].Name
}
return out[i].Kind < out[j].Kind
}
return out[i].At.Before(out[j].At)
})
return out
}
func (t *JoinLeaveEventTracker) persistLocked() error {
dir := filepath.Join(common.RootDir(), "runtime")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
f, err := os.Create(filepath.Join(dir, joinLeaveEventsFileName))
if err != nil {
return err
}
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
return enc.Encode(joinLeaveEventsSnapshot{
UpdatedAt: time.Now(),
Events: append([]JoinLeaveEvent(nil), t.events...),
})
}
func dedupeJoinLeaveEvents(events []JoinLeaveEvent) []JoinLeaveEvent {
if len(events) <= 1 {
return events
}
seen := make(map[string]struct{}, len(events))
out := make([]JoinLeaveEvent, 0, len(events))
for _, ev := range events {
key := joinLeaveEventKey(ev)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, ev)
}
return out
}
func containsJoinLeaveEvent(events []JoinLeaveEvent, candidate JoinLeaveEvent) bool {
key := joinLeaveEventKey(candidate)
for _, ev := range events {
if joinLeaveEventKey(ev) == key {
return true
}
}
return false
}
func joinLeaveEventKey(ev JoinLeaveEvent) string {
return ev.At.UTC().Format(time.RFC3339Nano) + "|" + ev.Kind + "|" + ev.Name
}

View File

@@ -0,0 +1,26 @@
package app
import (
"testing"
"time"
)
func TestDeduplicateJoinLeaveEventsIgnoresRepeatedReplays(t *testing.T) {
at := time.Date(2026, 7, 1, 1, 23, 45, 0, time.FixedZone("JST", 9*60*60))
events := []JoinLeaveEvent{
{At: at, Kind: "join", Name: "Alice", Count: 10},
{At: at, Kind: "join", Name: "Alice", Count: 11},
{At: at.Add(5 * time.Second), Kind: "leave", Name: "Alice", Count: 9},
}
got := dedupeJoinLeaveEvents(events)
if len(got) != 2 {
t.Fatalf("expected 2 unique events, got %d: %#v", len(got), got)
}
if got[0].Count != 10 {
t.Fatalf("expected first event to remain stable, got %#v", got[0])
}
if got[1].Kind != "leave" {
t.Fatalf("expected leave event to remain, got %#v", got[1])
}
}

View File

@@ -0,0 +1,28 @@
package app
import (
"time"
"vrc_osc_go/internal/config"
)
const defaultGUIRefreshInterval = 2 * time.Second
func guiRefreshIntervalFromConfig(cfg *config.Config) time.Duration {
if cfg == nil {
return defaultGUIRefreshInterval
}
interval := cfg.GUI.RefreshInterval()
if interval < time.Second {
return time.Second
}
return interval
}
func currentGUIRefreshInterval() time.Duration {
cfg, err := config.Load("")
if err != nil || cfg == nil {
return defaultGUIRefreshInterval
}
return guiRefreshIntervalFromConfig(cfg)
}

View File

@@ -26,18 +26,50 @@ func (w *runtimeLogWriter) Write(p []byte) (int, error) {
return w.file.Write(p) return w.file.Write(p)
} }
func (w *runtimeLogWriter) ensureFile(path string) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file != nil {
return nil
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
w.file = f
return nil
}
func (w *runtimeLogWriter) appendf(path, format string, args ...any) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
w.file = f
}
_, err := fmt.Fprintf(w.file, format, args...)
return err
}
var runtimeLogFileWriter runtimeLogWriter
var joinLeaveLogFileWriter runtimeLogWriter
var desktopJoinLogMu sync.Mutex
var desktopJoinLogLastPath string
var desktopJoinLogLastBody string
func setupRuntimeLogger() error { func setupRuntimeLogger() error {
dir := filepath.Join(common.RootDir(), "runtime") dir := filepath.Join(common.RootDir(), "runtime")
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
return err return err
} }
path := filepath.Join(dir, "runtime.log") path := filepath.Join(dir, "runtime.log")
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) if err := runtimeLogFileWriter.ensureFile(path); err != nil {
if err != nil {
return err return err
} }
logWriter := &runtimeLogWriter{file: f} log.SetOutput(io.MultiWriter(os.Stderr, &runtimeLogFileWriter))
log.SetOutput(io.MultiWriter(os.Stderr, logWriter))
return nil return nil
} }
@@ -51,13 +83,7 @@ func appendRuntimeLog(title, text string) error {
if body == "" { if body == "" {
body = "(empty)" body = "(empty)"
} }
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) return runtimeLogFileWriter.appendf(path, "\n[%s] %s\n%s\n", time.Now().Format("2006-01-02 15:04:05"), title, body)
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 AppendRuntimeLog(title, text string) error { func AppendRuntimeLog(title, text string) error {
@@ -74,13 +100,7 @@ func appendJoinLeaveLog(text string) error {
if body == "" { if body == "" {
body = "(empty)" body = "(empty)"
} }
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) return joinLeaveLogFileWriter.appendf(path, "\n[%s] VRC JOIN/LEAVE\n%s\n", time.Now().Format("2006-01-02 15:04:05"), body)
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
} }
func appendDesktopJoinLog(title, worldLabel, text string) error { func appendDesktopJoinLog(title, worldLabel, text string) error {
@@ -101,6 +121,11 @@ func appendDesktopJoinLog(title, worldLabel, text string) error {
if body == "" { if body == "" {
body = "(empty)" body = "(empty)"
} }
desktopJoinLogMu.Lock()
defer desktopJoinLogMu.Unlock()
if desktopJoinLogLastPath == path && desktopJoinLogLastBody == body {
return nil
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil { if err != nil {
fallbackDir := filepath.Join(common.RootDir(), "runtime") fallbackDir := filepath.Join(common.RootDir(), "runtime")
@@ -115,8 +140,12 @@ func appendDesktopJoinLog(title, worldLabel, text string) error {
path = fallbackPath path = fallbackPath
} }
defer f.Close() defer f.Close()
_, err = fmt.Fprintf(f, "%s\nWorld: %s\n\n%s\n", title, worldLabel, body) if _, err = fmt.Fprintf(f, "%s\n", body); err != nil {
return err return err
}
desktopJoinLogLastPath = path
desktopJoinLogLastBody = body
return nil
} }
func AppendDesktopJoinLog(title, worldLabel, text string) error { func AppendDesktopJoinLog(title, worldLabel, text string) error {

View File

@@ -2,13 +2,11 @@ package app
import ( import (
"log" "log"
"os"
"regexp" "regexp"
"strings" "strings"
"time" "time"
"vrc_osc_go/internal/config" "vrc_osc_go/internal/config"
"vrc_osc_go/internal/osc"
) )
var ( var (
@@ -25,31 +23,32 @@ func startSelfMonitor(cfg *config.Config, state *discordMuteState) {
for { for {
path, err := findLatestVrchatLog() path, err := findLatestVrchatLog()
if err != nil { if err != nil {
time.Sleep(2 * time.Second) time.Sleep(currentGUIRefreshInterval())
continue continue
} }
text, err := os.ReadFile(path) text, err := readFileTail(path, vrchatLogTailBytes)
if err != nil { if err != nil {
time.Sleep(2 * time.Second) time.Sleep(currentGUIRefreshInterval())
continue continue
} }
nextState := extractLatestSelfLogState(string(text), cfg.VrcLog.SelfName) liveCfg := cfg
if loaded, err := config.Load(""); err == nil && loaded != nil {
liveCfg = loaded
}
nextState := extractLatestSelfLogState(DecodeVRChatLog(text), liveCfg.VrcLog.SelfName)
if nextState != "" && nextState != lastState { if nextState != "" && nextState != lastState {
if nextState == "joined" { if nextState == "left" && liveCfg.GUI.AutoUnmuteOnSelfLeave {
if err := handleDiscordSend(state, []osc.Value{{Type: 'T', Bool: true}}); err != nil { if err := unmuteDiscordIfMuted(state, "self_leave"); err != nil {
log.Printf("self monitor discord mute failed: %v", err) log.Printf("self monitor discord unmute failed: %v", err)
SetDiscordSource("self_monitor_error") SetDiscordSource("self_monitor_error")
} }
} else if nextState == "left" { } else if nextState == "left" {
if err := handleDiscordSend(state, []osc.Value{{Type: 'F', Bool: false}}); err != nil { log.Printf("self monitor detected leave; auto unmute disabled")
log.Printf("self monitor discord mute failed: %v", err)
SetDiscordSource("self_monitor_error")
}
} }
SetDiscordSource("self_monitor") SetDiscordSource("self_monitor")
lastState = nextState lastState = nextState
} }
time.Sleep(2 * time.Second) time.Sleep(guiRefreshIntervalFromConfig(liveCfg))
} }
}() }()
} }

View File

@@ -19,6 +19,7 @@ type RuntimeState struct {
LastOCRText string LastOCRText string
LastTranslate string LastTranslate string
CurrentWorld string CurrentWorld string
CurrentWorldSince string
} }
var runtimeState = &RuntimeState{} var runtimeState = &RuntimeState{}
@@ -84,6 +85,29 @@ func SetCurrentWorld(v string) {
_ = persistRuntimeState() _ = persistRuntimeState()
} }
func SetCurrentWorldVisit(world string, since time.Time) {
runtimeState.mu.Lock()
runtimeState.CurrentWorld = world
if since.IsZero() {
runtimeState.CurrentWorldSince = ""
} else {
runtimeState.CurrentWorldSince = since.Format(time.RFC3339)
}
runtimeState.mu.Unlock()
_ = persistRuntimeState()
}
func SetCurrentWorldSince(v time.Time) {
runtimeState.mu.Lock()
if v.IsZero() {
runtimeState.CurrentWorldSince = ""
} else {
runtimeState.CurrentWorldSince = v.Format(time.RFC3339)
}
runtimeState.mu.Unlock()
_ = persistRuntimeState()
}
func SnapshotRuntime() RuntimeState { func SnapshotRuntime() RuntimeState {
runtimeState.mu.RLock() runtimeState.mu.RLock()
defer runtimeState.mu.RUnlock() defer runtimeState.mu.RUnlock()
@@ -111,6 +135,7 @@ func persistRuntimeState() error {
"ocr": s.LastOCRText, "ocr": s.LastOCRText,
"translate": s.LastTranslate, "translate": s.LastTranslate,
"world": s.CurrentWorld, "world": s.CurrentWorld,
"world_since": s.CurrentWorldSince,
"updated_at": time.Now().Format(time.RFC3339), "updated_at": time.Now().Format(time.RFC3339),
}) })
} }

View File

@@ -1,15 +1,16 @@
package app package app
import ( import (
"bytes"
"fmt" "fmt"
"io"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"time"
"unicode/utf8"
"syscall" "syscall"
"time"
"unsafe" "unsafe"
) )
@@ -24,6 +25,8 @@ var (
enteringRoomPattern = regexp.MustCompile(`\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$`) enteringRoomPattern = regexp.MustCompile(`\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$`)
) )
const vrchatLogTailBytes = 2 * 1024 * 1024
type vrcLogState struct { type vrcLogState struct {
location string location string
worldID string worldID string
@@ -94,13 +97,13 @@ func watchVrchatLog() {
path, err := findLatestVrchatLog() path, err := findLatestVrchatLog()
if err != nil { if err != nil {
log.Printf("vrchat log not found: %v", err) log.Printf("vrchat log not found: %v", err)
time.Sleep(2 * time.Second) time.Sleep(currentGUIRefreshInterval())
continue continue
} }
info, err := os.Stat(path) info, err := os.Stat(path)
if err != nil { if err != nil {
log.Printf("vrchat log stat failed: %v", err) log.Printf("vrchat log stat failed: %v", err)
time.Sleep(2 * time.Second) time.Sleep(currentGUIRefreshInterval())
continue continue
} }
log.Printf("vrchat log loop path=%s size=%d last=%d", path, info.Size(), lastSize) log.Printf("vrchat log loop path=%s size=%d last=%d", path, info.Size(), lastSize)
@@ -119,7 +122,7 @@ func watchVrchatLog() {
} else { } else {
lastSize = info.Size() lastSize = info.Size()
} }
time.Sleep(2 * time.Second) time.Sleep(currentGUIRefreshInterval())
continue continue
} }
@@ -128,10 +131,10 @@ func watchVrchatLog() {
} }
if info.Size() > lastSize { if info.Size() > lastSize {
log.Printf("vrchat log updated path=%s old=%d new=%d", path, lastSize, info.Size()) log.Printf("vrchat log updated path=%s old=%d new=%d", path, lastSize, info.Size())
b, err := os.ReadFile(path) b, err := readFileFrom(path, lastSize)
if err != nil { if err != nil {
log.Printf("vrchat log read failed: %v", err) log.Printf("vrchat log read failed: %v", err)
time.Sleep(2 * time.Second) time.Sleep(currentGUIRefreshInterval())
continue continue
} }
text := decodeVRChatLog(b) text := decodeVRChatLog(b)
@@ -146,16 +149,13 @@ func watchVrchatLog() {
if err := appendRuntimeLog("VRC LOG", line); err != nil { if err := appendRuntimeLog("VRC LOG", line); err != nil {
log.Printf("append runtime log failed: %v", err) log.Printf("append runtime log failed: %v", err)
} }
at := extractLineTime(line)
if changed, worldLabel := updateWorldState(state, line); changed { if changed, worldLabel := updateWorldState(state, line); changed {
log.Printf("world changed: %s", worldLabel) log.Printf("world changed: %s", worldLabel)
state.presentSet = map[string]struct{}{} state.presentSet = map[string]struct{}{}
SetCurrentWorld(worldLabel) handleWorldChange(state, worldLabel, at)
if tracker := GetGuestTracker(); tracker != nil {
tracker.SetCurrentInstance(worldLabel)
}
_ = appendRuntimeLog("VRC WORLD", worldLabel) _ = appendRuntimeLog("VRC WORLD", worldLabel)
} }
at := extractLineTime(line)
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 { if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1]) name := strings.TrimSpace(m[1])
log.Printf("join detected: %s", name) log.Printf("join detected: %s", name)
@@ -164,6 +164,9 @@ func watchVrchatLog() {
if tracker := GetGuestTracker(); tracker != nil { if tracker := GetGuestTracker(); tracker != nil {
tracker.MarkJoin(name, at) tracker.MarkJoin(name, at)
} }
if tracker := GetJoinLeaveEventTracker(); tracker != nil {
tracker.Record("join", name, len(state.presentSet), at)
}
out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet)) out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet))
log.Print(out) log.Print(out)
if err := appendJoinLeaveLog(out); err != nil { if err := appendJoinLeaveLog(out); err != nil {
@@ -179,6 +182,9 @@ func watchVrchatLog() {
if tracker := GetGuestTracker(); tracker != nil { if tracker := GetGuestTracker(); tracker != nil {
tracker.MarkLeave(name, at) tracker.MarkLeave(name, at)
} }
if tracker := GetJoinLeaveEventTracker(); tracker != nil {
tracker.Record("leave", name, len(state.presentSet), at)
}
out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet)) out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet))
log.Print(out) log.Print(out)
if err := appendJoinLeaveLog(out); err != nil { if err := appendJoinLeaveLog(out); err != nil {
@@ -190,10 +196,55 @@ func watchVrchatLog() {
log.Printf("vrchat log batch done join=%d leave=%d", joinHits, leaveHits) log.Printf("vrchat log batch done join=%d leave=%d", joinHits, leaveHits)
lastSize = info.Size() lastSize = info.Size()
} }
time.Sleep(2 * time.Second) time.Sleep(currentGUIRefreshInterval())
} }
} }
func readFileFrom(path string, offset int64) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
if offset > 0 {
if _, err := f.Seek(offset, 0); err != nil {
return nil, err
}
}
return io.ReadAll(f)
}
func readFileTail(path string, maxBytes int64) ([]byte, error) {
if maxBytes <= 0 {
return os.ReadFile(path)
}
info, err := os.Stat(path)
if err != nil {
return nil, err
}
if info.Size() <= maxBytes {
return os.ReadFile(path)
}
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
if _, err := f.Seek(info.Size()-maxBytes, 0); err != nil {
return nil, err
}
b, err := io.ReadAll(f)
if err != nil {
return nil, err
}
for i, c := range b {
if c == '\n' && i+1 < len(b) {
return b[i+1:], nil
}
}
return b, nil
}
func scanExistingVrchatLog(path string, state *vrcLogState) error { func scanExistingVrchatLog(path string, state *vrcLogState) error {
b, err := os.ReadFile(path) b, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -208,13 +259,18 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
state.initialized = false state.initialized = false
text := decodeVRChatLog(b) text := decodeVRChatLog(b)
var lastWorldAt time.Time
sawWorldChange := false
for _, raw := range strings.Split(text, "\n") { for _, raw := range strings.Split(text, "\n") {
line := strings.TrimRight(raw, "\r") line := strings.TrimRight(raw, "\r")
if line == "" { if line == "" {
continue continue
} }
if changed, _ := updateWorldState(state, line); changed { if changed, _ := updateWorldState(state, line); changed {
sawWorldChange = true
lastWorldAt = extractLineTime(line)
state.presentSet = map[string]struct{}{} state.presentSet = map[string]struct{}{}
handleWorldChange(state, state.worldName, lastWorldAt)
} }
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 { if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1]) name := strings.TrimSpace(m[1])
@@ -235,10 +291,12 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
} }
state.initialized = true state.initialized = true
if state.worldName != "" { if state.worldName != "" {
log.Printf("world changed: %s", state.worldName) if !sawWorldChange {
SetCurrentWorld(state.worldName) log.Printf("world changed: %s", state.worldName)
if tracker := GetGuestTracker(); tracker != nil { if lastWorldAt.IsZero() {
tracker.SetCurrentInstance(state.worldName) lastWorldAt = time.Now()
}
handleWorldChange(state, state.worldName, lastWorldAt)
} }
if err := appendRuntimeLog("VRC WORLD", state.worldName); err != nil { if err := appendRuntimeLog("VRC WORLD", state.worldName); err != nil {
log.Printf("append world log failed: %v", err) log.Printf("append world log failed: %v", err)
@@ -251,9 +309,6 @@ func decodeVRChatLog(b []byte) string {
if len(b) == 0 { if len(b) == 0 {
return "" return ""
} }
if utf8.Valid(b) {
return strings.TrimPrefix(string(b), "\ufeff")
}
if len(b) >= 2 { if len(b) >= 2 {
if b[0] == 0xff && b[1] == 0xfe { if b[0] == 0xff && b[1] == 0xfe {
u16 := make([]uint16, 0, (len(b)-2)/2) u16 := make([]uint16, 0, (len(b)-2)/2)
@@ -270,12 +325,23 @@ func decodeVRChatLog(b []byte) string {
return strings.TrimPrefix(fixMojibake(syscall.UTF16ToString(u16)), "\ufeff") return strings.TrimPrefix(fixMojibake(syscall.UTF16ToString(u16)), "\ufeff")
} }
} }
for _, cp := range []uint32{932, 1252, 65001} { lines := bytes.Split(b, []byte{'\n'})
if decoded := decodeWithCodePage(b, cp); decoded != "" { var out strings.Builder
return strings.TrimPrefix(fixMojibake(decoded), "\ufeff") for i, raw := range lines {
if i > 0 {
out.WriteByte('\n')
} }
out.WriteString(decodeVRChatLogLine(raw))
} }
return strings.TrimPrefix(fixMojibake(string(b)), "\ufeff") return strings.TrimPrefix(out.String(), "\ufeff")
}
func decodeVRChatLogLine(b []byte) string {
line := bytes.TrimRight(b, "\r")
if len(line) == 0 {
return ""
}
return strings.TrimPrefix(string(line), "\ufeff")
} }
func DecodeVRChatLog(b []byte) string { func DecodeVRChatLog(b []byte) string {
@@ -402,6 +468,9 @@ func updateWorldState(state *vrcLogState, line string) (bool, string) {
nextLocation = worldID + ":" + instanceID nextLocation = worldID + ":" + instanceID
} }
} }
if nextLocation == "" && roomTitle != "" {
nextLocation = roomTitle
}
if nextLocation != "" && nextLocation != state.location { if nextLocation != "" && nextLocation != state.location {
state.location = nextLocation state.location = nextLocation
@@ -421,8 +490,8 @@ func updateWorldState(state *vrcLogState, line string) (bool, string) {
} }
if state.initialized { if state.initialized {
log.Printf("world changed: %s", label) log.Printf("world changed: %s", label)
_ = appendRuntimeLog("VRC WORLD", label)
} }
state.initialized = true
return true, label return true, label
} }
@@ -489,3 +558,16 @@ func extractLineTime(line string) time.Time {
} }
return time.Now() return time.Now()
} }
func handleWorldChange(state *vrcLogState, worldLabel string, at time.Time) {
if state == nil {
return
}
SetCurrentWorldVisit(worldLabel, at)
if tracker := GetWorldHistoryTracker(); tracker != nil {
tracker.ObserveWorld(worldLabel, state.worldID, state.instanceID, at)
}
if tracker := GetGuestTracker(); tracker != nil {
tracker.ResetCurrentInstance(worldLabel)
}
}

View File

@@ -0,0 +1,43 @@
package app
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestFindLatestVrchatLogKeepsEmptyNewestFile(t *testing.T) {
base := t.TempDir()
userProfile := filepath.Join(base, "profile")
logDir := filepath.Join(userProfile, "AppData", "LocalLow", "VRChat", "VRChat")
if err := os.MkdirAll(logDir, 0o755); err != nil {
t.Fatalf("mkdir log dir: %v", err)
}
t.Setenv("USERPROFILE", userProfile)
oldPath := filepath.Join(logDir, "output_log_2026-06-29_20-46-23.txt")
newPath := filepath.Join(logDir, "output_log_2026-06-30_22-25-51.txt")
if err := os.WriteFile(oldPath, []byte("old"), 0o644); err != nil {
t.Fatalf("write old log: %v", err)
}
if err := os.WriteFile(newPath, nil, 0o644); err != nil {
t.Fatalf("write new log: %v", err)
}
oldTime := time.Date(2026, 6, 30, 22, 25, 52, 0, time.Local)
newTime := time.Date(2026, 6, 30, 22, 26, 0, 0, time.Local)
if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
t.Fatalf("chtimes old log: %v", err)
}
if err := os.Chtimes(newPath, newTime, newTime); err != nil {
t.Fatalf("chtimes new log: %v", err)
}
got, err := findLatestVrchatLog()
if err != nil {
t.Fatalf("find latest log: %v", err)
}
if got != newPath {
t.Fatalf("expected newest empty log, got %q want %q", got, newPath)
}
}

View File

@@ -0,0 +1,260 @@
package app
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"vrc_osc_go/internal/common"
)
const worldHistoryFileName = "world_history.json"
type WorldVisit struct {
WorldLabel string `json:"world_label"`
WorldID string `json:"world_id,omitempty"`
InstanceID string `json:"instance_id,omitempty"`
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at,omitempty"`
}
type worldHistorySnapshot struct {
UpdatedAt time.Time `json:"updated_at"`
Visits []WorldVisit `json:"visits"`
}
type WorldHistoryTracker struct {
mu sync.RWMutex
current *WorldVisit
visits []WorldVisit
}
var worldHistoryTracker *WorldHistoryTracker
func InitWorldHistoryTracker(t *WorldHistoryTracker) {
worldHistoryTracker = t
}
func GetWorldHistoryTracker() *WorldHistoryTracker {
return worldHistoryTracker
}
func NewWorldHistoryTracker() *WorldHistoryTracker {
t := &WorldHistoryTracker{}
_ = t.load()
return t
}
func (t *WorldHistoryTracker) load() error {
dir := filepath.Join(common.RootDir(), "runtime")
path := filepath.Join(dir, worldHistoryFileName)
b, err := os.ReadFile(path)
if err != nil {
return err
}
var snap worldHistorySnapshot
if err := json.Unmarshal(b, &snap); err != nil {
return err
}
t.visits = mergeWorldVisits(snap.Visits)
return nil
}
func (t *WorldHistoryTracker) ObserveWorld(worldLabel, worldID, instanceID string, at time.Time) {
if t == nil {
return
}
if at.IsZero() {
at = time.Now()
}
label := normalizeWorldLabel(worldLabel, worldID, instanceID)
key := worldVisitKey(worldID, instanceID, label)
t.mu.Lock()
defer t.mu.Unlock()
if t.current != nil && t.currentKey() == key {
if t.current.WorldLabel == "" {
t.current.WorldLabel = label
}
if t.current.WorldID == "" {
t.current.WorldID = worldID
}
if t.current.InstanceID == "" {
t.current.InstanceID = instanceID
}
return
}
if t.current != nil {
if t.current.EndedAt.IsZero() {
t.current.EndedAt = at
}
t.visits = append(t.visits, *t.current)
}
t.current = &WorldVisit{
WorldLabel: label,
WorldID: worldID,
InstanceID: instanceID,
StartedAt: at,
}
_ = t.persistLocked()
}
func (t *WorldHistoryTracker) FinalizeCurrent(at time.Time) {
if t == nil {
return
}
if at.IsZero() {
at = time.Now()
}
t.mu.Lock()
defer t.mu.Unlock()
if t.current == nil {
return
}
if t.current.EndedAt.IsZero() {
t.current.EndedAt = at
}
t.visits = append(t.visits, *t.current)
t.current = nil
_ = t.persistLocked()
}
func (t *WorldHistoryTracker) Current() (WorldVisit, bool) {
if t == nil {
return WorldVisit{}, false
}
t.mu.RLock()
defer t.mu.RUnlock()
if t.current == nil {
return WorldVisit{}, false
}
return *t.current, true
}
func (t *WorldHistoryTracker) Snapshot() []WorldVisit {
if t == nil {
return nil
}
t.mu.RLock()
defer t.mu.RUnlock()
out := make([]WorldVisit, 0, len(t.visits)+1)
out = append(out, mergeWorldVisits(t.visits)...)
if t.current != nil {
out = append(out, *t.current)
}
sort.SliceStable(out, func(i, j int) bool {
ti := out[i].EndedAt
if ti.IsZero() {
ti = out[i].StartedAt
}
tj := out[j].EndedAt
if tj.IsZero() {
tj = out[j].StartedAt
}
if ti.Equal(tj) {
return strings.ToLower(out[i].WorldLabel) < strings.ToLower(out[j].WorldLabel)
}
return ti.After(tj)
})
return out
}
func (t *WorldHistoryTracker) currentKey() string {
if t.current == nil {
return ""
}
return worldVisitKey(t.current.WorldID, t.current.InstanceID, t.current.WorldLabel)
}
func (t *WorldHistoryTracker) persistLocked() error {
dir := filepath.Join(common.RootDir(), "runtime")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
f, err := os.Create(filepath.Join(dir, worldHistoryFileName))
if err != nil {
return err
}
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
return enc.Encode(worldHistorySnapshot{
UpdatedAt: time.Now(),
Visits: mergeWorldVisits(t.visits),
})
}
func mergeWorldVisits(visits []WorldVisit) []WorldVisit {
if len(visits) <= 1 {
return append([]WorldVisit(nil), visits...)
}
type bucket struct {
visit WorldVisit
}
seen := make(map[string]int, len(visits))
out := make([]bucket, 0, len(visits))
for _, visit := range visits {
key := worldVisitKey(visit.WorldID, visit.InstanceID, visit.WorldLabel) + "|" + visit.StartedAt.UTC().Format(time.RFC3339Nano)
if idx, ok := seen[key]; ok {
if visit.EndedAt.After(out[idx].visit.EndedAt) {
out[idx].visit.EndedAt = visit.EndedAt
}
if strings.TrimSpace(out[idx].visit.WorldLabel) == "" && strings.TrimSpace(visit.WorldLabel) != "" {
out[idx].visit.WorldLabel = visit.WorldLabel
}
continue
}
seen[key] = len(out)
out = append(out, bucket{visit: visit})
}
sort.SliceStable(out, func(i, j int) bool {
ti := out[i].visit.EndedAt
if ti.IsZero() {
ti = out[i].visit.StartedAt
}
tj := out[j].visit.EndedAt
if tj.IsZero() {
tj = out[j].visit.StartedAt
}
if ti.Equal(tj) {
return strings.ToLower(out[i].visit.WorldLabel) < strings.ToLower(out[j].visit.WorldLabel)
}
return ti.After(tj)
})
merged := make([]WorldVisit, 0, len(out))
for _, item := range out {
merged = append(merged, item.visit)
}
return merged
}
func worldVisitKey(worldID, instanceID, worldLabel string) string {
if worldID != "" {
if instanceID != "" {
return worldID + ":" + instanceID
}
return worldID
}
return strings.TrimSpace(worldLabel)
}
func normalizeWorldLabel(worldLabel, worldID, instanceID string) string {
label := strings.TrimSpace(worldLabel)
if label != "" {
return label
}
if worldID != "" {
if instanceID != "" {
return worldID + ":" + instanceID
}
return worldID
}
return "(unknown)"
}

View File

@@ -33,20 +33,33 @@ type VrcLogConfig struct {
} }
type GUIConfig struct { type GUIConfig struct {
TopMost bool TopMost bool
FontSize int FontSize int
AutoUnmuteOnSelfLeave bool AutoUnmuteOnSelfLeave bool
RefreshIntervalValue int RefreshIntervalValue int
RefreshIntervalUnit string RefreshIntervalUnit string
HistoryRegex string HistoryRegex string
HistoryFromDate string HistoryFromDate string
HistoryToDate string HistoryToDate string
HistoryExportDir string
HistoryExportIncludeTime bool
HistoryExportIncludeWorld bool
HistoryExportIncludeJoinLeave bool
HistoryExportCustomEnabled bool
HistoryExportCustom string
} }
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
cfg := &Config{ cfg := &Config{
OSC: OSCConfig{Host: "127.0.0.1", Port: 9001}, OSC: OSCConfig{Host: "127.0.0.1", Port: 9001},
GUI: GUIConfig{FontSize: 18, RefreshIntervalValue: 2, RefreshIntervalUnit: "sec"}, GUI: GUIConfig{
FontSize: 18,
RefreshIntervalValue: 2,
RefreshIntervalUnit: "sec",
HistoryExportIncludeTime: true,
HistoryExportIncludeWorld: true,
HistoryExportIncludeJoinLeave: true,
},
} }
if path == "" { if path == "" {
path = filepath.Join(common.RootDir(), "config", "config.toml") path = filepath.Join(common.RootDir(), "config", "config.toml")
@@ -76,8 +89,7 @@ func Load(path string) (*Config, error) {
continue continue
} }
key := strings.TrimSpace(parts[0]) key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1]) val := parseConfigValue(parts[1])
val = strings.Trim(val, "\"'")
switch section { switch section {
case "osc": case "osc":
switch key { switch key {
@@ -135,6 +147,18 @@ func Load(path string) (*Config, error) {
cfg.GUI.HistoryFromDate = normalizeHistoryDate(val) cfg.GUI.HistoryFromDate = normalizeHistoryDate(val)
case "history_to": case "history_to":
cfg.GUI.HistoryToDate = normalizeHistoryDate(val) cfg.GUI.HistoryToDate = normalizeHistoryDate(val)
case "history_export_dir":
cfg.GUI.HistoryExportDir = strings.TrimSpace(val)
case "history_export_include_time":
cfg.GUI.HistoryExportIncludeTime = parseBool(val, true)
case "history_export_include_world":
cfg.GUI.HistoryExportIncludeWorld = parseBool(val, true)
case "history_export_include_join_leave":
cfg.GUI.HistoryExportIncludeJoinLeave = parseBool(val, true)
case "history_export_custom_enabled":
cfg.GUI.HistoryExportCustomEnabled = parseBool(val, false)
case "history_export_custom":
cfg.GUI.HistoryExportCustom = strings.TrimSpace(val)
} }
} }
} }
@@ -144,6 +168,8 @@ func Load(path string) (*Config, error) {
cfg.GUI.RefreshIntervalUnit = normalizeRefreshIntervalUnit(cfg.GUI.RefreshIntervalUnit) cfg.GUI.RefreshIntervalUnit = normalizeRefreshIntervalUnit(cfg.GUI.RefreshIntervalUnit)
cfg.GUI.HistoryFromDate = normalizeHistoryDate(cfg.GUI.HistoryFromDate) cfg.GUI.HistoryFromDate = normalizeHistoryDate(cfg.GUI.HistoryFromDate)
cfg.GUI.HistoryToDate = normalizeHistoryDate(cfg.GUI.HistoryToDate) cfg.GUI.HistoryToDate = normalizeHistoryDate(cfg.GUI.HistoryToDate)
cfg.GUI.HistoryExportDir = strings.TrimSpace(cfg.GUI.HistoryExportDir)
cfg.GUI.HistoryExportCustom = strings.TrimSpace(cfg.GUI.HistoryExportCustom)
if cfg.VrcLog.GuestFile != "" { if cfg.VrcLog.GuestFile != "" {
if !filepath.IsAbs(cfg.VrcLog.GuestFile) { if !filepath.IsAbs(cfg.VrcLog.GuestFile) {
cfg.VrcLog.GuestFile = filepath.Join(common.RootDir(), cfg.VrcLog.GuestFile) cfg.VrcLog.GuestFile = filepath.Join(common.RootDir(), cfg.VrcLog.GuestFile)
@@ -155,6 +181,17 @@ func Load(path string) (*Config, error) {
return cfg, scanner.Err() return cfg, scanner.Err()
} }
func parseConfigValue(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if unquoted, err := strconv.Unquote(raw); err == nil {
return strings.TrimSpace(unquoted)
}
return strings.TrimSpace(strings.Trim(raw, "\"'"))
}
func SaveGUI(path string, gui GUIConfig) error { func SaveGUI(path string, gui GUIConfig) error {
path = resolvePath(path) path = resolvePath(path)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
@@ -263,6 +300,12 @@ func appendGUISection(out *[]string, gui GUIConfig) {
fmt.Sprintf("history_regex = %q", gui.HistoryRegex), fmt.Sprintf("history_regex = %q", gui.HistoryRegex),
fmt.Sprintf("history_from = %q", gui.HistoryFromDate), fmt.Sprintf("history_from = %q", gui.HistoryFromDate),
fmt.Sprintf("history_to = %q", gui.HistoryToDate), fmt.Sprintf("history_to = %q", gui.HistoryToDate),
fmt.Sprintf("history_export_dir = %q", strings.TrimSpace(gui.HistoryExportDir)),
fmt.Sprintf("history_export_include_time = %t", gui.HistoryExportIncludeTime),
fmt.Sprintf("history_export_include_world = %t", gui.HistoryExportIncludeWorld),
fmt.Sprintf("history_export_include_join_leave = %t", gui.HistoryExportIncludeJoinLeave),
fmt.Sprintf("history_export_custom_enabled = %t", gui.HistoryExportCustomEnabled),
fmt.Sprintf("history_export_custom = %q", strings.TrimSpace(gui.HistoryExportCustom)),
) )
} }

View File

@@ -35,14 +35,14 @@ func TestLoadOSCValues(t *testing.T) {
func TestLoadGUIValues(t *testing.T) { func TestLoadGUIValues(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "config.toml") path := filepath.Join(dir, "config.toml")
if err := os.WriteFile(path, []byte("[gui]\ntop_most = true\nfont_size = 24\nauto_unmute_on_self_leave = true\nrefresh_interval_value = 5\nrefresh_interval_unit = \"min\"\nhistory_regex = \"[wip]\"\nhistory_from = \"2026-01-01\"\nhistory_to = \"2026-01-31\"\n"), 0o600); err != nil { if err := os.WriteFile(path, []byte("[gui]\ntop_most = true\nfont_size = 24\nauto_unmute_on_self_leave = true\nrefresh_interval_value = 5\nrefresh_interval_unit = \"min\"\nhistory_regex = \"[wip]\"\nhistory_from = \"2026-01-01\"\nhistory_to = \"2026-01-31\"\nhistory_export_dir = \"C:/tmp/visit\"\nhistory_export_include_time = false\nhistory_export_include_world = true\nhistory_export_include_join_leave = true\nhistory_export_custom_enabled = true\nhistory_export_custom = \"[wip]\"\n"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err) t.Fatalf("WriteFile: %v", err)
} }
cfg, err := Load(path) cfg, err := Load(path)
if err != nil { if err != nil {
t.Fatalf("Load returned error: %v", err) t.Fatalf("Load returned error: %v", err)
} }
if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 24 || cfg.GUI.AutoUnmuteOnSelfLeave != true || cfg.GUI.RefreshIntervalValue != 5 || cfg.GUI.RefreshIntervalUnit != "min" || cfg.GUI.HistoryRegex != "[wip]" || cfg.GUI.HistoryFromDate != "2026-01-01" || cfg.GUI.HistoryToDate != "2026-01-31" { if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 24 || cfg.GUI.AutoUnmuteOnSelfLeave != true || cfg.GUI.RefreshIntervalValue != 5 || cfg.GUI.RefreshIntervalUnit != "min" || cfg.GUI.HistoryRegex != "[wip]" || cfg.GUI.HistoryFromDate != "2026-01-01" || cfg.GUI.HistoryToDate != "2026-01-31" || cfg.GUI.HistoryExportDir != "C:/tmp/visit" || cfg.GUI.HistoryExportIncludeTime != false || cfg.GUI.HistoryExportIncludeWorld != true || cfg.GUI.HistoryExportIncludeJoinLeave != true || cfg.GUI.HistoryExportCustomEnabled != true || cfg.GUI.HistoryExportCustom != "[wip]" {
t.Fatalf("unexpected gui values: %+v", cfg.GUI) t.Fatalf("unexpected gui values: %+v", cfg.GUI)
} }
} }
@@ -54,14 +54,14 @@ func TestSaveGUIUpdatesSection(t *testing.T) {
if err := os.WriteFile(path, initial, 0o600); err != nil { if err := os.WriteFile(path, initial, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err) t.Fatalf("WriteFile: %v", err)
} }
if err := SaveGUI(path, GUIConfig{TopMost: true, FontSize: 26, AutoUnmuteOnSelfLeave: true, RefreshIntervalValue: 3, RefreshIntervalUnit: "sec", HistoryRegex: "[wip]", HistoryFromDate: "2026-01-01", HistoryToDate: "2026-01-31"}); err != nil { if err := SaveGUI(path, GUIConfig{TopMost: true, FontSize: 26, AutoUnmuteOnSelfLeave: true, RefreshIntervalValue: 3, RefreshIntervalUnit: "sec", HistoryRegex: "[wip]", HistoryFromDate: "2026-01-01", HistoryToDate: "2026-01-31", HistoryExportDir: "C:/tmp/visit", HistoryExportIncludeTime: false, HistoryExportIncludeWorld: true, HistoryExportIncludeJoinLeave: true, HistoryExportCustomEnabled: true, HistoryExportCustom: "[wip]"}); err != nil {
t.Fatalf("SaveGUI returned error: %v", err) t.Fatalf("SaveGUI returned error: %v", err)
} }
cfg, err := Load(path) cfg, err := Load(path)
if err != nil { if err != nil {
t.Fatalf("Load returned error: %v", err) t.Fatalf("Load returned error: %v", err)
} }
if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 26 || cfg.GUI.AutoUnmuteOnSelfLeave != true || cfg.GUI.RefreshIntervalValue != 3 || cfg.GUI.RefreshIntervalUnit != "sec" || cfg.GUI.HistoryRegex != "[wip]" || cfg.GUI.HistoryFromDate != "2026-01-01" || cfg.GUI.HistoryToDate != "2026-01-31" { if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 26 || cfg.GUI.AutoUnmuteOnSelfLeave != true || cfg.GUI.RefreshIntervalValue != 3 || cfg.GUI.RefreshIntervalUnit != "sec" || cfg.GUI.HistoryRegex != "[wip]" || cfg.GUI.HistoryFromDate != "2026-01-01" || cfg.GUI.HistoryToDate != "2026-01-31" || cfg.GUI.HistoryExportDir != "C:/tmp/visit" || cfg.GUI.HistoryExportIncludeTime != false || cfg.GUI.HistoryExportIncludeWorld != true || cfg.GUI.HistoryExportIncludeJoinLeave != true || cfg.GUI.HistoryExportCustomEnabled != true || cfg.GUI.HistoryExportCustom != "[wip]" {
t.Fatalf("unexpected saved gui values: %+v", cfg.GUI) t.Fatalf("unexpected saved gui values: %+v", cfg.GUI)
} }
data, err := os.ReadFile(path) data, err := os.ReadFile(path)