Compare commits
20 Commits
main
...
go-rewrite
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70bafabee9 | ||
|
|
8e704980c1 | ||
|
|
1b3d978137 | ||
|
|
51c00f2a75 | ||
|
|
6d318af329 | ||
|
|
a017170c71 | ||
|
|
d38d2edee3 | ||
|
|
01d5799914 | ||
|
|
f9968895ac | ||
|
|
452c7c59b6 | ||
|
|
b059c91388 | ||
|
|
289337f016 | ||
|
|
dadc65065c | ||
|
|
4df1a5d71a | ||
|
|
b25e002092 | ||
|
|
dd165d9301 | ||
|
|
3ff33e40e3 | ||
|
|
ae426244e0 | ||
|
|
4d77e67870 | ||
|
|
75f2d8b9c2 |
@@ -25,28 +25,26 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
cd VRWT_Tool/VRC_OSC
|
GO111MODULE=on go test ./internal/...
|
||||||
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)"
|
||||||
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-H windowsgui -X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc.exe ./cmd/vrc_osc
|
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-H windowsgui -X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc.exe ./cmd/vrc_osc
|
||||||
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-H windowsgui -X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc_launcher.exe ./cmd/vrc_osc_launcher
|
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-H windowsgui -X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc_launcher.exe ./cmd/vrc_osc_launcher
|
||||||
|
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-H windowsgui -X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc_gui.exe ./cmd/vrc_osc_gui
|
||||||
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrwt_tool.exe ./cmd/vrwt_tool
|
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrwt_tool.exe ./cmd/vrwt_tool
|
||||||
|
|
||||||
- name: Package executables
|
- 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
|
||||||
(cd dist && zip -r vrc_osc-windows.zip vrc_osc.exe vrc_osc_launcher.exe vrwt_tool.exe config.example.toml secrets.example.toml guests.example.txt)
|
(cd dist && zip -r vrc_osc-windows.zip vrc_osc.exe vrc_osc_launcher.exe vrc_osc_gui.exe vrwt_tool.exe config.example.toml secrets.example.toml guests.example.txt)
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
env:
|
env:
|
||||||
@@ -72,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
@@ -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_*/
|
||||||
|
|||||||
2
EXE.md
@@ -7,6 +7,7 @@ Release の zip は Gitea Actions で作ります。
|
|||||||
```powershell
|
```powershell
|
||||||
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc.exe ./cmd\vrc_osc
|
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc.exe ./cmd\vrc_osc
|
||||||
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_launcher.exe ./cmd\vrc_osc_launcher
|
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_launcher.exe ./cmd\vrc_osc_launcher
|
||||||
|
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_gui.exe ./cmd\vrc_osc_gui
|
||||||
go build -o .\dist\vrwt_tool.exe ./cmd\vrwt_tool
|
go build -o .\dist\vrwt_tool.exe ./cmd\vrwt_tool
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -26,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\`
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ Release の zip には次のファイルが入ります。
|
|||||||
go test ./...
|
go test ./...
|
||||||
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc.exe ./cmd/vrc_osc
|
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc.exe ./cmd/vrc_osc
|
||||||
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_launcher.exe ./cmd/vrc_osc_launcher
|
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_launcher.exe ./cmd/vrc_osc_launcher
|
||||||
|
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_gui.exe ./cmd/vrc_osc_gui
|
||||||
go build -o .\dist\vrwt_tool.exe ./cmd\vrwt_tool
|
go build -o .\dist\vrwt_tool.exe ./cmd\vrwt_tool
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
13
VRWT_Tool/VRC_OSC/.gitignore
vendored
@@ -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_*/
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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 {'&':'&','<':'<','>':'>','"':'"'}[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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
GuestUserA
|
|
||||||
GuestUserB
|
|
||||||
GuestUserC
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
[discord]
|
|
||||||
webhook_url = ""
|
|
||||||
|
|
||||||
[vrchat]
|
|
||||||
username = ""
|
|
||||||
password = ""
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
module vrc_osc_go
|
|
||||||
|
|
||||||
go 1.22
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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() }
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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`
|
|
||||||
|
|
||||||
秘密情報を含む可能性があるものはコミットしない。
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
deep-translator
|
|
||||||
paddleocr
|
|
||||||
pillow
|
|
||||||
pyautogui
|
|
||||||
pygetwindow
|
|
||||||
pykakasi
|
|
||||||
pyperclip
|
|
||||||
python-osc
|
|
||||||
rapidfuzz
|
|
||||||
vrchatapi
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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(),
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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", "終了しました")
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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)
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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_",
|
|
||||||
"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()
|
|
||||||
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
@@ -4,18 +4,52 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"vrc_osc_go/internal/app"
|
"vrc_osc_go/internal/app"
|
||||||
|
"vrc_osc_go/internal/buildinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
log.Printf("vrc_osc version=%s build=%s", buildinfo.Version, buildinfo.BuildTime)
|
||||||
|
log.Printf("runtime main begin")
|
||||||
var configPath string
|
var configPath string
|
||||||
|
var mode string
|
||||||
|
var noGUI bool
|
||||||
flag.StringVar(&configPath, "config", "config/config.toml", "path to config.toml")
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
if err := app.Run(configPath); err != nil {
|
if mode == "" && !noGUI {
|
||||||
|
log.Printf("launching gui companion")
|
||||||
|
launchGUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("calling app.Run config=%s mode=%s noGUI=%v", configPath, mode, noGUI)
|
||||||
|
if err := app.Run(configPath, mode); err != nil {
|
||||||
log.SetOutput(os.Stderr)
|
log.SetOutput(os.Stderr)
|
||||||
log.Fatalf("vrc_osc_go: %v", err)
|
log.Fatalf("vrc_osc_go: %v", err)
|
||||||
}
|
}
|
||||||
|
log.Printf("app.Run returned cleanly")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func launchGUI() {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("launchGUI: os.Executable failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gui := filepath.Join(filepath.Dir(exe), "vrc_osc_gui.exe")
|
||||||
|
log.Printf("launchGUI: exe=%s gui=%s", exe, gui)
|
||||||
|
if _, err := os.Stat(gui); err != nil {
|
||||||
|
log.Printf("launchGUI: gui missing: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := exec.Command(gui).Start(); err != nil {
|
||||||
|
log.Printf("launchGUI: start failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("launchGUI: started gui companion")
|
||||||
|
}
|
||||||
|
|||||||
1525
cmd/vrc_osc_gui/history_view_windows.go
Normal file
99
cmd/vrc_osc_gui/main.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"vrc_osc_go/internal/app"
|
||||||
|
"vrc_osc_go/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
initialTab := parseInitialTab(os.Args[1:])
|
||||||
|
if exe, err := os.Executable(); err == nil {
|
||||||
|
_ = app.AppendRuntimeLog("GUI", "starting vrc_osc_gui.exe exe="+exe+" dir="+filepath.Dir(exe)+" initial_tab="+initialTab)
|
||||||
|
} else {
|
||||||
|
_ = app.AppendRuntimeLog("GUI", "starting vrc_osc_gui.exe exe=unknown err="+err.Error())
|
||||||
|
}
|
||||||
|
ensureRuntimeStarted()
|
||||||
|
if err := runNativeGUI(initialTab); err != nil {
|
||||||
|
_ = app.AppendRuntimeLog("GUI", "runNativeGUI failed: "+err.Error())
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = 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 "history", "hist":
|
||||||
|
return "history"
|
||||||
|
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)
|
||||||
|
}
|
||||||
5
cmd/vrc_osc_gui/main_nonwindows.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
func main() {}
|
||||||
BIN
cmd/vrc_osc_gui/rsrc_windows_amd64.syso
Normal file
232
cmd/vrc_osc_gui/tray_windows.go
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
wmUser = 0x0400
|
||||||
|
wmTray = wmUser + 1
|
||||||
|
wmCommand = 0x0111
|
||||||
|
wmDestroy = 0x0002
|
||||||
|
wmClose = 0x0010
|
||||||
|
wmRButtonUp = 0x0205
|
||||||
|
wmLButtonUp = 0x0202
|
||||||
|
wmCreate = 0x0001
|
||||||
|
wmQuit = 0x0012
|
||||||
|
niAdd = 0x00000000
|
||||||
|
niModify = 0x00000001
|
||||||
|
niDelete = 0x00000002
|
||||||
|
nifMessage = 0x00000001
|
||||||
|
nifIcon = 0x00000002
|
||||||
|
nifTip = 0x00000004
|
||||||
|
nifInfo = 0x00000010
|
||||||
|
nimPopup = 0x00000000
|
||||||
|
miString = 0x00000000
|
||||||
|
miSeparator = 0x00000800
|
||||||
|
miDefault = 0x00001000
|
||||||
|
swHide = 0
|
||||||
|
idOpen = 1001
|
||||||
|
idExit = 1002
|
||||||
|
maxTip = 128
|
||||||
|
maxClassName = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
type trayApp struct {
|
||||||
|
uiURL string
|
||||||
|
open func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTray(uiURL string, open func()) *trayApp {
|
||||||
|
return &trayApp{uiURL: uiURL, open: open}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *trayApp) Run() error {
|
||||||
|
return runTray(t.uiURL, t.open)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openBrowser(url string) {
|
||||||
|
_ = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func trayIconPath() string {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return filepath.Join("doc", "vrcworldtour_new.ico")
|
||||||
|
}
|
||||||
|
return filepath.Join(filepath.Dir(exe), "..", "doc", "vrcworldtour_new.ico")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTray(uiURL string, open func()) error {
|
||||||
|
user32 := syscall.NewLazyDLL("user32.dll")
|
||||||
|
shell32 := syscall.NewLazyDLL("shell32.dll")
|
||||||
|
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
||||||
|
|
||||||
|
registerClass := user32.NewProc("RegisterClassW")
|
||||||
|
createWindowEx := user32.NewProc("CreateWindowExW")
|
||||||
|
defWindowProc := user32.NewProc("DefWindowProcW")
|
||||||
|
getMessage := user32.NewProc("GetMessageW")
|
||||||
|
translateMessage := user32.NewProc("TranslateMessage")
|
||||||
|
dispatchMessage := user32.NewProc("DispatchMessageW")
|
||||||
|
postQuitMessage := user32.NewProc("PostQuitMessage")
|
||||||
|
createPopupMenu := user32.NewProc("CreatePopupMenu")
|
||||||
|
appendMenu := user32.NewProc("AppendMenuW")
|
||||||
|
setForegroundWindow := user32.NewProc("SetForegroundWindow")
|
||||||
|
trackPopupMenu := user32.NewProc("TrackPopupMenu")
|
||||||
|
showWindow := user32.NewProc("ShowWindow")
|
||||||
|
loadImage := user32.NewProc("LoadImageW")
|
||||||
|
shellNotifyIcon := shell32.NewProc("Shell_NotifyIconW")
|
||||||
|
getModuleHandle := kernel32.NewProc("GetModuleHandleW")
|
||||||
|
|
||||||
|
type wndClassEx struct {
|
||||||
|
cbSize uint32
|
||||||
|
style uint32
|
||||||
|
lpfnWndProc uintptr
|
||||||
|
cbClsExtra int32
|
||||||
|
cbWndExtra int32
|
||||||
|
hInstance uintptr
|
||||||
|
hIcon uintptr
|
||||||
|
hCursor uintptr
|
||||||
|
hbrBackground uintptr
|
||||||
|
lpszMenuName *uint16
|
||||||
|
lpszClassName *uint16
|
||||||
|
hIconSm uintptr
|
||||||
|
}
|
||||||
|
type point struct{ X, Y int32 }
|
||||||
|
type msg struct {
|
||||||
|
hwnd uintptr
|
||||||
|
message uint32
|
||||||
|
wParam uintptr
|
||||||
|
lParam uintptr
|
||||||
|
time uint32
|
||||||
|
pt point
|
||||||
|
}
|
||||||
|
type notifyIconData struct {
|
||||||
|
cbSize uint32
|
||||||
|
hWnd uintptr
|
||||||
|
uID uint32
|
||||||
|
uFlags uint32
|
||||||
|
uCallbackMessage uint32
|
||||||
|
hIcon uintptr
|
||||||
|
szTip [maxTip]uint16
|
||||||
|
dwState uint32
|
||||||
|
dwStateMask uint32
|
||||||
|
szInfo [256]uint16
|
||||||
|
uTimeoutVersion uint32
|
||||||
|
szInfoTitle [64]uint16
|
||||||
|
dwInfoFlags uint32
|
||||||
|
guidItem [16]byte
|
||||||
|
hBalloonIcon uintptr
|
||||||
|
}
|
||||||
|
|
||||||
|
className, _ := syscall.UTF16PtrFromString("VRC_OSC_TRAY")
|
||||||
|
windowTitle, _ := syscall.UTF16PtrFromString("VRC OSC Tray")
|
||||||
|
iconPath := trayIconPath()
|
||||||
|
iconPathPtr, _ := syscall.UTF16PtrFromString(iconPath)
|
||||||
|
const (
|
||||||
|
lrLoadFromFile = 0x00000010
|
||||||
|
imageIcon = 1
|
||||||
|
)
|
||||||
|
icon, _, _ := loadImage.Call(0, uintptr(unsafe.Pointer(iconPathPtr)), imageIcon, 0, 0, lrLoadFromFile)
|
||||||
|
|
||||||
|
var hwnd uintptr
|
||||||
|
var menu uintptr
|
||||||
|
var nid notifyIconData
|
||||||
|
|
||||||
|
wndProc := syscall.NewCallback(func(hwnd uintptr, msg uint32, wParam, lParam uintptr) uintptr {
|
||||||
|
switch msg {
|
||||||
|
case wmCreate:
|
||||||
|
return 0
|
||||||
|
case wmTray:
|
||||||
|
switch lParam {
|
||||||
|
case wmLButtonUp:
|
||||||
|
if open != nil {
|
||||||
|
open()
|
||||||
|
}
|
||||||
|
case wmRButtonUp:
|
||||||
|
if menu == 0 {
|
||||||
|
m, _, _ := createPopupMenu.Call()
|
||||||
|
menu = m
|
||||||
|
openText, _ := syscall.UTF16PtrFromString("Open UI")
|
||||||
|
exitText, _ := syscall.UTF16PtrFromString("Exit")
|
||||||
|
appendMenu.Call(menu, miString|miDefault, idOpen, uintptr(unsafe.Pointer(openText)))
|
||||||
|
appendMenu.Call(menu, miString, idExit, uintptr(unsafe.Pointer(exitText)))
|
||||||
|
}
|
||||||
|
setForegroundWindow.Call(hwnd)
|
||||||
|
var p point
|
||||||
|
user32.NewProc("GetCursorPos").Call(uintptr(unsafe.Pointer(&p)))
|
||||||
|
trackPopupMenu.Call(menu, nimPopup, uintptr(p.X), uintptr(p.Y), 0, hwnd, 0)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
case wmCommand:
|
||||||
|
switch uint16(wParam & 0xffff) {
|
||||||
|
case idOpen:
|
||||||
|
if open != nil {
|
||||||
|
open()
|
||||||
|
}
|
||||||
|
case idExit:
|
||||||
|
user32.NewProc("DestroyWindow").Call(hwnd)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
case wmDestroy:
|
||||||
|
nid.cbSize = uint32(unsafe.Sizeof(nid))
|
||||||
|
nid.hWnd = hwnd
|
||||||
|
nid.uID = 1
|
||||||
|
shellNotifyIcon.Call(niDelete, uintptr(unsafe.Pointer(&nid)))
|
||||||
|
postQuitMessage.Call(0)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
ret, _, _ := defWindowProc.Call(hwnd, uintptr(msg), wParam, lParam)
|
||||||
|
return ret
|
||||||
|
})
|
||||||
|
|
||||||
|
hInstance, _, _ := getModuleHandle.Call(0)
|
||||||
|
_, _, regErr := registerClass.Call(uintptr(unsafe.Pointer(&wndClassEx{
|
||||||
|
cbSize: uint32(unsafe.Sizeof(wndClassEx{})),
|
||||||
|
lpfnWndProc: wndProc,
|
||||||
|
hInstance: hInstance,
|
||||||
|
hIcon: icon,
|
||||||
|
lpszClassName: className,
|
||||||
|
})))
|
||||||
|
if regErr != nil {
|
||||||
|
return fmt.Errorf("RegisterClassW failed: %v", regErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
hwnd, _, err := createWindowEx.Call(0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(windowTitle)), 0, 0, 0, 0, 0, 0, 0, hInstance, 0)
|
||||||
|
if hwnd == 0 {
|
||||||
|
return fmt.Errorf("CreateWindowExW failed: %v", err)
|
||||||
|
}
|
||||||
|
showWindow.Call(hwnd, swHide)
|
||||||
|
|
||||||
|
nid.cbSize = uint32(unsafe.Sizeof(nid))
|
||||||
|
nid.hWnd = hwnd
|
||||||
|
nid.uID = 1
|
||||||
|
nid.uFlags = nifMessage | nifIcon | nifTip
|
||||||
|
nid.uCallbackMessage = wmTray
|
||||||
|
nid.hIcon = icon
|
||||||
|
tip := syscall.StringToUTF16("VRC OSC")
|
||||||
|
copy(nid.szTip[:], tip)
|
||||||
|
if ok, _, _ := shellNotifyIcon.Call(niAdd, uintptr(unsafe.Pointer(&nid))); ok == 0 {
|
||||||
|
return fmt.Errorf("Shell_NotifyIconW add failed")
|
||||||
|
}
|
||||||
|
log.Printf("tray started")
|
||||||
|
|
||||||
|
var m msg
|
||||||
|
for {
|
||||||
|
r, _, _ := getMessage.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0)
|
||||||
|
if int32(r) <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
translateMessage.Call(uintptr(unsafe.Pointer(&m)))
|
||||||
|
dispatchMessage.Call(uintptr(unsafe.Pointer(&m)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
4729
cmd/vrc_osc_gui/window_windows.go
Normal file
6
cmd/vrc_osc_launcher/launcher_icon.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// This file exists so the launcher package has a Windows build target
|
||||||
|
// alongside the generated .syso resource file.
|
||||||
@@ -4,15 +4,20 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"vrc_osc_go/internal/app"
|
||||||
"vrc_osc_go/internal/buildinfo"
|
"vrc_osc_go/internal/buildinfo"
|
||||||
"vrc_osc_go/internal/common"
|
"vrc_osc_go/internal/common"
|
||||||
"vrc_osc_go/internal/update"
|
"vrc_osc_go/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
_ = appendLauncherLog("LAUNCHER", "launcher main begin")
|
||||||
var baseURL string
|
var baseURL string
|
||||||
var ownerRepo string
|
var ownerRepo string
|
||||||
var assetName string
|
var assetName string
|
||||||
@@ -30,16 +35,34 @@ func main() {
|
|||||||
}
|
}
|
||||||
if _, err := mgr.CheckAndUpdate(); err != nil {
|
if _, err := mgr.CheckAndUpdate(); err != nil {
|
||||||
log.Printf("auto update skipped: %v", err)
|
log.Printf("auto update skipped: %v", err)
|
||||||
|
_ = appendLauncherLog("LAUNCHER", "auto update skipped: "+err.Error())
|
||||||
|
} else {
|
||||||
|
_ = appendLauncherLog("LAUNCHER", "auto update ok")
|
||||||
}
|
}
|
||||||
exe := filepath.Join(common.RootDir(), "vrc_osc.exe")
|
base := runtimeBaseDir()
|
||||||
|
exe := filepath.Join(base, "vrc_osc.exe")
|
||||||
|
gui := filepath.Join(base, "vrc_osc_gui.exe")
|
||||||
|
_ = appendLauncherLog("LAUNCHER", "runtimeBaseDir="+base)
|
||||||
if _, err := os.Stat(exe); err != nil {
|
if _, err := os.Stat(exe); err != nil {
|
||||||
|
_ = appendLauncherLog("LAUNCHER", "missing runtime exe: "+err.Error())
|
||||||
showError("vrc_osc_launcher", "missing exe: "+err.Error())
|
showError("vrc_osc_launcher", "missing exe: "+err.Error())
|
||||||
log.Fatalf("missing exe: %v", err)
|
log.Fatalf("missing exe: %v", err)
|
||||||
}
|
}
|
||||||
if err := update.Launch(exe, os.Args[1:]); err != nil {
|
_ = appendLauncherLog("LAUNCHER", "version="+buildinfo.Version+" build="+buildinfo.BuildTime+" base="+base+" exe="+exe+" gui="+gui)
|
||||||
|
if _, err := os.Stat(gui); err == nil {
|
||||||
|
_ = appendLauncherLog("LAUNCHER", "starting gui companion")
|
||||||
|
_ = exec.Command(gui).Start()
|
||||||
|
} else {
|
||||||
|
_ = appendLauncherLog("LAUNCHER", "gui companion missing: "+err.Error())
|
||||||
|
}
|
||||||
|
args := append([]string{"--no-gui"}, os.Args[1:]...)
|
||||||
|
_ = appendLauncherLog("LAUNCHER", "launching runtime args="+strings.Join(args, " "))
|
||||||
|
if err := update.Launch(exe, args); err != nil {
|
||||||
|
_ = appendLauncherLog("LAUNCHER", "launch failed: "+err.Error())
|
||||||
showError("vrc_osc_launcher", "launch failed: "+err.Error())
|
showError("vrc_osc_launcher", "launch failed: "+err.Error())
|
||||||
log.Fatalf("launch failed: %v", err)
|
log.Fatalf("launch failed: %v", err)
|
||||||
}
|
}
|
||||||
|
_ = appendLauncherLog("LAUNCHER", "runtime launch returned")
|
||||||
}
|
}
|
||||||
|
|
||||||
func showError(title, message string) {
|
func showError(title, message string) {
|
||||||
@@ -49,8 +72,23 @@ func showError(title, message string) {
|
|||||||
const mbIconError = 0x00000010
|
const mbIconError = 0x00000010
|
||||||
_, _, _ = msgBox.Call(
|
_, _, _ = msgBox.Call(
|
||||||
0,
|
0,
|
||||||
uintptr(syscall.StringToUTF16Ptr(message)),
|
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(message))),
|
||||||
uintptr(syscall.StringToUTF16Ptr(title)),
|
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(title))),
|
||||||
uintptr(mbOK|mbIconError),
|
uintptr(mbOK|mbIconError),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runtimeBaseDir() string {
|
||||||
|
root := common.RootDir()
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "vrc_osc.exe")); err == nil {
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "tmp", "vrc_osc.exe")); err == nil {
|
||||||
|
return filepath.Join(root, "tmp")
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendLauncherLog(title, text string) error {
|
||||||
|
return app.AppendRuntimeLog(title, text)
|
||||||
|
}
|
||||||
|
|||||||
BIN
cmd/vrc_osc_launcher/rsrc_windows_amd64.syso
Normal file
@@ -7,6 +7,19 @@ names = []
|
|||||||
[guest]
|
[guest]
|
||||||
file = "config/guests.txt"
|
file = "config/guests.txt"
|
||||||
|
|
||||||
|
[gui]
|
||||||
|
top_most = false
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
35
config/config.test.toml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
[self]
|
||||||
|
name = "毎日がHoliday'"
|
||||||
|
|
||||||
|
[staff]
|
||||||
|
file = "config/staff.txt"
|
||||||
|
[guest]
|
||||||
|
file = "config/guests.txt"
|
||||||
|
|
||||||
|
[gui]
|
||||||
|
top_most = false
|
||||||
|
font_size = 18
|
||||||
|
|
||||||
|
[notice]
|
||||||
|
missing_count = 0
|
||||||
|
|
||||||
|
[vrc_log]
|
||||||
|
patterns = [
|
||||||
|
"OnPlayerJoined",
|
||||||
|
"OnPlayerLeft",
|
||||||
|
"Entering Room",
|
||||||
|
"Joining or Creating Room",
|
||||||
|
"worldId=",
|
||||||
|
"wrld_",
|
||||||
|
]
|
||||||
|
|
||||||
|
[ocr.crop]
|
||||||
|
left = 0.05
|
||||||
|
top = 0.35
|
||||||
|
right = 0.95
|
||||||
|
bottom = 0.95
|
||||||
|
|
||||||
|
[ocr]
|
||||||
|
preprocess = true
|
||||||
|
scale = 1.5
|
||||||
|
use_angle_cls = false
|
||||||
BIN
doc/vrcworldtour.png
Normal file
|
After Width: | Height: | Size: 732 KiB |
BIN
doc/vrcworldtour_new.ico
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
doc/vrcworldtour_new.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
124
doc/workflow.md
@@ -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 に保存]
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,37 +1,100 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"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/osc"
|
"vrc_osc_go/internal/osc"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Run(configPath string) error {
|
type discordMuteState struct {
|
||||||
cfg, err := config.Load(configPath)
|
mu sync.Mutex
|
||||||
if err != nil {
|
muted bool
|
||||||
|
buttonPressed bool
|
||||||
|
lastAction time.Time
|
||||||
|
lastPressEdge time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run(configPath string, mode string) error {
|
||||||
|
if mode != "" {
|
||||||
|
return consenttool.Run(configPath, mode)
|
||||||
|
}
|
||||||
|
log.Printf("app run begin config=%s mode=%s", configPath, mode)
|
||||||
|
if err := setupRuntimeLogger(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
log.Printf("runtime logger ready")
|
||||||
|
|
||||||
|
cfg, err := config.Load(configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("config load failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("app version=%s build=%s", buildinfo.Version, buildinfo.BuildTime)
|
||||||
|
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)
|
||||||
|
|
||||||
server := osc.NewServer(cfg.OSC.Host, cfg.OSC.Port)
|
server := osc.NewServer(cfg.OSC.Host, cfg.OSC.Port)
|
||||||
|
discordState := &discordMuteState{muted: true}
|
||||||
|
SetDiscordMuted(true)
|
||||||
server.Map("DiscordSend", func(_ string, args []osc.Value) error {
|
server.Map("DiscordSend", func(_ string, args []osc.Value) error {
|
||||||
return fmt.Errorf("discord mute not implemented yet")
|
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
|
||||||
|
})
|
||||||
|
server.Map("translation", func(_ string, args []osc.Value) error {
|
||||||
|
log.Printf("received translation args=%+v", args)
|
||||||
|
if err := runPythonTranslationFromLastOCR(); err != nil {
|
||||||
|
log.Printf("translation failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
go func() { errCh <- server.Serve() }()
|
go func() {
|
||||||
|
log.Printf("osc server listening on %s:%d", cfg.OSC.Host, cfg.OSC.Port)
|
||||||
|
errCh <- server.Serve()
|
||||||
|
}()
|
||||||
|
log.Printf("starting vrchat log watcher")
|
||||||
|
go watchVrchatLog()
|
||||||
|
log.Printf("vrchat log watcher goroutine started")
|
||||||
|
startSelfMonitor(cfg, discordState)
|
||||||
|
log.Printf("self monitor started")
|
||||||
|
|
||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||||
select {
|
select {
|
||||||
case err := <-errCh:
|
case err := <-errCh:
|
||||||
|
log.Printf("osc server stopped: %v", err)
|
||||||
return err
|
return err
|
||||||
case <-sigCh:
|
case <-sigCh:
|
||||||
|
log.Printf("shutdown signal received")
|
||||||
|
if tracker := GetWorldHistoryTracker(); tracker != nil {
|
||||||
|
tracker.FinalizeCurrent(time.Now())
|
||||||
|
}
|
||||||
server.Close()
|
server.Close()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
internal/app/codepage_nonwindows.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
func decodeWithCodePage(_ []byte, _ uint32) string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
37
internal/app/codepage_windows.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
func decodeWithCodePage(b []byte, codePage uint32) string {
|
||||||
|
if len(b) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
||||||
|
multiByteToWideChar := kernel32.NewProc("MultiByteToWideChar")
|
||||||
|
n, _, _ := multiByteToWideChar.Call(
|
||||||
|
uintptr(codePage),
|
||||||
|
0,
|
||||||
|
uintptr(unsafe.Pointer(&b[0])),
|
||||||
|
uintptr(len(b)),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if n == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
buf := make([]uint16, n)
|
||||||
|
multiByteToWideChar.Call(
|
||||||
|
uintptr(codePage),
|
||||||
|
0,
|
||||||
|
uintptr(unsafe.Pointer(&b[0])),
|
||||||
|
uintptr(len(b)),
|
||||||
|
uintptr(unsafe.Pointer(&buf[0])),
|
||||||
|
uintptr(len(buf)),
|
||||||
|
)
|
||||||
|
return utf16ToString(buf)
|
||||||
|
}
|
||||||
@@ -10,43 +10,6 @@ func pressDiscordMuteHotkey() error {
|
|||||||
script := `
|
script := `
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
Add-Type @"
|
|
||||||
using System;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
public static class Win32 {
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
public struct RECT {
|
|
||||||
public int Left;
|
|
||||||
public int Top;
|
|
||||||
public int Right;
|
|
||||||
public int Bottom;
|
|
||||||
}
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
public static extern bool SetCursorPos(int X, int Y);
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
public static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo);
|
|
||||||
}
|
|
||||||
"@
|
|
||||||
|
|
||||||
function Click-WindowCenter($p) {
|
|
||||||
if ($null -eq $p) { return }
|
|
||||||
$hWnd = [IntPtr]$p.MainWindowHandle
|
|
||||||
if ($hWnd -eq [IntPtr]::Zero) { return }
|
|
||||||
|
|
||||||
$rect = New-Object 'Win32+RECT'
|
|
||||||
if (-not [Win32]::GetWindowRect($hWnd, [ref]$rect)) { return }
|
|
||||||
|
|
||||||
$x = [int](($rect.Left + $rect.Right) / 2)
|
|
||||||
$y = [int](($rect.Top + $rect.Bottom) / 2)
|
|
||||||
[Win32]::SetCursorPos($x, $y) | Out-Null
|
|
||||||
Start-Sleep -Milliseconds 50
|
|
||||||
[Win32]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero)
|
|
||||||
[Win32]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero)
|
|
||||||
Start-Sleep -Milliseconds 100
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-DiscordWindows {
|
function Get-DiscordWindows {
|
||||||
$windows = foreach ($p in Get-Process) {
|
$windows = foreach ($p in Get-Process) {
|
||||||
$title = ($p.MainWindowTitle | Out-String).Trim()
|
$title = ($p.MainWindowTitle | Out-String).Trim()
|
||||||
@@ -92,7 +55,6 @@ function Activate-Window($p) {
|
|||||||
$wshell = New-Object -ComObject WScript.Shell
|
$wshell = New-Object -ComObject WScript.Shell
|
||||||
try { $null = $wshell.AppActivate($p.Id) } catch {}
|
try { $null = $wshell.AppActivate($p.Id) } catch {}
|
||||||
Start-Sleep -Milliseconds 200
|
Start-Sleep -Milliseconds 200
|
||||||
Click-WindowCenter $p
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$discord = Get-BestDiscordWindow
|
$discord = Get-BestDiscordWindow
|
||||||
|
|||||||
@@ -30,38 +30,65 @@ func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
|
|||||||
|
|
||||||
if isPressed && !state.buttonPressed {
|
if isPressed && !state.buttonPressed {
|
||||||
state.buttonPressed = true
|
state.buttonPressed = true
|
||||||
if state.muted {
|
log.Printf("ACTION VRChat button pressed -> Discord mute toggle")
|
||||||
log.Printf("ACTION VRChat button pressed -> Discord unmute hotkey")
|
|
||||||
if err := pressDiscordMuteHotkey(); err != nil {
|
if err := pressDiscordMuteHotkey(); err != nil {
|
||||||
return err
|
log.Printf("discord hotkey failed: %v", err)
|
||||||
}
|
SetDiscordAction("hotkey_failed")
|
||||||
state.muted = false
|
|
||||||
SetDiscordMuted(state.muted)
|
|
||||||
state.lastAction = now
|
|
||||||
} else {
|
} else {
|
||||||
log.Printf("INFO already unmuted")
|
state.muted = !state.muted
|
||||||
|
SetDiscordMuted(state.muted)
|
||||||
|
if state.muted {
|
||||||
|
SetDiscordAction("muted")
|
||||||
|
} else {
|
||||||
|
SetDiscordAction("unmuted")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
state.lastAction = now
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isPressed && state.buttonPressed {
|
if !isPressed && state.buttonPressed {
|
||||||
state.buttonPressed = false
|
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
|
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':
|
||||||
@@ -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()
|
||||||
58
internal/app/guest_status_test.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vrc_osc_go/internal/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGuestTrackerJoinLeave(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
prev := common.SetRootDirForTest(func() string { return base })
|
||||||
|
defer prev()
|
||||||
|
|
||||||
|
tracker := NewGuestTracker([]string{"Alice", "Bob"})
|
||||||
|
now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.Local)
|
||||||
|
|
||||||
|
tracker.MarkJoin("Alice", now)
|
||||||
|
tracker.MarkJoin("Bob", now.Add(10*time.Second))
|
||||||
|
|
||||||
|
got := tracker.Snapshot(now.Add(20 * time.Second))
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("expected 2 guests, got %d", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
present := 0
|
||||||
|
for _, g := range got {
|
||||||
|
if g.Present {
|
||||||
|
present++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if present != 2 {
|
||||||
|
t.Fatalf("expected 2 present guests, got %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
tracker.MarkLeave("Alice", now.Add(30*time.Second))
|
||||||
|
got = tracker.Snapshot(now.Add(40 * time.Second))
|
||||||
|
|
||||||
|
var alice GuestStatus
|
||||||
|
for _, g := range got {
|
||||||
|
if g.Name == "Alice" {
|
||||||
|
alice = g
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if alice.Present {
|
||||||
|
t.Fatalf("expected Alice to be absent, got %#v", alice)
|
||||||
|
}
|
||||||
|
if alice.AbsentFor == "" {
|
||||||
|
t.Fatalf("expected Alice absent duration, got %#v", alice)
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshotPath := filepath.Join(base, "runtime", "guest_snapshot.json")
|
||||||
|
if _, err := os.Stat(snapshotPath); err != nil {
|
||||||
|
t.Fatalf("expected snapshot file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
214
internal/app/join_leave_events.go
Normal 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
|
||||||
|
}
|
||||||
26
internal/app/join_leave_events_test.go
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
128
internal/app/ocr.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OcrResult struct {
|
||||||
|
ImagePath string
|
||||||
|
TextPath string
|
||||||
|
Text string
|
||||||
|
Translate string
|
||||||
|
ErrReason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func runPythonScript(script string) (string, error) {
|
||||||
|
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("python output: %s", string(out))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return string(out), fmt.Errorf("python failed: %w", err)
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func capturePythonOcr() (map[string]any, error) {
|
||||||
|
script := `
|
||||||
|
import sys, json
|
||||||
|
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
|
||||||
|
result = runOcrFromScreen()
|
||||||
|
print(json.dumps(result, ensure_ascii=False))
|
||||||
|
`
|
||||||
|
out, err := runPythonScript(script)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||||
|
for i := len(lines) - 1; i >= 0; i-- {
|
||||||
|
line := strings.TrimSpace(lines[i])
|
||||||
|
if strings.HasPrefix(line, "{") && strings.HasSuffix(line, "}") {
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(line), &m); err == nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("ocr result json not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runPythonOcrFromScreen() error {
|
||||||
|
log.Printf("ocr trigger begin")
|
||||||
|
result, err := capturePythonOcr()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ocr trigger failed: %v", err)
|
||||||
|
SetOCRText("OCRテキストなし")
|
||||||
|
SetTranslateText("翻訳エンジン未設定")
|
||||||
|
_ = AppendRuntimeLog("OCR ERROR", err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
text, _ := result["text"].(string)
|
||||||
|
imagePath, _ := result["image_path"].(string)
|
||||||
|
textPath, _ := result["text_path"].(string)
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
text = "OCRテキストなし"
|
||||||
|
}
|
||||||
|
SetOCRText(text)
|
||||||
|
SetTranslateText("")
|
||||||
|
_ = AppendRuntimeLog("OCR INPUT", fmt.Sprintf("image=%s\n%s", imagePath, text))
|
||||||
|
if textPath != "" {
|
||||||
|
_ = AppendRuntimeLog("OCR TEXT", fmt.Sprintf("image=%s\ntext_path=%s\n%s", imagePath, textPath, text))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runPythonTranslationFromLastOCR() error {
|
||||||
|
runtime := SnapshotRuntime()
|
||||||
|
source := strings.TrimSpace(runtime.LastOCRText)
|
||||||
|
if source == "" || source == "OCRテキストなし" {
|
||||||
|
SetTranslateText("OCRテキストなし")
|
||||||
|
_ = AppendRuntimeLog("TRANSLATION ERROR", "OCRテキストなし")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("translation trigger begin")
|
||||||
|
SetTranslateText("翻訳実行中")
|
||||||
|
_ = AppendRuntimeLog("TRANSLATION INPUT", source)
|
||||||
|
script := `
|
||||||
|
import sys, json
|
||||||
|
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 translate.translate_actions import translateTextToJapanese, saveTranslationText
|
||||||
|
text = sys.argv[1]
|
||||||
|
translated = translateTextToJapanese(text)
|
||||||
|
print(translated or "")
|
||||||
|
if translated:
|
||||||
|
saveTranslationText("runtime", translated)
|
||||||
|
`
|
||||||
|
cmd := exec.Command("python", "-c", script, source)
|
||||||
|
cmd.Dir = `C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC`
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if len(out) > 0 {
|
||||||
|
log.Printf("translation output: %s", string(out))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
SetTranslateText("翻訳失敗")
|
||||||
|
_ = AppendRuntimeLog("TRANSLATION ERROR", err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
translated := strings.TrimSpace(string(out))
|
||||||
|
if translated == "" {
|
||||||
|
SetTranslateText("翻訳結果なし")
|
||||||
|
_ = AppendRuntimeLog("TRANSLATION ERROR", "翻訳結果なし")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
SetTranslateText(translated)
|
||||||
|
_ = AppendRuntimeLog("TRANSLATION RESULT", translated)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
28
internal/app/refresh_interval.go
Normal 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)
|
||||||
|
}
|
||||||
153
internal/app/runtime.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vrc_osc_go/internal/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type runtimeLogWriter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
file *os.File
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *runtimeLogWriter) Write(p []byte) (int, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if w.file == nil {
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
dir := filepath.Join(common.RootDir(), "runtime")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "runtime.log")
|
||||||
|
if err := runtimeLogFileWriter.ensureFile(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.SetOutput(io.MultiWriter(os.Stderr, &runtimeLogFileWriter))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)"
|
||||||
|
}
|
||||||
|
return runtimeLogFileWriter.appendf(path, "\n[%s] %s\n%s\n", time.Now().Format("2006-01-02 15:04:05"), title, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AppendRuntimeLog(title, text string) error {
|
||||||
|
return appendRuntimeLog(title, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)"
|
||||||
|
}
|
||||||
|
return joinLeaveLogFileWriter.appendf(path, "\n[%s] VRC JOIN/LEAVE\n%s\n", time.Now().Format("2006-01-02 15:04:05"), body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendDesktopJoinLog(title, worldLabel, text string) error {
|
||||||
|
dir := filepath.Join(os.Getenv("USERPROFILE"), "Desktop")
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
|
if home, herr := os.UserHomeDir(); herr == nil {
|
||||||
|
dir = filepath.Join(home, "Desktop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
dir = filepath.Join(common.RootDir(), "runtime")
|
||||||
|
if mkErr := os.MkdirAll(dir, 0o755); mkErr != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "join.txt")
|
||||||
|
body := text
|
||||||
|
if body == "" {
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
fallbackDir := filepath.Join(common.RootDir(), "runtime")
|
||||||
|
if mkErr := os.MkdirAll(fallbackDir, 0o755); mkErr != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fallbackPath := filepath.Join(fallbackDir, "join.txt")
|
||||||
|
f, err = os.OpenFile(fallbackPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path = fallbackPath
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err = fmt.Fprintf(f, "%s\n", body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
desktopJoinLogLastPath = path
|
||||||
|
desktopJoinLogLastBody = body
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AppendDesktopJoinLog(title, worldLabel, text string) error {
|
||||||
|
return appendDesktopJoinLog(title, worldLabel, text)
|
||||||
|
}
|
||||||
@@ -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,28 +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")
|
||||||
}
|
}
|
||||||
} 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")
|
||||||
lastState = nextState
|
lastState = nextState
|
||||||
}
|
}
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(guiRefreshIntervalFromConfig(liveCfg))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -13,9 +13,13 @@ import (
|
|||||||
type RuntimeState struct {
|
type RuntimeState struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
DiscordMuted bool
|
DiscordMuted bool
|
||||||
|
DiscordWindow string
|
||||||
|
DiscordSource string
|
||||||
|
DiscordAction string
|
||||||
LastOCRText string
|
LastOCRText string
|
||||||
LastTranslate string
|
LastTranslate string
|
||||||
CurrentWorld string
|
CurrentWorld string
|
||||||
|
CurrentWorldSince string
|
||||||
}
|
}
|
||||||
|
|
||||||
var runtimeState = &RuntimeState{}
|
var runtimeState = &RuntimeState{}
|
||||||
@@ -39,6 +43,27 @@ func SetDiscordMuted(v bool) {
|
|||||||
_ = persistRuntimeState()
|
_ = persistRuntimeState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SetDiscordWindow(v string) {
|
||||||
|
runtimeState.mu.Lock()
|
||||||
|
runtimeState.DiscordWindow = v
|
||||||
|
runtimeState.mu.Unlock()
|
||||||
|
_ = persistRuntimeState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetDiscordSource(v string) {
|
||||||
|
runtimeState.mu.Lock()
|
||||||
|
runtimeState.DiscordSource = v
|
||||||
|
runtimeState.mu.Unlock()
|
||||||
|
_ = persistRuntimeState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetDiscordAction(v string) {
|
||||||
|
runtimeState.mu.Lock()
|
||||||
|
runtimeState.DiscordAction = v
|
||||||
|
runtimeState.mu.Unlock()
|
||||||
|
_ = persistRuntimeState()
|
||||||
|
}
|
||||||
|
|
||||||
func SetOCRText(v string) {
|
func SetOCRText(v string) {
|
||||||
runtimeState.mu.Lock()
|
runtimeState.mu.Lock()
|
||||||
runtimeState.LastOCRText = v
|
runtimeState.LastOCRText = v
|
||||||
@@ -60,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()
|
||||||
@@ -81,9 +129,13 @@ func persistRuntimeState() error {
|
|||||||
enc.SetIndent("", " ")
|
enc.SetIndent("", " ")
|
||||||
return enc.Encode(map[string]any{
|
return enc.Encode(map[string]any{
|
||||||
"discord_muted": s.DiscordMuted,
|
"discord_muted": s.DiscordMuted,
|
||||||
|
"discord_window": s.DiscordWindow,
|
||||||
|
"discord_source": s.DiscordSource,
|
||||||
|
"discord_action": s.DiscordAction,
|
||||||
"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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf16"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -22,6 +24,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
|
||||||
@@ -77,6 +81,12 @@ func findLatestVrchatLog() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func watchVrchatLog() {
|
func watchVrchatLog() {
|
||||||
|
log.Printf("vrchat log watcher started")
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("vrchat log watcher panic: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
lastPath := ""
|
lastPath := ""
|
||||||
lastSize := int64(0)
|
lastSize := int64(0)
|
||||||
state := &vrcLogState{
|
state := &vrcLogState{
|
||||||
@@ -85,27 +95,33 @@ func watchVrchatLog() {
|
|||||||
for {
|
for {
|
||||||
path, err := findLatestVrchatLog()
|
path, err := findLatestVrchatLog()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
time.Sleep(2 * time.Second)
|
log.Printf("vrchat log not found: %v", err)
|
||||||
|
time.Sleep(currentGUIRefreshInterval())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
time.Sleep(2 * time.Second)
|
log.Printf("vrchat log stat failed: %v", err)
|
||||||
|
time.Sleep(currentGUIRefreshInterval())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
log.Printf("vrchat log loop path=%s size=%d last=%d", path, info.Size(), lastSize)
|
||||||
if path != lastPath {
|
if path != lastPath {
|
||||||
log.Printf("vrchat log watching %s", path)
|
log.Printf("vrchat log watching %s", path)
|
||||||
lastPath = path
|
lastPath = path
|
||||||
lastSize = 0
|
lastSize = 0
|
||||||
|
log.Printf("vrchat log initial scan begin path=%s", path)
|
||||||
if err := scanExistingVrchatLog(path, state); err != nil {
|
if err := scanExistingVrchatLog(path, state); err != nil {
|
||||||
log.Printf("vrchat log initial scan failed: %v", err)
|
log.Printf("vrchat log initial scan failed: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("vrchat log initial scan ok path=%s", path)
|
||||||
}
|
}
|
||||||
if info2, err := os.Stat(path); err == nil {
|
if info2, err := os.Stat(path); err == nil {
|
||||||
lastSize = info2.Size()
|
lastSize = info2.Size()
|
||||||
} else {
|
} else {
|
||||||
lastSize = info.Size()
|
lastSize = info.Size()
|
||||||
}
|
}
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(currentGUIRefreshInterval())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,65 +129,126 @@ func watchVrchatLog() {
|
|||||||
lastSize = 0
|
lastSize = 0
|
||||||
}
|
}
|
||||||
if info.Size() > lastSize {
|
if info.Size() > lastSize {
|
||||||
f, err := os.Open(path)
|
log.Printf("vrchat log updated path=%s old=%d new=%d", path, lastSize, info.Size())
|
||||||
|
b, err := readFileFrom(path, lastSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
time.Sleep(2 * time.Second)
|
log.Printf("vrchat log read failed: %v", err)
|
||||||
|
time.Sleep(currentGUIRefreshInterval())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := decodeVRChatLog(b)
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
joinHits := 0
|
||||||
|
leaveHits := 0
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimRight(line, "\r")
|
||||||
|
if line == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_, _ = f.Seek(lastSize, 0)
|
|
||||||
scanner := bufio.NewScanner(f)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
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)
|
||||||
|
joinHits++
|
||||||
state.presentSet[name] = struct{}{}
|
state.presentSet[name] = struct{}{}
|
||||||
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)
|
||||||
_ = appendJoinLeaveLog(out)
|
if err := appendJoinLeaveLog(out); err != nil {
|
||||||
|
log.Printf("append join log failed: %v", err)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
|
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||||
name := strings.TrimSpace(m[1])
|
name := strings.TrimSpace(m[1])
|
||||||
|
log.Printf("leave detected: %s", name)
|
||||||
|
leaveHits++
|
||||||
delete(state.presentSet, name)
|
delete(state.presentSet, name)
|
||||||
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)
|
||||||
_ = appendJoinLeaveLog(out)
|
if err := appendJoinLeaveLog(out); err != nil {
|
||||||
|
log.Printf("append leave log failed: %v", err)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
log.Printf("vrchat log batch done join=%d leave=%d", joinHits, leaveHits)
|
||||||
lastSize = info.Size()
|
lastSize = info.Size()
|
||||||
_ = f.Close()
|
|
||||||
}
|
}
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(currentGUIRefreshInterval())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
func readFileFrom(path string, offset int64) ([]byte, error) {
|
||||||
f, err := os.Open(path)
|
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 {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
state.presentSet = map[string]struct{}{}
|
state.presentSet = map[string]struct{}{}
|
||||||
state.location = ""
|
state.location = ""
|
||||||
state.worldID = ""
|
state.worldID = ""
|
||||||
@@ -180,11 +257,19 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
|||||||
state.pendingWorldName = ""
|
state.pendingWorldName = ""
|
||||||
state.initialized = false
|
state.initialized = false
|
||||||
|
|
||||||
scanner := bufio.NewScanner(f)
|
text := decodeVRChatLog(b)
|
||||||
for scanner.Scan() {
|
var lastWorldAt time.Time
|
||||||
line := scanner.Text()
|
sawWorldChange := false
|
||||||
|
for _, raw := range strings.Split(text, "\n") {
|
||||||
|
line := strings.TrimRight(raw, "\r")
|
||||||
|
if line == "" {
|
||||||
|
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])
|
||||||
@@ -203,21 +288,128 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
state.initialized = true
|
state.initialized = true
|
||||||
if state.worldName != "" {
|
if state.worldName != "" {
|
||||||
|
if !sawWorldChange {
|
||||||
log.Printf("world changed: %s", state.worldName)
|
log.Printf("world changed: %s", state.worldName)
|
||||||
SetCurrentWorld(state.worldName)
|
if lastWorldAt.IsZero() {
|
||||||
if tracker := GetGuestTracker(); tracker != nil {
|
lastWorldAt = time.Now()
|
||||||
tracker.SetCurrentInstance(state.worldName)
|
}
|
||||||
|
handleWorldChange(state, state.worldName, lastWorldAt)
|
||||||
|
}
|
||||||
|
if err := appendRuntimeLog("VRC WORLD", state.worldName); err != nil {
|
||||||
|
log.Printf("append world log failed: %v", err)
|
||||||
}
|
}
|
||||||
_ = appendRuntimeLog("VRC WORLD", state.worldName)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func decodeVRChatLog(b []byte) string {
|
||||||
|
if len(b) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(b) >= 2 {
|
||||||
|
if b[0] == 0xff && b[1] == 0xfe {
|
||||||
|
u16 := make([]uint16, 0, (len(b)-2)/2)
|
||||||
|
for i := 2; i+1 < len(b); i += 2 {
|
||||||
|
u16 = append(u16, uint16(b[i])|uint16(b[i+1])<<8)
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(fixMojibake(utf16ToString(u16)), "\ufeff")
|
||||||
|
}
|
||||||
|
if b[0] == 0xfe && b[1] == 0xff {
|
||||||
|
u16 := make([]uint16, 0, (len(b)-2)/2)
|
||||||
|
for i := 2; i+1 < len(b); i += 2 {
|
||||||
|
u16 = append(u16, uint16(b[i+1])|uint16(b[i])<<8)
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(fixMojibake(utf16ToString(u16)), "\ufeff")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines := bytes.Split(b, []byte{'\n'})
|
||||||
|
var out strings.Builder
|
||||||
|
for i, raw := range lines {
|
||||||
|
if i > 0 {
|
||||||
|
out.WriteByte('\n')
|
||||||
|
}
|
||||||
|
out.WriteString(decodeVRChatLogLine(raw))
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return decodeVRChatLog(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixMojibake(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
candidates := []string{s}
|
||||||
|
if repaired := tryRepairShiftJISMojibake(s); repaired != "" {
|
||||||
|
candidates = append(candidates, repaired)
|
||||||
|
}
|
||||||
|
best := s
|
||||||
|
bestScore := scoreReadable(best)
|
||||||
|
for _, cand := range candidates {
|
||||||
|
if score := scoreReadable(cand); score > bestScore {
|
||||||
|
best = cand
|
||||||
|
bestScore = score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryRepairShiftJISMojibake(s string) string {
|
||||||
|
raw := []byte(s)
|
||||||
|
// Try the common "UTF-8 bytes interpreted as CP932" pattern repair by
|
||||||
|
// round-tripping through CP932 in the reverse direction.
|
||||||
|
if repaired := decodeWithCodePage(raw, 932); repaired != "" && repaired != s {
|
||||||
|
return repaired
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func utf16ToString(u16 []uint16) string {
|
||||||
|
for i, r := range u16 {
|
||||||
|
if r == 0 {
|
||||||
|
u16 = u16[:i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(utf16.Decode(u16))
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreReadable(s string) int {
|
||||||
|
if s == "" {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
score := 0
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case r == '<27>':
|
||||||
|
score -= 8
|
||||||
|
case r >= 0x3040 && r <= 0x30ff:
|
||||||
|
score += 3
|
||||||
|
case r >= 0x4e00 && r <= 0x9fff:
|
||||||
|
score += 3
|
||||||
|
case r >= 0x0020 && r <= 0x007e:
|
||||||
|
score += 1
|
||||||
|
case r == '\n' || r == '\r' || r == '\t':
|
||||||
|
score += 1
|
||||||
|
default:
|
||||||
|
score -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
func updateWorldState(state *vrcLogState, line string) (bool, string) {
|
func updateWorldState(state *vrcLogState, line string) (bool, string) {
|
||||||
worldID := ""
|
worldID := ""
|
||||||
instanceID := ""
|
instanceID := ""
|
||||||
@@ -256,6 +448,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
|
||||||
@@ -275,8 +470,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,3 +538,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
internal/app/vrc_log_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
266
internal/app/world_history.go
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
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"`
|
||||||
|
Current *WorldVisit `json:"current,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
if snap.Current != nil && !snap.Current.StartedAt.IsZero() {
|
||||||
|
current := *snap.Current
|
||||||
|
t.current = ¤t
|
||||||
|
}
|
||||||
|
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),
|
||||||
|
Current: t.current,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)"
|
||||||
|
}
|
||||||
26
internal/common/project_paths.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
var rootDirFunc = RuntimeBaseDir
|
||||||
|
|
||||||
|
func RuntimeBaseDir() string {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err == nil {
|
||||||
|
return filepath.Dir(exe)
|
||||||
|
}
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
|
||||||
|
func RootDir() string { return rootDirFunc() }
|
||||||
|
|
||||||
|
func SetRootDirForTest(fn func() string) func() {
|
||||||
|
prev := rootDirFunc
|
||||||
|
rootDirFunc = fn
|
||||||
|
return func() {
|
||||||
|
rootDirFunc = prev
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,20 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vrc_osc_go/internal/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
OSC OSCConfig
|
OSC OSCConfig
|
||||||
|
VrcLog VrcLogConfig
|
||||||
|
GUI GUIConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
type OSCConfig struct {
|
type OSCConfig struct {
|
||||||
@@ -16,8 +23,49 @@ type OSCConfig struct {
|
|||||||
Port int
|
Port int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VrcLogConfig struct {
|
||||||
|
SelfName string
|
||||||
|
GuestNames []string
|
||||||
|
GuestFile string
|
||||||
|
StaffNames []string
|
||||||
|
MissingCount int
|
||||||
|
LogPatterns []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GUIConfig struct {
|
||||||
|
TopMost bool
|
||||||
|
FontSize int
|
||||||
|
AutoUnmuteOnSelfLeave bool
|
||||||
|
RefreshIntervalValue int
|
||||||
|
RefreshIntervalUnit string
|
||||||
|
HistoryRegex string
|
||||||
|
HistoryFromDate 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{OSC: OSCConfig{Host: "127.0.0.1", Port: 9001}}
|
cfg := &Config{
|
||||||
|
OSC: OSCConfig{Host: "127.0.0.1", Port: 9001},
|
||||||
|
GUI: GUIConfig{
|
||||||
|
FontSize: 14,
|
||||||
|
RefreshIntervalValue: 2,
|
||||||
|
RefreshIntervalUnit: "sec",
|
||||||
|
HistoryExportIncludeTime: true,
|
||||||
|
HistoryExportIncludeWorld: true,
|
||||||
|
HistoryExportIncludeJoinLeave: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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)
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
@@ -28,6 +76,7 @@ func Load(path string) (*Config, error) {
|
|||||||
scanner := bufio.NewScanner(file)
|
scanner := bufio.NewScanner(file)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := strings.TrimSpace(scanner.Text())
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
line = strings.TrimPrefix(line, "\ufeff")
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -40,9 +89,9 @@ 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 {
|
||||||
if section == "osc" {
|
case "osc":
|
||||||
switch key {
|
switch key {
|
||||||
case "host":
|
case "host":
|
||||||
cfg.OSC.Host = val
|
cfg.OSC.Host = val
|
||||||
@@ -51,8 +100,260 @@ func Load(path string) (*Config, error) {
|
|||||||
cfg.OSC.Port = n
|
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
|
||||||
|
}
|
||||||
|
case "gui":
|
||||||
|
switch key {
|
||||||
|
case "top_most":
|
||||||
|
cfg.GUI.TopMost = parseBool(val, cfg.GUI.TopMost)
|
||||||
|
case "font_size":
|
||||||
|
if n, err := strconv.Atoi(val); err == nil && n > 0 {
|
||||||
|
cfg.GUI.FontSize = n
|
||||||
|
}
|
||||||
|
case "auto_unmute_on_self_leave":
|
||||||
|
cfg.GUI.AutoUnmuteOnSelfLeave = parseBool(val, cfg.GUI.AutoUnmuteOnSelfLeave)
|
||||||
|
case "refresh_interval_value":
|
||||||
|
if n, err := strconv.Atoi(val); err == nil && n > 0 {
|
||||||
|
cfg.GUI.RefreshIntervalValue = n
|
||||||
|
}
|
||||||
|
case "refresh_interval_unit":
|
||||||
|
cfg.GUI.RefreshIntervalUnit = normalizeRefreshIntervalUnit(val)
|
||||||
|
case "history_regex":
|
||||||
|
cfg.GUI.HistoryRegex = val
|
||||||
|
case "history_from":
|
||||||
|
cfg.GUI.HistoryFromDate = normalizeHistoryDate(val)
|
||||||
|
case "history_to":
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.GUI.RefreshIntervalValue <= 0 {
|
||||||
|
cfg.GUI.RefreshIntervalValue = 2
|
||||||
|
}
|
||||||
|
cfg.GUI.RefreshIntervalUnit = normalizeRefreshIntervalUnit(cfg.GUI.RefreshIntervalUnit)
|
||||||
|
cfg.GUI.HistoryFromDate = normalizeHistoryDate(cfg.GUI.HistoryFromDate)
|
||||||
|
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 !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()
|
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 {
|
||||||
|
path = resolvePath(path)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
content := upsertGUISection(string(data), gui)
|
||||||
|
return os.WriteFile(path, []byte(content), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 parseBool(value string, fallback bool) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "true", "1", "on", "yes":
|
||||||
|
return true
|
||||||
|
case "false", "0", "off", "no":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvePath(path string) string {
|
||||||
|
if path == "" {
|
||||||
|
return filepath.Join(common.RootDir(), "config", "config.toml")
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
return filepath.Join(common.RootDir(), path)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertGUISection(existing string, gui GUIConfig) string {
|
||||||
|
normalized := strings.ReplaceAll(existing, "\r\n", "\n")
|
||||||
|
lines := strings.Split(normalized, "\n")
|
||||||
|
out := make([]string, 0, len(lines)+6)
|
||||||
|
inGUI := false
|
||||||
|
replaced := false
|
||||||
|
for _, raw := range lines {
|
||||||
|
line := raw
|
||||||
|
trimmed := strings.TrimSpace(strings.TrimPrefix(line, "\ufeff"))
|
||||||
|
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
||||||
|
section := strings.Trim(trimmed, "[]")
|
||||||
|
if section == "gui" {
|
||||||
|
if !replaced {
|
||||||
|
appendGUISection(&out, gui)
|
||||||
|
replaced = true
|
||||||
|
}
|
||||||
|
inGUI = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inGUI {
|
||||||
|
inGUI = false
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inGUI {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
if !replaced {
|
||||||
|
if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" {
|
||||||
|
out = append(out, "")
|
||||||
|
}
|
||||||
|
appendGUISection(&out, gui)
|
||||||
|
}
|
||||||
|
content := strings.Join(out, "\n")
|
||||||
|
content = strings.TrimRight(content, "\n")
|
||||||
|
return content + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendGUISection(out *[]string, gui GUIConfig) {
|
||||||
|
gui.RefreshIntervalUnit = normalizeRefreshIntervalUnit(gui.RefreshIntervalUnit)
|
||||||
|
if gui.RefreshIntervalValue <= 0 {
|
||||||
|
gui.RefreshIntervalValue = 2
|
||||||
|
}
|
||||||
|
*out = append(*out,
|
||||||
|
"[gui]",
|
||||||
|
fmt.Sprintf("top_most = %t", gui.TopMost),
|
||||||
|
fmt.Sprintf("font_size = %d", gui.FontSize),
|
||||||
|
fmt.Sprintf("auto_unmute_on_self_leave = %t", gui.AutoUnmuteOnSelfLeave),
|
||||||
|
fmt.Sprintf("refresh_interval_value = %d", gui.RefreshIntervalValue),
|
||||||
|
fmt.Sprintf("refresh_interval_unit = %q", gui.RefreshIntervalUnit),
|
||||||
|
fmt.Sprintf("history_regex = %q", gui.HistoryRegex),
|
||||||
|
fmt.Sprintf("history_from = %q", gui.HistoryFromDate),
|
||||||
|
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)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRefreshIntervalUnit(unit string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(unit)) {
|
||||||
|
case "min", "minute", "minutes":
|
||||||
|
return "min"
|
||||||
|
default:
|
||||||
|
return "sec"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g GUIConfig) RefreshInterval() time.Duration {
|
||||||
|
value := g.RefreshIntervalValue
|
||||||
|
if value <= 0 {
|
||||||
|
value = 2
|
||||||
|
}
|
||||||
|
switch normalizeRefreshIntervalUnit(g.RefreshIntervalUnit) {
|
||||||
|
case "min":
|
||||||
|
return time.Duration(value) * time.Minute
|
||||||
|
default:
|
||||||
|
return time.Duration(value) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeHistoryDate(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if _, err := time.Parse("2006-01-02", value); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,3 +32,44 @@ func TestLoadOSCValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadGUIValues(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
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\"\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)
|
||||||
|
}
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
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" || 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveGUIUpdatesSection(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.toml")
|
||||||
|
initial := []byte("[self]\nname = \"alice\"\n\n[gui]\ntop_most = false\nfont_size = 18\n")
|
||||||
|
if err := os.WriteFile(path, initial, 0o600); err != nil {
|
||||||
|
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", HistoryExportDir: "C:/tmp/visit", HistoryExportIncludeTime: false, HistoryExportIncludeWorld: true, HistoryExportIncludeJoinLeave: true, HistoryExportCustomEnabled: true, HistoryExportCustom: "[wip]"}); err != nil {
|
||||||
|
t.Fatalf("SaveGUI returned error: %v", err)
|
||||||
|
}
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
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" || 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)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile: %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
if !strings.Contains(text, "[self]") || !strings.Contains(text, "name = \"alice\"") {
|
||||||
|
t.Fatalf("non-gui config content was lost: %s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package osc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -33,7 +35,12 @@ func NewServer(host string, port int) *Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Map(param string, h Handler) { s.handlers["/avatar/parameters/"+param] = h }
|
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) Close() error {
|
||||||
|
if s.conn != nil {
|
||||||
|
return s.conn.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) Serve() error {
|
func (s *Server) Serve() error {
|
||||||
addr, err := net.ResolveUDPAddr("udp", s.addr)
|
addr, err := net.ResolveUDPAddr("udp", s.addr)
|
||||||
@@ -65,13 +72,70 @@ func (s *Server) Serve() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseMessage(b []byte) (string, []Value, error) {
|
func parseMessage(b []byte) (string, []Value, error) {
|
||||||
parts := bytes.SplitN(b, []byte{0}, 2)
|
address, offset, err := parsePaddedString(b, 0)
|
||||||
if len(parts) == 0 {
|
if err != nil {
|
||||||
return "", nil, fmt.Errorf("invalid osc")
|
return "", nil, err
|
||||||
}
|
}
|
||||||
address := string(parts[0])
|
|
||||||
if !strings.HasPrefix(address, "/") {
|
if !strings.HasPrefix(address, "/") {
|
||||||
return "", nil, fmt.Errorf("invalid address")
|
return "", nil, fmt.Errorf("invalid address")
|
||||||
}
|
}
|
||||||
|
if offset >= len(b) {
|
||||||
return address, nil, nil
|
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
|
||||||
}
|
}
|
||||||
|
|||||||