14 Commits

Author SHA1 Message Date
messypy
dd165d9301 Clean project layout and fix release workflow
Some checks failed
build-windows-exe / build (push) Failing after 1m13s
2026-07-27 23:01:24 +09:00
every_holiday
3ff33e40e3 Add history calendar export view 2026-06-30 11:03:17 +09:00
every_holiday
ae426244e0 Simplify GUI settings and build flags 2026-06-26 09:16:46 +09:00
every_holiday
4d77e67870 Stabilize GUI settings controls 2026-06-26 02:05:43 +09:00
every_holiday
75f2d8b9c2 Fix GUI instance counts and launcher tmp paths 2026-06-25 02:36:55 +09:00
every_holiday
23d5fcfcda Make launcher a Windows GUI app 2026-06-25 00:09:58 +09:00
every_holiday
329bcd0c50 Use windowsgui in release workflow
All checks were successful
build-windows-exe / build (push) Successful in 2m7s
2026-06-25 00:02:41 +09:00
every_holiday
93a0251082 Merge remote-tracking branch 'origin/main' into go-rewrite 2026-06-25 00:00:33 +09:00
every_holiday
6add92ef40 Improve Discord mute focus handling 2026-06-24 23:43:53 +09:00
every_holiday
63700d9d29 Restore VRTW_Tool title; note discord mute regression 2026-06-24 03:08:30 +09:00
every_holiday
ab9551c241 Improve GUI guest sorting and presence display 2026-06-24 02:38:06 +09:00
every_holiday
b6421958af Add Windows app icon assets 2026-06-24 02:17:04 +09:00
every_holiday
8bff7af6bc Add release auto-update launcher
All checks were successful
build-windows-exe / build (push) Successful in 1m47s
2026-06-24 02:08:19 +09:00
every_holiday
cbd8911c25 Port 2026-06-22 22:29:10 +09:00
90 changed files with 9383 additions and 3701 deletions

View File

@@ -25,25 +25,26 @@ jobs:
- name: Test
shell: bash
run: |
cd VRWT_Tool/VRC_OSC
GO111MODULE=on go test ./...
- name: Build executables
shell: bash
run: |
cd VRWT_Tool/VRC_OSC
mkdir -p dist
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o dist/vrc_osc.exe ./cmd/vrc_osc
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o dist/vrwt_tool.exe ./cmd/vrwt_tool
VERSION="${GITHUB_REF_NAME}"
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-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_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
- name: Package executables
shell: bash
run: |
cd VRWT_Tool/VRC_OSC
cp config/config.example.toml dist/config.example.toml
cp config/secrets.example.toml dist/secrets.example.toml
cp config/guests.example.txt dist/guests.example.txt
(cd dist && zip -r vrc_osc-windows.zip vrc_osc.exe 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
env:
@@ -69,7 +70,7 @@ jobs:
"${api}/repos/${owner_repo}/releases")
release_id=$(printf '%s' "${release_json}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
fi
asset_path="VRWT_Tool/VRC_OSC/dist/vrc_osc-windows.zip"
asset_path="dist/vrc_osc-windows.zip"
curl -fsSL -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${asset_path}" \

19
.gitignore vendored
View File

@@ -5,9 +5,28 @@ config/config.toml
config/secrets.toml
config/guests.txt
runtime/
dist/
.gocache/
.gomodcache/
.gotelemetry/
.gotmp/
.telemetry/
.appdata/
.tmp/
.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_*
backup_*/

1
.goenv Normal file
View File

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

35
EXE.md Normal file
View File

@@ -0,0 +1,35 @@
# Windows Release Packaging
## Build
Release の zip は Gitea Actions で作ります。
```powershell
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_gui.exe ./cmd\vrc_osc_gui
go build -o .\dist\vrwt_tool.exe ./cmd\vrwt_tool
```
## 起動
通常は `vrc_osc_launcher.exe` を起動してください。
- 起動時に最新 Release をチェック
- 新しい版があれば自動更新
- 更新後に本体を起動
`vrc_osc.exe` を直接起動した場合は更新チェックしません。
## 配置
Release zip を展開したフォルダに以下を置きます。
- `vrc_osc.exe`
- `vrc_osc_launcher.exe`
- `vrc_osc_gui.exe`
- `vrwt_tool.exe`
- `config\`
- `runtime\`
`runtime\` は起動時に作られます。

133
README.md
View File

@@ -1,123 +1,74 @@
# VRC_OSC
VRC_OSC は、VRChat の状態を見て Discord やログ連携を行うためのツールです。
VRChat のログ監視、Discord ミュート連携、OCR、同意リスト生成をまとめたツールです。
一番の使い方は、VRChat の `ExMenu` から操作することです。
`ExMenu` は VRChat のメニュー画面に出る、追加の操作メニューです。
## 主な機能
## できること
- VRChat のログを見て Discord に通知する
- Discord のミュートを VRChat 側の操作に合わせて切り替える
- VRChat 画面を読み取って OCR する
- OCR した文字を翻訳してコピーする
## 画面イメージ
### ExMenu
VRChat のメニュー画面から、このツールの機能を操作します。
![ExMenu 1](doc/ExMenu1.png)
![ExMenu 2](doc/ExMenu2.png)
### VRC Log
VRChat のログ確認や通知の流れは、次のようなイメージです。
![VRC Log](doc/VRC_Log.png)
## 使い方
1. 必要な設定ファイルを用意します。
2. ツールを起動します。
3. VRChat を開きます。
4. VRChat の `ExMenu` から必要な機能を選びます。
### Discord のミュート連動
VRChat 側で特定の操作が行われたときに、Discord のミュート状態を切り替えます。
通話中に VRChat を使うとき、Discord の音声操作を手で切り替える手間を減らすための機能です。
### OCR と翻訳
VRChat 画面の文字を読み取って、必要に応じて翻訳します。
見た文字や翻訳結果はコピーできるので、内容をそのまま使いやすくなります。
- VRChat の入退出ログを監視して `join / leave` を出力
- Discord のミュート切り替え
- OCR で画面文字を取得
- 同意フォーム用の JSON 生成とログ抽出
- Gitea Release からの自動更新
## 起動方法
PowerShell から起動する場合:
配布 zip を展開したら、まず `vrc_osc_launcher.exe` を起動してください。
```powershell
bin\vrc_osc.ps1
vrc_osc_launcher.exe
```
Python から直接起動する場合:
launcher は起動時に最新 Release を確認します。新しい版があれば自動で更新してから本体を起動します。
本体を直接起動することもできます。
```powershell
python src\app.py
vrc_osc.exe
```
## 事前準備
ただし、この場合は自動更新は走りません。
依存関係を入れます。
## 配布物
```powershell
python -m pip install -r requirements.txt
```
Release の zip には次のファイルが入ります。
## 設定ファイル
- `vrc_osc.exe`
- `vrc_osc_launcher.exe`
- `vrwt_tool.exe`
- `config.example.toml`
- `secrets.example.toml`
- `guests.example.txt`
設定は `config/` に置きます。
## 設定
設定ファイルは exe の横の `config/` に置きます。
- `config/config.toml`
- `config/secrets.toml`
- `config/guests.txt`
サンプルはそれぞれ `*.example.*`使って作成できます
最初は各 `*.example.*`コピーして使ってください
## 初期セットアップ
## Release 更新
最初は、`config/` のサンプルファイルを実ファイルにしてから中身を埋めます。
自動更新は Gitea の Release を参照します。
1. `config/config.example.toml``config/config.toml` にコピーします。
2. `config/secrets.example.toml``config/secrets.toml` にコピーします。
3. `config/guests.example.txt``config/guests.txt` にコピーします。
4. `config/config.toml``self.name` を自分の VRChat 表示名にします。
5. `config/secrets.toml``discord.webhook_url` を設定します。
6. `config/secrets.toml``vrchat.username``vrchat.password` を設定します。
7. `config/guests.txt` に、あらかじめ guest として見たい参加者名を 1 行ずつ書きます。
8. 自分の名前も guest 扱いしたい場合は、`guests.txt` に自分の名前も 1 行追加します。
- 更新元: `https://git.vrcworldtour.com/every_holiday/VRCWT-OSC/releases`
- 対象 asset: `vrc_osc-windows.zip`
`guests.txt` は、例として次のように書きます。
## 開発時のビルド
```text
GuestUserA
GuestUserB
YourNameHere
```powershell
go test ./...
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_gui.exe ./cmd/vrc_osc_gui
go build -o .\dist\vrwt_tool.exe ./cmd\vrwt_tool
```
## よく使う設定
## ログ
- `discord.webhook_url`
- Discord に通知を送るための Webhook URL
- `vrchat.username`
- VRChat のアカウント名
- `vrchat.password`
- VRChat のパスワード
- `staff.names`
- スタッフとして扱う名前
- `guest.file`
- guest 名簿のファイル
## 補足
この README は、まず使い方が分かることを優先して簡単にまとめています。
細かい内部仕様が必要な場合は、ソースコードと `config/*.example.*` を見てください。
## 詳しい資料
- [設計書](doc/design.md)
- [シーケンス図](doc/sequence.md)
- [ワークフロー](doc/workflow.md)
- `runtime/runtime.log`
- `runtime/join_leave.log`
- `runtime/latest_ocr.png`
- `runtime/latest_screenshot.png`

View File

@@ -1,50 +0,0 @@
name: build-windows-exe
on:
push:
jobs:
build:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Go
shell: pwsh
run: |
choco install golang --version=1.22.0 -y --no-progress
$env:PATH = "C:\Program Files\Go\bin;$env:PATH"
go version
- name: Test
working-directory: VRC_OSC
shell: pwsh
run: |
$env:PATH = "C:\Program Files\Go\bin;$env:PATH"
go test ./...
- name: Build executables
working-directory: VRC_OSC
shell: pwsh
run: |
$env:PATH = "C:\Program Files\Go\bin;$env:PATH"
go build -o dist\vrc_osc.exe ./cmd/vrc_osc
go build -o dist\vrwt_tool.exe ./cmd/vrwt_tool
- name: Package executables
working-directory: VRC_OSC
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path dist | Out-Null
Copy-Item config\config.example.toml dist\config.example.toml
Copy-Item config\secrets.example.toml dist\secrets.example.toml
Copy-Item config\guests.example.txt dist\guests.example.txt
Compress-Archive -Path dist\vrc_osc.exe,dist\vrwt_tool.exe,dist\config.example.toml,dist\secrets.example.toml,dist\guests.example.txt -DestinationPath dist\vrc_osc-windows.zip -Force
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: vrc_osc-windows
path: VRC_OSC/dist/vrc_osc-windows.zip

View File

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

View File

@@ -1,7 +0,0 @@
$ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$RootDir = Resolve-Path (Join-Path $ScriptDir "..")
$EntryPoint = Join-Path $RootDir "bin\vrc_osc.py"
python $EntryPoint

View File

@@ -1,22 +0,0 @@
package main
import (
"flag"
"log"
"os"
"vrc_osc_go/internal/app"
)
func main() {
var configPath string
var mode string
flag.StringVar(&configPath, "config", "config/config.toml", "path to config.toml")
flag.StringVar(&mode, "mode", "", "generate-json or extract-log")
flag.Parse()
if err := app.Run(configPath, mode); err != nil {
log.SetOutput(os.Stderr)
log.Fatalf("vrc_osc_go: %v", err)
}
}

View File

@@ -1,22 +0,0 @@
[self]
name = ""
[staff]
names = []
[guest]
file = "config/guests.txt"
[notice]
missing_count = 0
[ocr.crop]
left = 0.05
top = 0.35
right = 0.95
bottom = 0.95
[ocr]
preprocess = true
scale = 1.5
use_angle_cls = false

View File

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

View File

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

View File

@@ -1,36 +0,0 @@
package app
import (
"bytes"
"fmt"
"log"
"os/exec"
)
func pressDiscordMuteHotkey() error {
script := `
$p = Get-Process | Where-Object { $_.MainWindowTitle -match 'Discord' } | Select-Object -First 1
if ($null -eq $p) { exit 2 }
[void][System.Runtime.InteropServices.Marshal]::GetLastWin32Error()
$wshell = New-Object -ComObject WScript.Shell
$null = $wshell.AppActivate($p.Id)
Start-Sleep -Milliseconds 150
$wshell.SendKeys('^+m')
`
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
}
func pressDiscordMuteHotkeyLegacy() error {
var buf bytes.Buffer
_ = buf
return pressDiscordMuteHotkey()
}

View File

@@ -1,28 +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))
}
if err != nil {
return fmt.Errorf("ocr failed: %w", err)
}
return nil
}

View File

@@ -1,48 +0,0 @@
package app
import (
"fmt"
"os"
"path/filepath"
"time"
"vrc_osc_go/internal/common"
)
func appendRuntimeLog(title, text string) error {
dir := filepath.Join(common.RootDir(), "runtime")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
path := filepath.Join(dir, "runtime.log")
body := text
if body == "" {
body = "(empty)"
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()
_, err = fmt.Fprintf(f, "\n[%s] %s\n%s\n", time.Now().Format("2006-01-02 15:04:05"), title, body)
return err
}
func appendJoinLeaveLog(text string) error {
dir := filepath.Join(common.RootDir(), "runtime")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
path := filepath.Join(dir, "join_leave.log")
body := text
if body == "" {
body = "(empty)"
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()
_, err = fmt.Fprintf(f, "\n[%s] VRC JOIN/LEAVE\n%s\n", time.Now().Format("2006-01-02 15:04:05"), body)
return err
}

View File

@@ -1,313 +0,0 @@
package app
import (
"bufio"
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
var (
joinPattern = regexp.MustCompile(`OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
leftPattern = regexp.MustCompile(`OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
worldIdPattern = regexp.MustCompile(`worldId=(wrld_[0-9a-fA-F-]+)`)
instanceIdPattern = regexp.MustCompile(`instanceId=([^,}\s]+)`)
worldNamePattern = regexp.MustCompile(`worldName=([^,}]+)`)
worldLocationPattern = regexp.MustCompile(`worldId=(wrld_[0-9a-fA-F-]+):([^\s,\]\)\"']+)`)
worldPattern = regexp.MustCompile(`(wrld_[0-9a-fA-F-]+(?::[^\s\]\)\"']+)?)`)
enteringRoomPattern = regexp.MustCompile(`\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$`)
)
type vrcLogState struct {
location string
worldID string
instanceID string
worldName string
pendingWorldName string
presentSet map[string]struct{}
initialized bool
}
func getVrchatLogDir() (string, error) {
userProfile := os.Getenv("USERPROFILE")
if userProfile == "" {
return "", os.ErrNotExist
}
return filepath.Join(userProfile, "AppData", "LocalLow", "VRChat", "VRChat"), nil
}
func findLatestVrchatLog() (string, error) {
dir, err := getVrchatLogDir()
if err != nil {
return "", err
}
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
var latest string
var latestMod time.Time
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(strings.ToLower(name), ".log") && !strings.HasSuffix(strings.ToLower(name), ".txt") {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
if info.ModTime().After(latestMod) {
latestMod = info.ModTime()
latest = filepath.Join(dir, name)
}
}
if latest == "" {
return "", os.ErrNotExist
}
return latest, nil
}
func watchVrchatLog() {
lastPath := ""
lastSize := int64(0)
state := &vrcLogState{
presentSet: map[string]struct{}{},
}
for {
path, err := findLatestVrchatLog()
if err != nil {
time.Sleep(2 * time.Second)
continue
}
info, err := os.Stat(path)
if err != nil {
time.Sleep(2 * time.Second)
continue
}
if path != lastPath {
log.Printf("vrchat log watching %s", path)
lastPath = path
lastSize = 0
if err := scanExistingVrchatLog(path, state); err != nil {
log.Printf("vrchat log initial scan failed: %v", err)
}
if info2, err := os.Stat(path); err == nil {
lastSize = info2.Size()
} else {
lastSize = info.Size()
}
time.Sleep(2 * time.Second)
continue
}
if info.Size() < lastSize {
lastSize = 0
}
if info.Size() > lastSize {
f, err := os.Open(path)
if err != nil {
time.Sleep(2 * time.Second)
continue
}
_, _ = f.Seek(lastSize, 0)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if err := appendRuntimeLog("VRC LOG", line); err != nil {
log.Printf("append runtime log failed: %v", err)
}
if changed, worldLabel := updateWorldState(state, line); changed {
log.Printf("world changed: %s", worldLabel)
state.presentSet = map[string]struct{}{}
_ = appendRuntimeLog("VRC WORLD", worldLabel)
}
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1])
state.presentSet[name] = struct{}{}
out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet))
log.Print(out)
_ = appendJoinLeaveLog(out)
continue
}
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1])
delete(state.presentSet, name)
out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet))
log.Print(out)
_ = appendJoinLeaveLog(out)
continue
}
}
lastSize = info.Size()
_ = f.Close()
}
time.Sleep(2 * time.Second)
}
}
func scanExistingVrchatLog(path string, state *vrcLogState) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
state.presentSet = map[string]struct{}{}
state.location = ""
state.worldID = ""
state.instanceID = ""
state.worldName = ""
state.pendingWorldName = ""
state.initialized = false
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if changed, _ := updateWorldState(state, line); changed {
state.presentSet = map[string]struct{}{}
}
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1])
state.presentSet[name] = struct{}{}
continue
}
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1])
delete(state.presentSet, name)
continue
}
}
if err := scanner.Err(); err != nil {
return err
}
state.initialized = true
if state.worldName != "" {
log.Printf("world changed: %s", state.worldName)
_ = appendRuntimeLog("VRC WORLD", state.worldName)
}
return nil
}
func updateWorldState(state *vrcLogState, line string) (bool, string) {
worldID := ""
instanceID := ""
worldName := ""
roomTitle := ""
if m := enteringRoomPattern.FindStringSubmatch(line); len(m) == 2 {
roomTitle = strings.TrimSpace(m[1])
state.pendingWorldName = roomTitle
}
if m := worldLocationPattern.FindStringSubmatch(line); len(m) == 3 {
worldID = strings.TrimSpace(m[1])
instanceID = strings.TrimSpace(m[2])
}
if m := worldIdPattern.FindStringSubmatch(line); len(m) == 2 {
worldID = strings.TrimSpace(m[1])
}
if m := instanceIdPattern.FindStringSubmatch(line); len(m) == 2 {
instanceID = strings.TrimSpace(m[1])
}
if m := worldNamePattern.FindStringSubmatch(line); len(m) == 2 {
worldName = strings.TrimSpace(m[1])
}
if m := enteringRoomPattern.FindStringSubmatch(line); len(m) == 2 && worldID == "" {
if worldMatch := worldPattern.FindStringSubmatch(m[1]); len(worldMatch) == 2 {
worldID = worldMatch[1]
}
}
nextLocation := ""
if worldID != "" {
nextLocation = worldID
if instanceID != "" {
nextLocation = worldID + ":" + instanceID
}
}
if nextLocation != "" && nextLocation != state.location {
state.location = nextLocation
state.worldID = worldID
state.instanceID = instanceID
if worldName != "" {
state.worldName = worldName
} else if shouldUseRoomTitlePreferLatest(roomTitle, state.worldName) {
state.worldName = roomTitle
}
label := state.worldName
if label == "" {
label = state.worldID
}
if label == "" {
label = nextLocation
}
if state.initialized {
log.Printf("world changed: %s", label)
_ = appendRuntimeLog("VRC WORLD", label)
}
return true, label
}
if worldName != "" {
state.worldName = worldName
state.pendingWorldName = ""
return false, ""
}
if shouldUseRoomTitlePreferLatest(roomTitle, state.worldName) {
state.worldName = roomTitle
}
return false, ""
}
func shouldUseRoomTitle(roomTitle, currentWorldName string) bool {
if roomTitle == "" {
return false
}
if strings.Contains(roomTitle, "Home Location") {
return false
}
if roomTitle == "Holiday-Cottage" && currentWorldName != "" && currentWorldName != roomTitle {
return false
}
if currentWorldName == "" {
return true
}
if roomTitle == currentWorldName {
return false
}
if len(roomTitle) > len(currentWorldName) {
return true
}
if strings.Contains(roomTitle, "「") || strings.Contains(roomTitle, "」") || strings.Contains(roomTitle, "[") {
return true
}
return false
}
func shouldUseRoomTitlePreferLatest(roomTitle, currentWorldName string) bool {
if roomTitle == "" {
return false
}
if strings.Contains(roomTitle, "Home Location") {
return false
}
if currentWorldName == "" {
return true
}
if roomTitle == currentWorldName {
return false
}
return true
}

View File

@@ -1,16 +0,0 @@
package common
import (
"os"
"path/filepath"
)
func RuntimeBaseDir() string {
exe, err := os.Executable()
if err == nil {
return filepath.Dir(exe)
}
return "."
}
func RootDir() string { return RuntimeBaseDir() }

View File

@@ -1,118 +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
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":
if key == "names" {
cfg.VrcLog.GuestNames = parseList(val)
}
}
}
return cfg, scanner.Err()
}
func parseList(value string) []string {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "[")
value = strings.TrimSuffix(value, "]")
if value == "" {
return nil
}
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
}

View File

@@ -1,238 +0,0 @@
# VRC_OSC
VRChat の OSC パラメータを受けて、Discord ミュート、VRChat ログ監視、OCR、翻訳を実行するローカルツール。
## 起動
依存関係を入れる。
```powershell
python -m pip install -r requirements.txt
```
PowerShell から起動する。
```powershell
bin\vrc_osc.ps1
```
Python から直接起動する場合。
```powershell
python src\bin\vrc_osc.py
```
OSC は `127.0.0.1:9001` で待ち受ける。
## 構成
```text
bin/ PowerShell 起動スクリプト
config/ example とローカル設定置き場
src/app.py アプリ本体のエントリ
src/bin/ Python 起動スクリプト
src/common/ パス、設定ロード、runtime ログなどの共通基盤
src/discord_control/ Discord ウィンドウ操作
src/ocr/ OCR 実行
src/osc/ OSC gateway
src/screen/ VRChat ウィンドウ検出とキャプチャ
src/tools/ 単体実行用の補助ツール
src/translate/ 翻訳
src/vrc_log/ VRChat ログ解析と名簿照合
```
## 設定ファイル
実設定は `config/config.toml` に置く。このファイルは Git に入れない。
雛形は `config/config.example.toml`。
```toml
[self]
name = "自分のVRChat表示名"
[staff]
names = [
"StaffUserA"
]
[guest]
file = "config/guests.txt"
[notice]
missing_count = 3
[ocr.crop]
left = 0.05
top = 0.35
right = 0.95
bottom = 0.95
[ocr]
preprocess = true
scale = 1.5
use_angle_cls = false
```
`self.name` は Discord 自動ミュート判定に使う。
`staff.names` は表示対象だが guest 人数には含めない。
`guest.file` は guest 名簿ファイル。guest 人数の母数と在室判定に使う。
`notice.missing_count` は「あと何人以内で全員集合か」の通知範囲。例えば guest が 23 人で `missing_count = 3` の場合、20〜22 人のときに未入室 guest を出す。
OCR 設定は速度と精度の調整用。
`ocr.crop` は VRChat ウィンドウ内の相対座標。`0.0` から `1.0` で指定する。デフォルトは画面下側の中央寄りだけを読む。
フル画面 OCR に戻す場合。
```toml
[ocr.crop]
left = 0
top = 0
right = 1
bottom = 1
```
`ocr.preprocess = true` はグレースケール化、コントラスト補正、シャープ化、拡大を行う。
`ocr.scale` は前処理時の拡大率。大きいほど小さい文字に強くなるが遅くなる。
`ocr.use_angle_cls = false` は角度分類を切って高速化する。縦書きや回転文字を読む必要がある場合だけ `true` にする。
## guest 名簿
`config/guests.txt` に 1 行 1 名で書く。このファイルは Git に入れない。
雛形は `config/guests.example.txt`。
```text
GuestUserA
GuestUserB
GuestUserC
```
空行と `#` で始まる行は無視する。
## 秘密情報
秘密情報は `config/secrets.toml` に置く。このファイルは Git に入れない。
雛形は `config/secrets.example.toml`。
```toml
[discord]
webhook_url = ""
[vrchat]
username = ""
password = ""
```
`discord.webhook_url` は VRC ログ結果の Discord Webhook 通知に使う。
`vrchat.username` / `vrchat.password` は `src/tools/resolve_vrchat_user.py` を使う場合だけ必要。
## OSC パラメータ
Avatar Parameters 側の名前と一致させる。
| Parameter | 動作 |
| --- | --- |
| `DiscordSend` | 押した瞬間に Discord の mute hotkey を送る |
| `vrc_log` | VRC ログ監視の on/off |
| `ocrEnabled` | VRChat 画面を撮って OCR |
| `translation` | OCR して日本語翻訳 |
## VRC ログ監視
`vrc_log` を押すと、現在の VRChat ログを一度解析して Discord Webhook に送る。その後はログファイル末尾を追い、新しい join / leave / world 系ログが追記されたタイミングで即再解析する。
以前のような `output_log = 10` の秒数ポーリング設定は使わない。
解析対象は昼 12:00 以降。実行時刻が昼 12:00 前なら前日 12:00 以降を見る。
インスタンスが変わったら在室状態はリセットする。
出力例。
```text
ワールド入室: Midnight Rooftop
現在Guest: 0/3
[01:20] [join] GuestUserA 1/3
[01:21] [join] GuestUserB 2/3
[01:25] [leave] GuestUserA 1/3
[01:26] [join] [staff] StaffUserA 1/3
```
notice 条件に入った場合。
```text
現在Guest: 2/3
未入室Guest:
- GuestUserC
```
## Discord 自動ミュート
常時 self monitor が VRChat ログの新規追記だけを見る。
新しく追記されたログの中で、自分の最後のイベントが `OnPlayerJoined` なら Discord を mute、`OnPlayerLeft` なら unmute する。
過去ログ全体の最後が self leave / join だっただけでは反応しない。
`setDiscordMuted()` にはロックがあり、同時実行で mute hotkey が連続送信されないようにしている。
## OCR / 翻訳
`ocrEnabled` は VRChat ウィンドウを撮影して PaddleOCR にかける。結果はクリップボードへコピーする。
`translation` は OCR 結果を翻訳し、翻訳結果をクリップボードへコピーする。
OCR はデフォルトで画面全体ではなく、画面下側の中央寄りを切り出して読む。会話ログや字幕を読む用途では速く、余計な UI 文字を拾いにくい。
## runtime
新規の実行ログは基本的に以下へまとめる。
```text
runtime/runtime.log
runtime/latest_ocr.png
runtime/latest_screenshot.png
```
`runtime/` は Git に入れない。
OCR text、翻訳 text、VRC ログ結果は `runtime/runtime.log` に追記する。
画像は最新ファイルに上書きする。日時別ファイルを増やさない。
## ユーザー名照合
`src/vrc_log/member_name_matcher.py` は TOML 登録名と VRChat 表示名を安全な順番で紐づける補助実装。
照合順。
1. 完全一致
2. 正規化一致
3. 高信頼 fuzzy
4. guest のみ低信頼 fuzzy
5. pending / ambiguous / unmatched
完全一致・正規化一致で確定した名前は fuzzy 候補から除外する。
## Git に入れないもの
以下はローカル専用。
- `config/config.toml`
- `config/secrets.toml`
- `config/guests.txt`
- `runtime/`
- `__pycache__/`
- `*.pyc`
- `.vrchat_cookie.txt`
秘密情報を含む可能性があるものはコミットしない。

View File

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

View File

@@ -1,114 +0,0 @@
import os
from pathlib import Path
import sys
os.environ["FLAGS_enable_pir_api"] = "0"
os.environ["FLAGS_use_onednn"] = "0"
os.environ["FLAGS_use_mkldnn"] = "0"
os.environ["FLAGS_use_onednn_bfloat16"] = "0"
os.environ["ONEDNN_VERBOSE"] = "0"
SRC_DIR = Path(__file__).resolve().parents[1]
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
from osc.gateway import VrcOscGateway
from discord_control.actions import setDiscordMute
from ocr.ocr_actions import runOcrFromScreen
from translate.translate_actions import saveTranslationText
from translate.translate_actions import translateTextToJapanese
from vrc_log.log_actions import collectVrchatLog
from vrc_log.log_actions import startSelfMonitor
from vrc_log.log_actions import startVrcLogMonitor
from vrc_log.log_actions import stopSelfMonitor
from vrc_log.log_actions import stopVrcLogMonitor
PARAM_DISCORD_MUTE = "DiscordSend"
PARAM_LOG_GREP = "vrc_log"
PARAM_OCR = "ocrEnabled"
PARAM_TRANSLATE_OCR = "translation"
def create_gateway():
gateway = VrcOscGateway(host="127.0.0.1", port=9001)
def on_discord_mute(address, *args):
if not args:
gateway.log("ERROR", "DiscordSend args empty")
return
if gateway.is_rising_edge(address, args[0]):
gateway.log("INFO", f"received {address} args={args}")
setDiscordMute()
vrc_log_enabled = False
def on_log_grep(address, *args):
nonlocal vrc_log_enabled
if not args:
gateway.log("ERROR", "vrc_log args empty")
return
if gateway.is_rising_edge(address, args[0]):
vrc_log_enabled = not vrc_log_enabled
gateway.log("INFO", f"received {address} args={args} enabled={vrc_log_enabled}")
if vrc_log_enabled:
collectVrchatLog(notify_changed=True)
startVrcLogMonitor()
else:
stopVrcLogMonitor()
def on_ocr(address, *args):
if not args:
gateway.log("ERROR", "ocrEnabled args empty")
return
if gateway.is_rising_edge(address, args[0]):
gateway.log("INFO", f"received {address} args={args}")
runOcrFromScreen()
def on_translate_ocr(address, *args):
if not args:
gateway.log("ERROR", "translateOcr args empty")
return
if gateway.is_rising_edge(address, args[0]):
gateway.log("INFO", f"received {address} args={args}")
result = runOcrFromScreen()
if not result:
gateway.log("ERROR", "OCR result is None")
return
text = result.get("text", "")
if not text.strip():
gateway.log("INFO", "OCR text is empty. translation skipped")
return
translated = translateTextToJapanese(text)
if not translated.strip():
gateway.log("INFO", "translation result is empty")
return
image_path = result.get("image_path")
stem = image_path.stem if image_path else "translation"
saved = saveTranslationText(stem, translated)
gateway.log("INFO", f"translation saved={saved}")
gateway.map(PARAM_DISCORD_MUTE, on_discord_mute)
gateway.map(PARAM_LOG_GREP, on_log_grep)
gateway.map(PARAM_OCR, on_ocr)
gateway.map(PARAM_TRANSLATE_OCR, on_translate_ocr)
gateway.set_default_debug(enabled=False)
return gateway
def main():
gateway = create_gateway()
startSelfMonitor()
gateway.serve_forever()
if __name__ == "__main__":
main()

View File

@@ -1,13 +0,0 @@
from pathlib import Path
import sys
SRC_DIR = Path(__file__).resolve().parents[1]
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
from app import main
if __name__ == "__main__":
main()

View File

@@ -1,130 +0,0 @@
from pathlib import Path
import tomllib
from common.project_paths import ROOT_DIR
CONFIG_DIR = ROOT_DIR / "config"
CONFIG_FILE = CONFIG_DIR / "config.toml"
SECRETS_FILE = CONFIG_DIR / "secrets.toml"
DEFAULT_EVENT_FILE = CONFIG_DIR / "event.toml"
DEFAULT_GUEST_FILE = CONFIG_DIR / "guests.txt"
def loadTomlFile(path):
if not path.exists():
return {}
return tomllib.loads(path.read_text(encoding="utf-8"))
def resolveConfigPath(value, default_path):
if not value:
return default_path
candidate = Path(value)
if candidate.is_absolute():
return candidate
return (ROOT_DIR / candidate).resolve()
def loadNameListFile(path):
if not path.exists():
return []
names = []
for line in path.read_text(encoding="utf-8-sig", errors="replace").splitlines():
name = line.strip()
if not name or name.startswith("#"):
continue
names.append(name)
return names
def loadSecretConfig():
return loadTomlFile(SECRETS_FILE)
def getSecretValue(section_name, key, default=""):
secrets = loadSecretConfig()
section = secrets.get(section_name, {})
return str(section.get(key, default)).strip()
def loadOcrConfig():
config = loadTomlFile(CONFIG_FILE)
ocr = config.get("ocr", {})
crop = ocr.get("crop", {})
return {
"crop_left": float(crop.get("left", 0.05)),
"crop_top": float(crop.get("top", 0.35)),
"crop_right": float(crop.get("right", 0.95)),
"crop_bottom": float(crop.get("bottom", 0.95)),
"preprocess": bool(ocr.get("preprocess", True)),
"scale": float(ocr.get("scale", 1.5)),
"use_angle_cls": bool(ocr.get("use_angle_cls", False)),
}
def loadVrcLogConfig():
config = loadTomlFile(CONFIG_FILE)
vrc_log = config.get("vrc_log", {})
self_section = config.get("self", {})
event_file_value = (
vrc_log.get("event_toml")
or vrc_log.get("event_file")
or config.get("event_toml")
or config.get("event_file")
)
event_file = resolveConfigPath(event_file_value, DEFAULT_EVENT_FILE)
event_config = loadTomlFile(event_file)
if not event_config:
event_config = vrc_log or config
guest_section = event_config.get("guest", {})
guest_names = guest_section.get("names", [])
guest_file_value = (
guest_section.get("file")
or event_config.get("guest_file")
or config.get("guest_file")
)
guest_file = resolveConfigPath(guest_file_value, DEFAULT_GUEST_FILE)
if not guest_names:
guest_names = loadNameListFile(guest_file)
staff_names = event_config.get("staff", {}).get("names", [])
if not guest_names and isinstance(event_config.get("guest_names"), list):
guest_names = event_config.get("guest_names", [])
if not staff_names and isinstance(event_config.get("staff_names"), list):
staff_names = event_config.get("staff_names", [])
notice_section = event_config.get("notice", {})
missing_count = notice_section.get("missing_count", 0)
if not missing_count:
missing_count = event_config.get("missing_count", 0)
self_name = (
self_section.get("name")
or config.get("self_name")
or vrc_log.get("self_name")
or event_config.get("self", {}).get("name")
or ""
)
return {
"config_file": CONFIG_FILE,
"event_file": event_file,
"guest_file": guest_file,
"guest_names": [str(name).strip() for name in guest_names if str(name).strip()],
"staff_names": [str(name).strip() for name in staff_names if str(name).strip()],
"missing_count": int(missing_count),
"self_name": str(self_name).strip(),
}

View File

@@ -1,6 +0,0 @@
from pathlib import Path
SRC_DIR = Path(__file__).resolve().parents[1]
ROOT_DIR = SRC_DIR.parent
RUNTIME_DIR = ROOT_DIR / "runtime"
RUNTIME_LOG_FILE = RUNTIME_DIR / "runtime.log"

View File

@@ -1,19 +0,0 @@
from datetime import datetime
from common.project_paths import RUNTIME_LOG_FILE
def appendRuntimeLog(title, text):
RUNTIME_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
body = str(text).strip()
if not body:
body = "(empty)"
with RUNTIME_LOG_FILE.open("a", encoding="utf-8") as file:
file.write(f"\n[{now}] {title}\n")
file.write(body)
file.write("\n")
return RUNTIME_LOG_FILE

View File

@@ -1,203 +0,0 @@
from datetime import datetime
import ctypes
import threading
import time
import pyautogui
import pygetwindow as gw
BROWSER_KEYWORDS = [
"google chrome",
"microsoft edge",
"mozilla firefox",
]
_discord_muted = True
_discord_mute_lock = threading.Lock()
def log(level, message):
now = datetime.now().strftime("%H:%M:%S")
print(f"[{level}] {now} {message}", flush=True)
def findDiscordWindow():
candidates = []
for window in gw.getAllWindows():
title = (window.title or "").strip()
if not title:
continue
if "discord" not in title.lower():
continue
candidates.append(window)
if not candidates:
raise RuntimeError("Discordウィンドウが見つかりません")
candidates.sort(key=lambda window: -(window.width * window.height))
return candidates[0]
def findBrowserWindow():
candidates = []
for window in gw.getAllWindows():
title = (window.title or "").strip()
if not title:
continue
lowered = title.lower()
if not any(keyword in lowered for keyword in BROWSER_KEYWORDS):
continue
candidates.append(window)
if not candidates:
raise RuntimeError("ブラウザウィンドウが見つかりません")
candidates.sort(key=lambda window: -(window.width * window.height))
return candidates[0]
def findDiscordBrowserWindow():
candidates = []
for window in gw.getAllWindows():
title = (window.title or "").strip()
if not title:
continue
lowered = title.lower()
if "discord" not in lowered:
continue
if not any(keyword in lowered for keyword in BROWSER_KEYWORDS):
continue
candidates.append(window)
if not candidates:
return None
candidates.sort(key=lambda window: -(window.width * window.height))
return candidates[0]
def activateDiscordWindow():
window = findDiscordWindow()
hwnd = getattr(window, "_hWnd", None)
try:
if window.isMinimized:
window.restore()
except Exception:
pass
if hwnd:
try:
ctypes.windll.user32.ShowWindow(hwnd, 9)
except Exception:
pass
try:
window.activate()
except Exception as e:
log("ERROR", f"Discord activate() failed detail={e}")
if hwnd:
try:
ctypes.windll.user32.BringWindowToTop(hwnd)
ctypes.windll.user32.SetForegroundWindow(hwnd)
except Exception as e:
log("ERROR", f"Discord SetForegroundWindow failed detail={e}")
time.sleep(0.15)
log("INFO", f"Discord window title={window.title}")
return window
def findVrchatWindow():
candidates = []
for window in gw.getAllWindows():
title = (window.title or "").strip()
if not title:
continue
lowered = title.lower()
if lowered == "vrchat":
candidates.append((0, window))
continue
if lowered.startswith("vrchat "):
candidates.append((1, window))
continue
if lowered.startswith("vrchat"):
candidates.append((2, window))
if not candidates:
raise RuntimeError("VRChatウィンドウが見つかりません")
candidates.sort(key=lambda item: (item[0], -(item[1].width * item[1].height)))
return candidates[0][1]
def activateWindow(window, label):
if not window:
return
hwnd = getattr(window, "_hWnd", None)
try:
if window.isMinimized:
window.restore()
except Exception:
pass
if hwnd:
try:
ctypes.windll.user32.ShowWindow(hwnd, 9)
except Exception:
pass
try:
window.activate()
except Exception as e:
log("ERROR", f"{label} activate() failed detail={e}")
if hwnd:
try:
ctypes.windll.user32.BringWindowToTop(hwnd)
ctypes.windll.user32.SetForegroundWindow(hwnd)
except Exception as e:
log("ERROR", f"{label} SetForegroundWindow failed detail={e}")
time.sleep(0.15)
try:
pyautogui.click(window.left + window.width // 2, window.top + window.height // 2)
except Exception:
pass
time.sleep(0.1)
log("INFO", f"{label} window title={window.title}")
def setDiscordMute():
log("ACTION", "setDiscordMute called")
try:
activateDiscordWindow()
except Exception as e:
log("ERROR", f"Discord window unavailable detail={e}")
browser = findDiscordBrowserWindow() or findBrowserWindow()
activateWindow(browser, "Browser")
log("INFO", "Discord hotkey Ctrl+Shift+M")
pyautogui.hotkey("ctrl", "shift", "m")
activateWindow(findVrchatWindow(), "VRChat")
def setDiscordMuted(muted):
global _discord_muted
muted = bool(muted)
with _discord_mute_lock:
if _discord_muted == muted:
log("INFO", f"Discord mute already {'on' if muted else 'off'}")
return
setDiscordMute()
_discord_muted = muted

View File

@@ -1,186 +0,0 @@
import os
from datetime import datetime
os.environ["FLAGS_enable_pir_api"] = "0"
os.environ["FLAGS_use_onednn"] = "0"
os.environ["FLAGS_use_mkldnn"] = "0"
os.environ["FLAGS_use_onednn_bfloat16"] = "0"
os.environ["MKLDNN_DISABLE_WORKSPACE"] = "1"
os.environ["ONEDNN_VERBOSE"] = "0"
import pyperclip
from paddleocr import PaddleOCR
from PIL import Image
from PIL import ImageFilter
from PIL import ImageOps
from common.config_loader import loadOcrConfig
from common.runtime_log import appendRuntimeLog
from screen.screen_actions import captureOcrRegion
_ocr_engine = None
def log(level, message):
now = datetime.now().strftime("%H:%M:%S")
print(f"[{level}] {now} {message}", flush=True)
def getOcrEngine():
global _ocr_engine
if _ocr_engine is None:
log("INFO", "PaddleOCR initializing")
config = loadOcrConfig()
options = {
"lang": "japan",
"use_angle_cls": config["use_angle_cls"],
"show_log": False,
}
try:
_ocr_engine = PaddleOCR(**options)
except (TypeError, ValueError):
options.pop("show_log", None)
_ocr_engine = PaddleOCR(**options)
return _ocr_engine
def saveOcrText(image_path, text):
return appendRuntimeLog(
"OCR TEXT",
f"image={image_path}\n{text}",
)
def extractTextFromAny(obj):
lines = []
def walk(x):
if x is None:
return
if isinstance(x, dict):
for key in ("rec_texts", "texts"):
value = x.get(key)
if isinstance(value, list):
for item in value:
text = str(item).strip()
if text:
lines.append(text)
for key in ("text", "rec_text"):
value = x.get(key)
if isinstance(value, str) and value.strip():
lines.append(value.strip())
for value in x.values():
walk(value)
return
if isinstance(x, (list, tuple)):
if len(x) >= 2 and isinstance(x[1], (list, tuple)) and len(x[1]) >= 1:
candidate = x[1][0]
if isinstance(candidate, str) and candidate.strip():
lines.append(candidate.strip())
for item in x:
walk(item)
walk(obj)
unique_lines = []
seen = set()
for line in lines:
if line not in seen:
unique_lines.append(line)
seen.add(line)
return "\n".join(unique_lines)
def printOcrResult(text):
cleaned = text.strip()
if not cleaned:
log("INFO", "OCR結果は空です")
return
log("CHECK", "OCR結果 full")
for line in cleaned.splitlines():
print(line, flush=True)
def preprocessOcrImage(image_path):
config = loadOcrConfig()
enabled = config["preprocess"]
if not enabled:
return image_path
try:
image = Image.open(image_path)
image = image.convert("L")
image = ImageOps.autocontrast(image)
image = image.filter(ImageFilter.SHARPEN)
scale = max(1.0, min(2.0, config["scale"]))
if scale > 1.0:
width, height = image.size
resampling = getattr(
getattr(Image, "Resampling", Image),
"LANCZOS",
)
image = image.resize(
(int(width * scale), int(height * scale)),
resampling,
)
image.save(image_path)
log("INFO", f"OCR image preprocessed={image_path}")
except Exception as e:
log("ERROR", f"OCR preprocess failed detail={e}")
return image_path
def runPaddleOcr(engine, image_path):
if hasattr(engine, "predict"):
return engine.predict(str(image_path))
return engine.ocr(str(image_path))
def runOcrFromImage(image_path, show_result=True):
log("ACTION", f"PaddleOCR start image={image_path}")
try:
image_path = preprocessOcrImage(image_path)
engine = getOcrEngine()
result = runPaddleOcr(engine, image_path)
except Exception as e:
log("ERROR", f"PaddleOCR failed detail={e}")
return None
text = extractTextFromAny(result)
text_path = saveOcrText(image_path, text)
try:
pyperclip.copy(text)
log("INFO", "OCR結果をクリップボードへコピーしました")
except Exception as e:
log("ERROR", f"clipboard copy failed detail={e}")
log("INFO", f"OCR text appended={text_path}")
if show_result:
printOcrResult(text)
return {
"image_path": image_path,
"text_path": text_path,
"text": text,
}
def runOcrFromScreen():
log("ACTION", "runOcrFromScreen called")
image_path = captureOcrRegion()
return runOcrFromImage(image_path, show_result=True)

View File

@@ -1,85 +0,0 @@
from pythonosc.dispatcher import Dispatcher
from pythonosc.osc_server import BlockingOSCUDPServer
from datetime import datetime
import sys
class VrcOscGateway:
def __init__(self, host="127.0.0.1", port=9001):
self.host = host
self.port = port
self.dispatcher = Dispatcher()
self.last_values = {}
def log(self, level, message):
now = datetime.now().strftime("%H:%M:%S")
print(f"[{level}] {now} {message}", flush=True)
def to_bool(self, value):
if isinstance(value, bool):
return value
if isinstance(value, int) or isinstance(value, float):
return value != 0
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in ("true", "1", "on"):
return True
if lowered in ("false", "0", "off"):
return False
raise ValueError(f"unsupported OSC value: {value}")
def is_rising_edge(self, address, value):
current = self.to_bool(value)
previous = self.last_values.get(address, False)
self.last_values[address] = current
return current and not previous
def is_changed(self, address, value):
current = self.to_bool(value)
previous = self.last_values.get(address, None)
if previous == current:
return False
self.last_values[address] = current
return True
def map(self, parameter_name, handler):
address = f"/avatar/parameters/{parameter_name}"
self.dispatcher.map(address, handler)
self.log("INFO", f"mapped {address}")
def set_default_debug(self, enabled=False):
if not enabled:
return
def on_any(address, *args):
if address.startswith("/avatar/parameters/"):
self.log("INFO", f"other address={address} args={args}")
self.dispatcher.set_default_handler(on_any)
def serve_forever(self):
self.log("CHECK", "STEP=1 状態確認")
self.log("INFO", f"listen={self.host}:{self.port}")
self.log("CHECK", "STEP=2 想定状態判定")
self.log("INFO", "VRChat OSC Enabled")
self.log("INFO", "Python は Windows PowerShell 側で実行")
self.log("INFO", "Unity側 MA Parameters と OSC名が一致していること")
try:
server = BlockingOSCUDPServer((self.host, self.port), self.dispatcher)
server.serve_forever()
except KeyboardInterrupt:
self.log("INFO", "Ctrl+C detected")
except Exception as e:
self.log("FATAL", f"OSC server error STEP=3 detail={e}")
input("Press Enter to exit...")
sys.exit(1)
self.log("CHECK", "STEP=4 結果確認")
self.log("INFO", "終了しました")

View File

@@ -1,167 +0,0 @@
import pyautogui
import pygetwindow as gw
from common.config_loader import loadOcrConfig
from common.project_paths import RUNTIME_DIR
SCREENSHOT_PATH = RUNTIME_DIR / "latest_screenshot.png"
OCR_SCREENSHOT_PATH = RUNTIME_DIR / "latest_ocr.png"
EXCLUDED_WINDOW_KEYWORDS = [
"Google Chrome",
"Microsoft Edge",
"Mozilla Firefox",
"Visual Studio Code",
"PowerShell",
"Windows Terminal",
"ChatGPT",
]
def log(level, message):
from datetime import datetime
now = datetime.now().strftime("%H:%M:%S")
print(f"[{level}] {now} {message}", flush=True)
def findVrchatWindow():
windows = gw.getAllWindows()
candidates = []
for window in windows:
title = window.title or ""
normalized = title.strip().lower()
if not normalized:
continue
if any(keyword.lower() in normalized for keyword in EXCLUDED_WINDOW_KEYWORDS):
continue
if normalized == "vrchat":
candidates.append((0, window))
continue
if normalized.startswith("vrchat "):
candidates.append((1, window))
continue
if normalized.startswith("vrchat"):
candidates.append((2, window))
if not candidates:
visible_titles = []
for window in windows:
title = (window.title or "").strip()
if title:
visible_titles.append(title)
log("ERROR", "VRChatウィンドウが見つかりません")
log("CHECK", "現在検出できるウィンドウ")
for title in visible_titles[:30]:
print(title, flush=True)
raise RuntimeError("VRChatウィンドウが見つかりません")
candidates = sorted(
candidates,
key=lambda item: (item[0], -(item[1].width * item[1].height)),
)
window = candidates[0][1]
if window.width <= 0 or window.height <= 0:
raise RuntimeError("VRChatウィンドウサイズが不正です")
return window
def getVrchatRegion():
window = findVrchatWindow()
try:
if window.isMinimized:
window.restore()
except Exception:
pass
left = int(window.left)
top = int(window.top)
width = int(window.width)
height = int(window.height)
if width <= 0 or height <= 0:
raise RuntimeError("VRChatウィンドウ範囲が不正です")
log("INFO", f"VRChat window title={window.title}")
log("INFO", f"VRChat region left={left} top={top} width={width} height={height}")
return left, top, width, height
def clampRatio(value):
return max(0.0, min(1.0, value))
def getOcrRegion():
left, top, width, height = getVrchatRegion()
config = loadOcrConfig()
# Default to the lower central area. It is much faster than OCRing the
# whole VRChat window and usually contains chat/subtitle text.
crop_left = clampRatio(config["crop_left"])
crop_top = clampRatio(config["crop_top"])
crop_right = clampRatio(config["crop_right"])
crop_bottom = clampRatio(config["crop_bottom"])
if crop_right <= crop_left:
crop_left = 0.0
crop_right = 1.0
if crop_bottom <= crop_top:
crop_top = 0.0
crop_bottom = 1.0
region_left = left + int(width * crop_left)
region_top = top + int(height * crop_top)
region_width = max(1, int(width * (crop_right - crop_left)))
region_height = max(1, int(height * (crop_bottom - crop_top)))
log(
"INFO",
f"OCR crop left={region_left} top={region_top} width={region_width} height={region_height}",
)
return region_left, region_top, region_width, region_height
def captureRegionTo(image_path, region):
image_path.parent.mkdir(parents=True, exist_ok=True)
left, top, width, height = region
image = pyautogui.screenshot(region=(left, top, width, height))
image.save(image_path)
log("ACTION", f"captureVrchatWindow saved={image_path}")
return image_path
def captureVrchatWindowTo(image_path):
return captureRegionTo(
image_path,
getVrchatRegion(),
)
def captureVrchatWindow():
return captureVrchatWindowTo(SCREENSHOT_PATH)
def captureOcrRegion():
return captureRegionTo(
OCR_SCREENSHOT_PATH,
getOcrRegion(),
)
def captureScreen():
return captureVrchatWindow()
def takeScreenshot():
return captureVrchatWindow()

View File

@@ -1,124 +0,0 @@
from pythonosc.dispatcher import Dispatcher
from pythonosc.osc_server import BlockingOSCUDPServer
from datetime import datetime
import pyautogui
import time
import sys
HOST = "127.0.0.1"
PORT = 9001
PARAMETER = "/avatar/parameters/DiscordSend"
# Discordの初期状態を「ミュート中」と仮定
discord_muted = True
button_pressed = False
last_action_time = 0.0
DEBOUNCE_SECONDS = 0.3
def log(level, message):
now = datetime.now().strftime("%H:%M:%S")
print(f"[{level}] {now} {message}", flush=True)
def wait_exit():
input("Press Enter to exit...")
def press_discord_mute_hotkey():
pyautogui.hotkey("ctrl", "shift", "m")
def handle_discord_send(address, *args):
global discord_muted
global button_pressed
global last_action_time
if not args:
log("ERROR", "OSC args is empty")
return
value = args[0]
now_ts = time.time()
if isinstance(value, bool):
is_pressed = value
elif isinstance(value, int) or isinstance(value, float):
is_pressed = value != 0
else:
log("ERROR", f"unsupported value type value={value}")
return
if now_ts - last_action_time < DEBOUNCE_SECONDS:
return
# 押した瞬間: ミュート解除
if is_pressed and not button_pressed:
button_pressed = True
if discord_muted:
log("ACTION", "VRChat button pressed -> Discord unmute hotkey")
press_discord_mute_hotkey()
discord_muted = False
last_action_time = now_ts
else:
log("INFO", "already unmuted")
return
# 離した瞬間: ミュート
if not is_pressed and button_pressed:
button_pressed = False
if not discord_muted:
log("ACTION", "VRChat button released -> Discord mute hotkey")
press_discord_mute_hotkey()
discord_muted = True
last_action_time = now_ts
else:
log("INFO", "already muted")
return
def main():
log("CHECK", "STEP=1 OSC受信状態確認")
log("INFO", f"host={HOST}")
log("INFO", f"port={PORT}")
log("INFO", f"parameter={PARAMETER}")
log("CHECK", "STEP=2 想定状態判定")
log("INFO", "Windows PowerShellで実行していること")
log("INFO", "VRChat OSC Enabledであること")
log("INFO", "Discordのミュート切替キーが Ctrl+Shift+M であること")
log("INFO", "Discordの現在状態はミュート中として扱います")
answer = input("[ACTION] 続行しますか? yes/no: ").strip().lower()
if answer != "yes":
if answer == "no":
log("FATAL", "ユーザー操作により停止 STEP=2")
wait_exit()
sys.exit(1)
log("ERROR", "不正入力です STEP=2")
wait_exit()
sys.exit(1)
log("ACTION", "STEP=3 OSC受信開始")
log("INFO", "VRChatのメニューを押している間だけDiscordミュート解除します")
log("INFO", "停止する場合は Ctrl+C")
dispatcher = Dispatcher()
dispatcher.map(PARAMETER, handle_discord_send)
try:
server = BlockingOSCUDPServer((HOST, PORT), dispatcher)
server.serve_forever()
except KeyboardInterrupt:
log("INFO", "Ctrl+C detected")
except Exception as e:
log("FATAL", f"OSC server error STEP=3 detail={e}")
wait_exit()
sys.exit(1)
log("CHECK", "STEP=4 結果確認")
log("INFO", "終了しました")
if __name__ == "__main__":
main()

View File

@@ -1,21 +0,0 @@
from pythonosc.dispatcher import Dispatcher
from pythonosc.osc_server import BlockingOSCUDPServer
from datetime import datetime
HOST = "127.0.0.1"
PORT = 9001
def on_any(address, *args):
now = datetime.now().strftime("%H:%M:%S")
print(f"[INFO] {now} address={address} args={args}", flush=True)
dispatcher = Dispatcher()
dispatcher.set_default_handler(on_any)
print("[CHECK] OSC受信開始")
print(f"[INFO] host={HOST}")
print(f"[INFO] port={PORT}")
print("[ACTION] VRChatでメニューを押してください")
server = BlockingOSCUDPServer((HOST, PORT), dispatcher)
server.serve_forever()

View File

@@ -1,54 +0,0 @@
from pythonosc.dispatcher import Dispatcher
from pythonosc.osc_server import BlockingOSCUDPServer
from datetime import datetime
HOST = "127.0.0.1"
PORT = 9001
def log(msg):
print(msg, flush=True)
def on_test(address, *args):
now = datetime.now().strftime("%H:%M:%S")
log("")
log(f"[INFO] time={now}")
log(f"[ACTION] OSC parameter received")
log(f"[INFO] address={address}")
log(f"[INFO] args={args}")
dispatcher = Dispatcher()
dispatcher.map(
"/avatar/parameters/TestPrint",
on_test
)
log("[CHECK] STEP=1 OSC待受開始")
log(f"[INFO] host={HOST}")
log(f"[INFO] port={PORT}")
try:
server = BlockingOSCUDPServer(
(HOST, PORT),
dispatcher
)
log("[INFO] OSC server started")
log("[ACTION] VRChatでTestPrintを押してください")
server.serve_forever()
except KeyboardInterrupt:
log("")
log("[INFO] Ctrl+C detected")
log("[INFO] shutdown")
except Exception as e:
log("")
log("[FATAL] OSC server error")
log(f"[ERROR] detail={e}")
print("")
input("Press Enter to exit...")
raise SystemExit(1)

View File

@@ -1,134 +0,0 @@
import re
from http.cookiejar import MozillaCookieJar
import vrchatapi
from vrchatapi.api import authentication_api
from vrchatapi.api import users_api
from vrchatapi.exceptions import UnauthorizedException
from vrchatapi.models.two_factor_auth_code import TwoFactorAuthCode
from vrchatapi.models.two_factor_email_code import TwoFactorEmailCode
from common.config_loader import getSecretValue
COOKIE_FILE = ".vrchat_cookie.txt"
USER_AGENT = (
"VRC_OSC/1.0 "
"(contact: test@gmail.com)"
)
def get_required_secret(section, key):
value = getSecretValue(section, key)
if not value:
raise RuntimeError(f"{section}.{key} is not set in config/secrets.toml")
return value
def setup_cookie_jar(api_client):
cookie_jar = MozillaCookieJar(COOKIE_FILE)
if os.path.exists(COOKIE_FILE):
try:
cookie_jar.load(
ignore_discard=True,
ignore_expires=True,
)
except Exception:
pass
api_client.rest_client.cookie_jar = cookie_jar
return cookie_jar
def save_cookie_jar(cookie_jar):
try:
cookie_jar.save(
ignore_discard=True,
ignore_expires=True,
)
except Exception:
pass
def login(api_client, cookie_jar):
auth_api = authentication_api.AuthenticationApi(api_client)
try:
current_user = auth_api.get_current_user()
save_cookie_jar(cookie_jar)
return current_user
except UnauthorizedException as e:
reason = str(e.reason)
if "Email 2 Factor Authentication" in reason:
code = input("Email 2FA Code: ").strip()
auth_api.verify2_fa_email_code(
TwoFactorEmailCode(code)
)
elif "2 Factor Authentication" in reason:
code = input("TOTP Code: ").strip()
auth_api.verify2_fa(
TwoFactorAuthCode(code)
)
else:
raise
current_user = auth_api.get_current_user()
save_cookie_jar(cookie_jar)
return current_user
def extract_user_id(text):
match = re.search(
r"(usr_[0-9a-fA-F\-]+)",
text,
)
if not match:
return None
return match.group(1)
def main():
text = input("参加者名: ").strip()
user_id = extract_user_id(text)
if not user_id:
print(text)
return
configuration = vrchatapi.Configuration(
username=get_required_secret("vrchat", "username"),
password=get_required_secret("vrchat", "password"),
)
with vrchatapi.ApiClient(configuration) as api_client:
api_client.user_agent = USER_AGENT
api_client.default_headers["User-Agent"] = USER_AGENT
cookie_jar = setup_cookie_jar(api_client)
login(api_client, cookie_jar)
users = users_api.UsersApi(api_client)
user = users.get_user(user_id)
print(user.display_name)
save_cookie_jar(cookie_jar)
if __name__ == "__main__":
main()

View File

@@ -1,102 +0,0 @@
from datetime import datetime
import re
import pyperclip
from deep_translator import GoogleTranslator
from common.runtime_log import appendRuntimeLog
IGNORE_EXACT = {
"戻る",
"翻訳",
"OCR",
"VRCLOG",
"Discord_Mute",
"Screenshot",
"OCR_Toggle",
"DCR_Toggle",
"HI",
}
def log(level, message):
now = datetime.now().strftime("%H:%M:%S")
print(f"[{level}] {now} {message}", flush=True)
def saveTranslationText(stem, text):
return appendRuntimeLog(
"TRANSLATION TEXT",
f"source={stem}\n{text}",
)
def looksLikeNoise(line):
value = line.strip()
if not value:
return True
if value in IGNORE_EXACT:
return True
if len(value) <= 2:
return True
if "_" in value:
return True
if re.fullmatch(r"[A-Za-z0-9\-_/]+", value):
return True
return False
def normalizeSourceText(text):
lines = []
for raw_line in text.splitlines():
line = raw_line.strip()
if looksLikeNoise(line):
continue
lines.append(line)
unique_lines = []
seen = set()
for line in lines:
if line not in seen:
unique_lines.append(line)
seen.add(line)
return "\n".join(unique_lines).strip()
def translateTextToJapanese(text):
source = normalizeSourceText(text)
if not source:
log("INFO", "翻訳対象が空です")
return ""
log("CHECK", "Translation source")
for line in source.splitlines():
print(line, flush=True)
try:
translated = GoogleTranslator(
source="auto",
target="ja",
).translate(source)
except Exception as e:
log("ERROR", f"translation failed detail={e}")
return ""
translated = translated.strip()
if not translated:
log("INFO", "翻訳結果は空です")
return ""
try:
pyperclip.copy(translated)
log("INFO", "翻訳結果をクリップボードへコピーしました")
except Exception as e:
log("ERROR", f"clipboard copy failed detail={e}")
log("CHECK", "Translation result")
for line in translated.splitlines():
print(line, flush=True)
return translated

View File

@@ -1,563 +0,0 @@
import os
import re
import subprocess
import tempfile
import threading
import time
from datetime import datetime
from datetime import timedelta
from pathlib import Path
from common.config_loader import loadVrcLogConfig
from common.config_loader import getSecretValue
from common.runtime_log import appendRuntimeLog
from discord_control.actions import setDiscordMuted
_monitor_thread = None
_monitor_stop_event = threading.Event()
_last_sent_output = None
_self_monitor_thread = None
_self_monitor_stop_event = threading.Event()
_last_self_state = None
REALTIME_IDLE_SECONDS = 0.2
JOIN_PATTERN = re.compile(
r"OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)"
)
LEFT_PATTERN = re.compile(
r"OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)"
)
WORLD_PATTERN = re.compile(
r"(wrld_[0-9a-fA-F-]+(?::[^\s\]\)\"']+)?)"
)
ENTERING_ROOM_PATTERN = re.compile(
r"\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$"
)
WORLD_NAME_PATTERN = re.compile(
r"worldId=(wrld_[0-9a-fA-F-]+)(?::[^,}]*)?,\s*worldName=([^,}]+)"
)
TIME_PATTERN = re.compile(
r"(\d{4}\.\d{2}\.\d{2}\s+\d{2}:\d{2}:\d{2})"
)
RELEVANT_LOG_PATTERN = re.compile(
r"OnPlayerJoined|OnPlayerLeft|Entering Room|Joining or Creating Room|worldId=|wrld_"
)
def log(level, message):
now = datetime.now().strftime("%H:%M:%S")
print(f"[{level}] {now} {message}", flush=True)
def getWebhookUrl():
return getSecretValue("discord", "webhook_url")
def sendWebhook(text):
webhook_url = getWebhookUrl().strip()
if not webhook_url:
log("ERROR", "Discord webhook URL is not set config=config/secrets.toml")
return False
message = str(text).strip()
if not message:
log("INFO", "Webhook送信内容が空です")
return False
if len(message) > 1800:
message = message[-1800:]
ps_script = r'''
param(
[string]$WebhookUrl,
[string]$Message
)
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$Payload = @{ content = $Message } | ConvertTo-Json -Compress
$Body = [System.Text.Encoding]::UTF8.GetBytes($Payload)
Invoke-WebRequest -Uri $WebhookUrl -Method Post -ContentType 'application/json; charset=utf-8' -Body $Body -UseBasicParsing | Out-Null
'''
ps_path = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
suffix=".ps1",
delete=False,
encoding="utf-8-sig",
) as tmp:
tmp.write(ps_script)
ps_path = tmp.name
result = subprocess.run(
[
"powershell",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
ps_path,
webhook_url,
message,
],
capture_output=True,
text=True,
timeout=20,
)
except Exception as e:
log("ERROR", f"Discord webhook PowerShell failed detail={e}")
return False
finally:
if ps_path:
try:
Path(ps_path).unlink(missing_ok=True)
except Exception:
pass
if result.returncode != 0:
stderr = (result.stderr or "").strip()
stdout = (result.stdout or "").strip()
log("ERROR", f"Discord webhook PowerShell failed code={result.returncode} stderr={stderr} stdout={stdout}")
return False
log("INFO", "Discord webhook sent via PowerShell")
return True
def getVrchatLogDir():
userprofile = os.environ.get("USERPROFILE")
if not userprofile:
raise RuntimeError("USERPROFILE が取得できません")
return Path(userprofile) / "AppData" / "LocalLow" / "VRChat" / "VRChat"
def findLatestVrchatLog():
log_dir = getVrchatLogDir()
if not log_dir.exists():
raise RuntimeError(f"VRChatログディレクトリが存在しません: {log_dir}")
candidates = []
for pattern in ("output_log_*.txt", "Player.log", "*.log", "*.txt"):
candidates.extend(log_dir.glob(pattern))
candidates = [path for path in candidates if path.is_file()]
if not candidates:
raise RuntimeError(f"VRChatログファイルが見つかりません: {log_dir}")
candidates.sort(key=lambda path: path.stat().st_mtime, reverse=True)
return candidates[0]
def readTextSafe(path):
for encoding in ("utf-8", "utf-8-sig", "cp932", "shift_jis"):
try:
return path.read_text(encoding=encoding, errors="replace")
except Exception:
continue
raise RuntimeError(f"ログファイルを読めません: {path}")
def parseLineTime(line):
matched = TIME_PATTERN.search(line)
if not matched:
return None
return datetime.strptime(matched.group(1), "%Y.%m.%d %H:%M:%S")
def formatClock(dt):
return dt.strftime("%H:%M")
def getLogStartTime(now):
noon_today = now.replace(hour=12, minute=0, second=0, microsecond=0)
if now >= noon_today:
return noon_today
return noon_today - timedelta(days=1)
def splitWorldLocation(location):
if ":" not in location:
return location, ""
world_id, instance_id = location.split(":", 1)
return world_id, instance_id
def extractWorldNameMap(text):
world_name_map = {}
pending_world_name = ""
for raw_line in text.splitlines():
room_match = ENTERING_ROOM_PATTERN.search(raw_line)
if room_match:
pending_world_name = room_match.group(1).strip()
match = WORLD_NAME_PATTERN.search(raw_line)
if match:
world_id = match.group(1).strip()
world_name = match.group(2).strip()
if world_id and world_name:
world_name_map[world_id] = world_name
continue
world_match = WORLD_PATTERN.search(raw_line)
if world_match and pending_world_name:
location = world_match.group(1).strip()
world_id, _ = splitWorldLocation(location)
if world_id:
world_name_map[world_id] = pending_world_name
pending_world_name = ""
return world_name_map
def formatWorldLabel(world_id, world_name_map):
world_name = world_name_map.get(world_id, "").strip()
if not world_name:
return "不明"
return world_name
def classifyUser(name, guest_names, staff_names):
if name in staff_names:
return "staff"
if name in guest_names:
return "guest"
return "other"
def isSelf(name, self_name):
return bool(self_name) and name.strip() == self_name.strip()
def buildNoticeLines(guest_names, guest_present_set, missing_count):
total_guest = len(guest_names)
current_guest = len(guest_present_set)
threshold = total_guest - missing_count
if missing_count <= 0:
return []
if not (threshold <= current_guest < total_guest):
return []
missing_guests = [name for name in guest_names if name not in guest_present_set]
if not missing_guests:
return []
lines = [f"現在Guest: {current_guest}/{total_guest}", "未入室Guest:"]
lines.extend(f"- {name}" for name in missing_guests)
return lines
def appendNoticeIfNeeded(output_lines, state):
notice_lines = buildNoticeLines(
state["guest_names"],
state["guest_present_set"],
state["missing_count"],
)
notice_key = tuple(notice_lines)
if not notice_lines:
state["last_notice_key"] = None
return
if state["last_notice_key"] == notice_key:
return
output_lines.extend(notice_lines)
state["last_notice_key"] = notice_key
def parseVrchatEvents(text, config):
start_time = getLogStartTime(datetime.now())
guest_names = config["guest_names"]
staff_names = set(config["staff_names"])
self_name = config.get("self_name", "")
world_name_map = extractWorldNameMap(text)
state = {
"location": "",
"world_id": "",
"instance_id": "",
"guest_names": guest_names,
"guest_name_set": set(guest_names),
"staff_names": staff_names,
"guest_present_set": set(),
"staff_present_set": set(),
"member_present_set": set(),
"missing_count": config["missing_count"],
"last_notice_key": None,
}
output_lines = []
self_state = None
for raw_line in text.splitlines():
line_time = parseLineTime(raw_line)
if not line_time or line_time < start_time:
continue
world_match = WORLD_PATTERN.search(raw_line)
if world_match:
location = world_match.group(1).strip()
world_id, instance_id = splitWorldLocation(location)
if location != state["location"]:
state["location"] = location
state["world_id"] = world_id
state["instance_id"] = instance_id
state["guest_present_set"].clear()
state["staff_present_set"].clear()
state["member_present_set"].clear()
state["last_notice_key"] = None
output_lines = []
output_lines.append(f"ワールド入室: {formatWorldLabel(world_id, world_name_map)}")
output_lines.append(f"現在Guest: 0/{len(guest_names)}")
join_match = JOIN_PATTERN.search(raw_line)
if join_match and state["world_id"]:
name = join_match.group(1).strip()
if isSelf(name, self_name):
self_state = "joined"
state["member_present_set"].add(name)
output_lines.append(
f'[{formatClock(line_time)}] [join] [self] {name} {len(state["member_present_set"])}/{len(guest_names)}'
)
continue
role = classifyUser(name, state["guest_name_set"], state["staff_names"])
if role == "guest":
state["guest_present_set"].add(name)
state["member_present_set"].add(name)
output_lines.append(
f'[{formatClock(line_time)}] [join] {name} {len(state["member_present_set"])}/{len(guest_names)}'
)
appendNoticeIfNeeded(output_lines, state)
elif role == "staff":
state["staff_present_set"].add(name)
output_lines.append(
f'[{formatClock(line_time)}] [join] [staff] {name} {len(state["member_present_set"])}/{len(guest_names)}'
)
else:
state["member_present_set"].add(name)
output_lines.append(
f'[{formatClock(line_time)}] [join] {name} {len(state["member_present_set"])}/{len(guest_names)}'
)
continue
left_match = LEFT_PATTERN.search(raw_line)
if left_match and state["world_id"]:
name = left_match.group(1).strip()
if isSelf(name, self_name):
self_state = "left"
state["member_present_set"].discard(name)
output_lines.append(
f'[{formatClock(line_time)}] [leave] [self] {name} {len(state["member_present_set"])}/{len(guest_names)}'
)
continue
role = classifyUser(name, state["guest_name_set"], state["staff_names"])
if role == "guest":
state["guest_present_set"].discard(name)
state["member_present_set"].discard(name)
output_lines.append(
f'[{formatClock(line_time)}] [leave] {name} {len(state["member_present_set"])}/{len(guest_names)}'
)
appendNoticeIfNeeded(output_lines, state)
elif role == "staff":
state["staff_present_set"].discard(name)
output_lines.append(
f'[{formatClock(line_time)}] [leave] [staff] {name} {len(state["member_present_set"])}/{len(guest_names)}'
)
else:
state["member_present_set"].discard(name)
output_lines.append(
f'[{formatClock(line_time)}] [leave] {name} {len(state["member_present_set"])}/{len(guest_names)}'
)
return output_lines, self_state
def extractLatestSelfLogState(text, self_name):
if not self_name:
return None
latest_state = None
for raw_line in text.splitlines():
join_match = JOIN_PATTERN.search(raw_line)
if join_match and isSelf(join_match.group(1).strip(), self_name):
latest_state = "joined"
continue
left_match = LEFT_PATTERN.search(raw_line)
if left_match and isSelf(left_match.group(1).strip(), self_name):
latest_state = "left"
return latest_state
def grepVrchatLog(pattern=None):
return collectVrchatLog(pattern=pattern, notify_changed=False)
def collectVrchatLog(pattern=None, notify_changed=True):
config = loadVrcLogConfig()
latest_log = findLatestVrchatLog()
log("ACTION", f"parseVrchatLog file={latest_log}")
log("INFO", f'event_file={config["event_file"]}')
text = readTextSafe(latest_log)
lines, self_state = parseVrchatEvents(text, config)
output_text = "\n".join(lines) if lines else "[INFO] 対象イベントなし"
output_path = appendRuntimeLog("VRC LOG", output_text)
if notify_changed:
global _last_sent_output
if output_text != _last_sent_output:
try:
ok = sendWebhook(output_text)
if ok:
log("INFO", "Discord webhook sent")
_last_sent_output = output_text
else:
log("ERROR", "Discord webhook send failed")
except Exception as e:
log("ERROR", f"Discord webhook failed detail={e}")
log("INFO", f"result appended={output_path}")
for line in lines or ["[INFO] 対象イベントなし"]:
print(line, flush=True)
return output_path
def _readNewText(path, offset):
for encoding in ("utf-8", "utf-8-sig", "cp932", "shift_jis"):
try:
with path.open("r", encoding=encoding, errors="replace") as file:
file.seek(offset)
return file.read(), file.tell()
except Exception:
continue
raise RuntimeError(f"ログファイルを読めません: {path}")
def _watchVrchatLogChanges(stop_event, label, on_relevant_change):
log("ACTION", f"{label} realtime watcher started")
current_log = None
offset = 0
while not stop_event.is_set():
try:
latest_log = findLatestVrchatLog()
latest_size = latest_log.stat().st_size
if current_log is None:
current_log = latest_log
offset = latest_size
log("INFO", f"{label} watching file={current_log}")
continue
if latest_log != current_log:
current_log = latest_log
offset = 0
log("INFO", f"{label} watching new file={current_log}")
if latest_size < offset:
offset = 0
if latest_size > offset:
text, offset = _readNewText(latest_log, offset)
if RELEVANT_LOG_PATTERN.search(text):
on_relevant_change(text)
except Exception as e:
log("ERROR", f"{label} realtime watcher failed detail={e}")
stop_event.wait(REALTIME_IDLE_SECONDS)
log("INFO", f"{label} realtime watcher stopped")
def _monitor_loop():
def on_relevant_change(_text):
collectVrchatLog(notify_changed=True)
_watchVrchatLogChanges(
_monitor_stop_event,
"VRC log monitor",
on_relevant_change,
)
def _self_monitor_loop():
def on_relevant_change(text):
try:
config = loadVrcLogConfig()
self_state = extractLatestSelfLogState(
text,
config.get("self_name", ""),
)
global _last_self_state
if self_state and self_state != _last_self_state:
if self_state == "joined":
setDiscordMuted(True)
elif self_state == "left":
setDiscordMuted(False)
_last_self_state = self_state
except Exception as e:
log("ERROR", f"VRC self monitor failed detail={e}")
_watchVrchatLogChanges(
_self_monitor_stop_event,
"VRC self monitor",
on_relevant_change,
)
def startVrcLogMonitor(_interval_seconds=None):
global _monitor_thread
if _monitor_thread and _monitor_thread.is_alive():
log("INFO", "VRC log monitor already running")
return
_monitor_stop_event.clear()
_monitor_thread = threading.Thread(
target=_monitor_loop,
daemon=True,
)
_monitor_thread.start()
def startSelfMonitor(_interval_seconds=None):
global _self_monitor_thread
if _self_monitor_thread and _self_monitor_thread.is_alive():
log("INFO", "VRC self monitor already running")
return
_self_monitor_stop_event.clear()
_self_monitor_thread = threading.Thread(
target=_self_monitor_loop,
daemon=True,
)
_self_monitor_thread.start()
def stopVrcLogMonitor():
_monitor_stop_event.set()
def stopSelfMonitor():
_self_monitor_stop_event.set()

View File

@@ -1,623 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
目的:
VRChat表示名とTOML登録名を安全な順番で紐づける
特徴:
- 完全一致を先に確定して候補から除外
- 正規化一致を確定して候補から除外
- 残った候補だけを high confidence fuzzy 判定
- guest のみ low confidence fuzzy で再選別
- pending / ambiguous / unmatched 判定
- 記号除去
- 全角半角統一
- 小文字化
- 日本語 -> ローマ字変換
- rapidfuzz による類似度
- テストケース付き
前提:
pip install rapidfuzz pykakasi
期待結果:
- 100%一致した名前を fuzzy 候補に残さない
- 似た名前同士の誤リンクを減らす
"""
import re
import sys
import unicodedata
from dataclasses import dataclass, field
from typing import Dict, Iterable, List, Optional, Tuple
try:
from rapidfuzz import fuzz
except Exception as e:
if __name__ == "__main__":
print(f"[FATAL] rapidfuzz import failed: {e}")
input("Press Enter to exit...")
sys.exit(1)
raise
try:
from pykakasi import kakasi
except Exception as e:
if __name__ == "__main__":
print(f"[FATAL] pykakasi import failed: {e}")
input("Press Enter to exit...")
sys.exit(1)
raise
# =========================
# 設定
# =========================
AUTO_LINK_SCORE = 95
LOW_CONFIDENCE_SCORE = 80
AMBIGUOUS_DIFF = 10
LOW_CONFIDENCE_MAX_REMAINING = 3
WEAK_MATCH_SCORE = 50
STATUS_MATCHED = "matched"
STATUS_MATCHED_LOW_CONFIDENCE = "matched_low_confidence"
STATUS_PENDING = "pending"
STATUS_AMBIGUOUS = "ambiguous"
STATUS_UNMATCHED = "unmatched"
STATUS_UNKNOWN_MEMBER = "unknown_member"
ROLE_GUEST = "guest"
ROLE_SELF = "self"
ROLE_STAFF = "staff"
TEST_GUESTS = [
"okada",
"tanaka",
"yamada",
"messykenty",
]
TEST_CURRENT_USERS = [
"岡田",
"ikada",
"okada_",
"",
"tanaka!!!",
"田中",
"やまだ",
"messy_kenty",
"messy.kenty",
"MESSYKENTY",
"山田",
]
@dataclass(frozen=True)
class TomlMember:
name: str
role: str = ROLE_GUEST
@dataclass
class MatchResult:
toml_name: str
role: str
status: str
vrchat_name: Optional[str] = None
score: Optional[float] = None
second_score: Optional[float] = None
reason: str = ""
candidates: List[Tuple[str, float]] = field(default_factory=list)
# =========================
# ローマ字変換
# =========================
kks = kakasi()
def jp_to_romaji(text: str) -> str:
try:
converted = kks.convert(text)
result = []
for item in converted:
result.append(item["hepburn"])
return "".join(result)
except Exception as e:
print(f"[ERROR] romaji convert failed: {text} : {e}")
return text
# =========================
# 正規化
# =========================
def normalize_name(name: str) -> str:
"""
正規化内容:
- 全角半角統一
- 小文字化
- 日本語→ローマ字
- 記号除去
"""
# Unicode normalize
name = unicodedata.normalize("NFKC", name)
# 小文字化
name = name.lower()
# 日本語→ローマ字
name = jp_to_romaji(name)
# 記号除去
name = re.sub(r"[^a-z0-9]", "", name)
return name.strip()
# =========================
# 類似候補検索
# =========================
def find_candidates(
target_guest: str,
current_users: List[str],
) -> List[Tuple[str, float]]:
normalized_target = normalize_name(target_guest)
normalized_map = {}
for user in current_users:
normalized_map[user] = normalize_name(user)
results = []
for original_name, normalized_name in normalized_map.items():
score = fuzz.ratio(
normalized_target,
normalized_name,
)
results.append((original_name, score))
results.sort(key=lambda x: x[1], reverse=True)
return results
def _to_toml_member(member) -> TomlMember:
if isinstance(member, TomlMember):
return member
if isinstance(member, str):
return TomlMember(name=member)
if isinstance(member, dict):
return TomlMember(
name=str(member.get("name", "")),
role=str(member.get("role", ROLE_GUEST)),
)
name = getattr(member, "name", "")
role = getattr(member, "role", ROLE_GUEST)
return TomlMember(
name=str(name),
role=str(role),
)
def _make_candidates(
toml_name: str,
current_users: Iterable[str],
) -> List[Tuple[str, float]]:
normalized_toml = normalize_name(toml_name)
results = []
for user in current_users:
score = fuzz.ratio(
normalized_toml,
normalize_name(user),
)
results.append((user, score))
results.sort(key=lambda x: x[1], reverse=True)
return results
def _classify_remaining(
member: TomlMember,
current_users: Iterable[str],
) -> MatchResult:
candidates = _make_candidates(
member.name,
current_users,
)
if not candidates:
return MatchResult(
toml_name=member.name,
role=member.role,
status=STATUS_UNMATCHED,
reason="no_candidate",
)
top_name, top_score = candidates[0]
second_score = candidates[1][1] if len(candidates) >= 2 else 0
diff = top_score - second_score
if top_score < WEAK_MATCH_SCORE:
status = STATUS_UNMATCHED
reason = "weak_candidate"
elif diff < AMBIGUOUS_DIFF:
status = STATUS_AMBIGUOUS
reason = "close_candidates"
else:
status = STATUS_PENDING
reason = "below_auto_link_threshold"
return MatchResult(
toml_name=member.name,
role=member.role,
status=status,
vrchat_name=top_name,
score=top_score,
second_score=second_score,
reason=reason,
candidates=candidates,
)
def _best_fuzzy_match(
member: TomlMember,
current_users: Iterable[str],
min_score: float,
) -> Optional[MatchResult]:
candidates = _make_candidates(
member.name,
current_users,
)
if not candidates:
return None
top_name, top_score = candidates[0]
second_score = candidates[1][1] if len(candidates) >= 2 else 0
if (
top_score >= min_score
and top_score - second_score >= AMBIGUOUS_DIFF
):
return MatchResult(
toml_name=member.name,
role=member.role,
status=STATUS_MATCHED,
vrchat_name=top_name,
score=top_score,
second_score=second_score,
reason="fuzzy",
candidates=candidates,
)
return None
def _apply_fuzzy_round(
remaining_members: Dict[str, TomlMember],
remaining_users: set,
min_score: float,
status: str,
reason: str,
eligible_roles: Optional[set] = None,
) -> List[MatchResult]:
results = []
while True:
possible_matches = []
for key, member in remaining_members.items():
if (
eligible_roles is not None
and member.role not in eligible_roles
):
continue
match = _best_fuzzy_match(
member,
remaining_users,
min_score,
)
if match is None:
continue
possible_matches.append((key, match))
if not possible_matches:
break
possible_matches.sort(
key=lambda x: (
x[1].score if x[1].score is not None else 0,
(x[1].score or 0) - (x[1].second_score or 0),
),
reverse=True,
)
key, match = possible_matches[0]
match.status = status
match.reason = reason
results.append(match)
remaining_members.pop(key)
remaining_users.remove(match.vrchat_name)
return results
def match_username_sets(
toml_members,
current_users: List[str],
) -> Tuple[List[MatchResult], List[str]]:
"""
TOML登録名とVRChat現在ユーザーを段階的に1対1照合する。
戻り値:
- TOML側の照合結果
- TOMLに紐づかなかったVRChat名
"""
members = [_to_toml_member(member) for member in toml_members]
remaining_members = {
f"{idx}:{member.name}": member
for idx, member in enumerate(members)
}
remaining_users = set(current_users)
results = []
# 1. 完全一致: fuzzy判定せず即確定して候補から除外
for key, member in list(remaining_members.items()):
if member.name in remaining_users:
results.append(
MatchResult(
toml_name=member.name,
role=member.role,
status=STATUS_MATCHED,
vrchat_name=member.name,
score=100,
second_score=None,
reason="exact",
)
)
remaining_members.pop(key)
remaining_users.remove(member.name)
# 2. 正規化一致: 一意に対応できるものだけを確定して除外
normalized_members: Dict[str, List[str]] = {}
normalized_users: Dict[str, List[str]] = {}
for key, member in remaining_members.items():
normalized_members.setdefault(
normalize_name(member.name),
[],
).append(key)
for user in remaining_users:
normalized_users.setdefault(
normalize_name(user),
[],
).append(user)
for normalized_name, member_keys in normalized_members.items():
users = normalized_users.get(normalized_name, [])
if len(member_keys) != 1 or len(users) != 1:
continue
key = member_keys[0]
user = users[0]
member = remaining_members[key]
results.append(
MatchResult(
toml_name=member.name,
role=member.role,
status=STATUS_MATCHED,
vrchat_name=user,
score=100,
second_score=None,
reason="normalized",
)
)
remaining_members.pop(key)
remaining_users.remove(user)
# 3. 高信頼fuzzy: 残った候補だけで、確定ごとに候補集合を縮める
results.extend(
_apply_fuzzy_round(
remaining_members,
remaining_users,
AUTO_LINK_SCORE,
STATUS_MATCHED,
"high_confidence_fuzzy",
)
)
# 4. 低信頼guest再選別: guestのみ、候補集合が少ない場合だけ
if len(remaining_members) <= LOW_CONFIDENCE_MAX_REMAINING:
results.extend(
_apply_fuzzy_round(
remaining_members,
remaining_users,
LOW_CONFIDENCE_SCORE,
STATUS_MATCHED_LOW_CONFIDENCE,
"guest_low_confidence_fuzzy",
eligible_roles={ROLE_GUEST},
)
)
# 5. 残りは無理に確定しない
for member in remaining_members.values():
results.append(
_classify_remaining(
member,
remaining_users,
)
)
unknown_users = sorted(remaining_users)
return results, unknown_users
# =========================
# 判定
# =========================
def evaluate_guest_match(
guest_name: str,
current_users: List[str],
):
print("")
print("=" * 60)
print(f"[CHECK] guest={guest_name}")
normalized_guest = normalize_name(guest_name)
print(f"[INFO] normalized_guest={normalized_guest}")
candidates = find_candidates(
guest_name,
current_users,
)
if not candidates:
print("[ERROR] no candidates")
return
print("")
print("[CHECK] candidate list")
for idx, (name, score) in enumerate(candidates[:5], start=1):
print(f" {idx}. {name:<20} score={score:.2f}")
top_name, top_score = candidates[0]
second_score = 0
if len(candidates) >= 2:
second_score = candidates[1][1]
diff = top_score - second_score
print("")
print(f"[INFO] top_score={top_score:.2f}")
print(f"[INFO] second_score={second_score:.2f}")
print(f"[INFO] diff={diff:.2f}")
# 自動確定
if (
top_score >= AUTO_LINK_SCORE
and diff >= AMBIGUOUS_DIFF
):
print("")
print("[INFO] AUTO LINK SAFE")
print(f"[INFO] linked => {top_name}")
return
# 曖昧
print("")
print("[WARN] ambiguous match")
print("[WARN] auto link disabled")
print("")
print("[ACTION] manual review required")
# =========================
# main
# =========================
def main():
print("[CHECK] normalize preview")
for user in TEST_CURRENT_USERS:
print(
f"{user:<20}"
f" => "
f"{normalize_name(user)}"
)
print("")
print("[CHECK] username sync ordered matching test")
results, unknown_users = match_username_sets(
TEST_GUESTS,
TEST_CURRENT_USERS,
)
for result in results:
if result.vrchat_name is None:
print(
f"{result.toml_name:<20}"
f" => "
f"{result.status:<22}"
f" reason={result.reason}"
)
continue
score_text = ""
if result.score is not None:
score_text = f" score={result.score:.2f}"
second_text = ""
if result.second_score is not None:
second_text = f" second={result.second_score:.2f}"
print(
f"{result.toml_name:<20}"
f" => "
f"{result.vrchat_name:<20}"
f" {result.status:<22}"
f" reason={result.reason}"
f"{score_text}"
f"{second_text}"
)
for user in unknown_users:
print(
f"{user:<20}"
f" => "
f"{STATUS_UNKNOWN_MEMBER}"
)
print("")
print("[INFO] completed")
if __name__ == "__main__":
print("[CHECK] start")
answer = input(
"[ACTION] run fuzzy test? (yes/no): "
).strip().lower()
if answer != "yes":
print("[FATAL] cancelled")
input("Press Enter to exit...")
sys.exit(1)
main()

BIN
assets/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

6
build_exe.ps1 Normal file
View File

@@ -0,0 +1,6 @@
$ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $ScriptDir
python -m PyInstaller .\vrc_osc.spec --noconfirm --clean

55
cmd/vrc_osc/main.go Normal file
View File

@@ -0,0 +1,55 @@
package main
import (
"flag"
"log"
"os"
"os/exec"
"path/filepath"
"vrc_osc_go/internal/app"
"vrc_osc_go/internal/buildinfo"
)
func main() {
log.Printf("vrc_osc version=%s build=%s", buildinfo.Version, buildinfo.BuildTime)
log.Printf("runtime main begin")
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 {
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.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")
}

File diff suppressed because it is too large Load Diff

97
cmd/vrc_osc_gui/main.go Normal file
View File

@@ -0,0 +1,97 @@
//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 "translate", "translation":
return "join"
case "settings", "setting", "config":
return "settings"
default:
return ""
}
}
func ensureRuntimeStarted() {
cfg, err := config.Load("")
if err != nil {
_ = app.AppendRuntimeLog("GUI", "runtime autostart skipped config="+err.Error())
return
}
addr := net.JoinHostPort(cfg.OSC.Host, strconv.Itoa(cfg.OSC.Port))
probe, err := net.ListenPacket("udp", addr)
if err != nil {
_ = app.AppendRuntimeLog("GUI", "runtime already listening addr="+addr+" err="+err.Error())
return
}
_ = probe.Close()
exe, err := os.Executable()
if err != nil {
_ = app.AppendRuntimeLog("GUI", "runtime autostart skipped exe="+err.Error())
return
}
runtimeExe := filepath.Join(filepath.Dir(exe), "vrc_osc.exe")
if _, err := os.Stat(runtimeExe); err != nil {
_ = app.AppendRuntimeLog("GUI", "runtime autostart missing exe="+runtimeExe+" err="+err.Error())
return
}
if err := exec.Command(runtimeExe, "--no-gui").Start(); err != nil {
_ = app.AppendRuntimeLog("GUI", "runtime autostart failed exe="+runtimeExe+" err="+err.Error())
return
}
_ = app.AppendRuntimeLog("GUI", "runtime autostart launched exe="+runtimeExe)
}

View File

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

Binary file not shown.

View File

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

File diff suppressed because it is too large Load Diff

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

View File

@@ -0,0 +1,94 @@
package main
import (
"flag"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"unsafe"
"vrc_osc_go/internal/app"
"vrc_osc_go/internal/buildinfo"
"vrc_osc_go/internal/common"
"vrc_osc_go/internal/update"
)
func main() {
_ = appendLauncherLog("LAUNCHER", "launcher main begin")
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)
_ = appendLauncherLog("LAUNCHER", "auto update skipped: "+err.Error())
} else {
_ = appendLauncherLog("LAUNCHER", "auto update ok")
}
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 {
_ = appendLauncherLog("LAUNCHER", "missing runtime exe: "+err.Error())
showError("vrc_osc_launcher", "missing exe: "+err.Error())
log.Fatalf("missing exe: %v", err)
}
_ = 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())
log.Fatalf("launch failed: %v", err)
}
_ = appendLauncherLog("LAUNCHER", "runtime launch returned")
}
func showError(title, message string) {
user32 := syscall.NewLazyDLL("user32.dll")
msgBox := user32.NewProc("MessageBoxW")
const mbOK = 0x00000000
const mbIconError = 0x00000010
_, _, _ = msgBox.Call(
0,
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(message))),
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(title))),
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)
}

Binary file not shown.

View File

@@ -7,9 +7,32 @@ names = []
[guest]
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]
missing_count = 0
[vrc_log]
patterns = [
"OnPlayerJoined",
"OnPlayerLeft",
"Entering Room",
"Joining or Creating Room",
"worldId=",
"wrld_",
]
[ocr.crop]
left = 0.05
top = 0.35

35
config/config.test.toml Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 KiB

BIN
doc/vrcworldtour_new.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
doc/vrcworldtour_new.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -1,70 +1,78 @@
# ワークフロー
# Workflow
## 起動から利用まで
## 全体の流れ
```mermaid
flowchart TD
A[起動] --> B[config を読む]
B --> C[OSC サーバーを起動]
C --> D[VRChat を開く]
D --> E[ExMenu から機能を選ぶ]
E --> F{どの機能か}
A[アプリ起動] --> B[設定読み込み]
B --> C[VRChat ログの探索]
C --> D[GUI 起動]
D --> E[タイマーで定期更新]
E --> F[現在のワールド判定]
E --> G[入退室ログ更新]
E --> H[履歴データ更新]
E --> I[OCR / 翻訳 / 設定反映]
F -->|DiscordSend| G[Discord ミュート操作]
F -->|vrc_log| H[VRC ログ監視]
F -->|ocrEnabled| I[OCR 実行]
F -->|translation| J[OCR + 翻訳]
G --> K[VRChat に戻る]
H --> L[Discord Webhook に通知]
I --> M[runtime に保存]
J --> N[クリップボードへコピー]
J --> M
G --> J[Discord 送信が必要なら通知]
H --> K[日別履歴表示]
K --> L[選択した訪問を export]
L --> M[visit_YYYYMMDD.log を保存]
```
## DiscordSend の流れ
## 起動時
```mermaid
flowchart TD
A[DiscordSend を受信] --> B[Discord ウィンドウを探す]
B --> C{Discord が見つかる?}
C -->|はい| D[Ctrl+Shift+M を送る]
C -->|いいえ| E[ブラウザ版 Discord を探す]
E --> D
D --> F[VRChat に戻す]
1. `config/config.toml` を読み込む。
2. GUI 設定を反映する。
3. `runtime/` 配下のログや履歴を確認する。
4. ネイティブ GUI を開く。
5. タイマーで定期更新を開始する。
## ログ収集
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
flowchart TD
A[vrc_log を受信] --> B[ログ監視 ON/OFF を切り替える]
B --> C{ON か?}
C -->|はい| D[最新ログを読む]
D --> E[入室 / 退室 / ワールド変更を解析]
E --> F{通知対象か?}
F -->|はい| G[Discord Webhook に送る]
F -->|いいえ| H[保存だけする]
C -->|いいえ| I[監視を停止する]
```
- `runtime/runtime.log`
- `runtime/join_leave.log`
- `runtime/world_history.json`
- `runtime/visit_YYYYMMDD.log`
- `runtime/latest_ocr.png`
- `runtime/latest_screenshot.png`
## ocrEnabled の流れ
```mermaid
flowchart TD
A[ocrEnabled を受信] --> B[VRChat 画面を読み取る]
B --> C[OCR を実行]
C --> D[認識結果を保存]
```
## translation の流れ
```mermaid
flowchart TD
A[translation を受信] --> B[VRChat 画面を読み取る]
B --> C[OCR を実行]
C --> D[不要な文字を除去]
D --> E[日本語へ翻訳]
E --> F[翻訳結果をクリップボードへコピー]
E --> G[runtime に保存]
```

View File

@@ -8,6 +8,7 @@ import (
"syscall"
"time"
"vrc_osc_go/internal/buildinfo"
"vrc_osc_go/internal/config"
"vrc_osc_go/internal/consenttool"
"vrc_osc_go/internal/osc"
@@ -18,27 +19,40 @@ type discordMuteState struct {
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
}
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)
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 {
@@ -49,14 +63,25 @@ func Run(configPath string, mode string) error {
}
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)
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)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
@@ -66,6 +91,9 @@ func Run(configPath string, mode string) error {
return err
case <-sigCh:
log.Printf("shutdown signal received")
if tracker := GetWorldHistoryTracker(); tracker != nil {
tracker.FinalizeCurrent(time.Now())
}
server.Close()
return nil
}

84
internal/app/discord.go Normal file
View File

@@ -0,0 +1,84 @@
package app
import (
"fmt"
"log"
"os/exec"
)
func pressDiscordMuteHotkey() error {
script := `
$ErrorActionPreference = "Stop"
function Get-DiscordWindows {
$windows = foreach ($p in Get-Process) {
$title = ($p.MainWindowTitle | Out-String).Trim()
if (-not $title) { continue }
if (-not $title.ToLower().Contains('discord')) { continue }
$p
}
if (-not $windows) { return @() }
return $windows
}
function Get-BestDiscordWindow {
$windows = Get-DiscordWindows
if (-not $windows -or $windows.Count -eq 0) { return $null }
$browserKeywords = @('google chrome', 'microsoft edge', 'mozilla firefox', 'brave', 'opera')
$ranked = $windows | Sort-Object {
$title = ($_.MainWindowTitle | Out-String).Trim().ToLower()
$score = 0
foreach ($keyword in $browserKeywords) {
if ($title.Contains($keyword)) { $score += 10 }
}
if ($title -match '^\(\d+\)\s*discord') { $score += 20 }
if ($title.Contains('discord')) { $score += 5 }
$score
} -Descending
return $ranked | Select-Object -First 1
}
function Get-VRChatWindow {
$windows = Get-Process | Where-Object {
$_.MainWindowTitle -and (
$_.MainWindowTitle -eq 'VRChat' -or $_.MainWindowTitle.ToLower().StartsWith('vrchat ')
)
}
if (-not $windows) { return $null }
return $windows | Sort-Object { $_.MainWindowHandle } -Descending | Select-Object -First 1
}
function Activate-Window($p) {
if ($null -eq $p) { return }
$wshell = New-Object -ComObject WScript.Shell
try { $null = $wshell.AppActivate($p.Id) } catch {}
Start-Sleep -Milliseconds 200
}
$discord = Get-BestDiscordWindow
if ($null -eq $discord) { exit 2 }
Activate-Window $discord
$wshell = New-Object -ComObject WScript.Shell
Start-Sleep -Milliseconds 200
$wshell.SendKeys('^+m')
Start-Sleep -Milliseconds 200
$vrchat = Get-VRChatWindow
if ($null -ne $vrchat) {
Activate-Window $vrchat
}
`
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script)
out, err := cmd.CombinedOutput()
if len(out) > 0 {
log.Printf("discord mute output: %s", string(out))
}
if err != nil {
return fmt.Errorf("discord mute failed: %w", err)
}
return nil
}

View File

@@ -30,34 +30,62 @@ func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
if isPressed && !state.buttonPressed {
state.buttonPressed = true
if state.muted {
log.Printf("ACTION VRChat button pressed -> Discord unmute hotkey")
if err := pressDiscordMuteHotkey(); err != nil {
return err
}
state.muted = false
state.lastAction = now
log.Printf("ACTION VRChat button pressed -> Discord mute toggle")
if err := pressDiscordMuteHotkey(); err != nil {
log.Printf("discord hotkey failed: %v", err)
SetDiscordAction("hotkey_failed")
} 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
}
if !isPressed && state.buttonPressed {
state.buttonPressed = false
if !state.muted {
log.Printf("ACTION VRChat button released -> Discord mute hotkey")
if err := pressDiscordMuteHotkey(); err != nil {
return err
}
state.muted = true
state.lastAction = now
} else {
log.Printf("INFO already muted")
}
}
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
}

View File

@@ -0,0 +1,144 @@
package app
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"time"
"vrc_osc_go/internal/common"
)
type GuestStatus struct {
Name string `json:"name"`
Present bool `json:"present"`
LastJoin time.Time `json:"last_join"`
LastLeave time.Time `json:"last_leave"`
AbsentFor string `json:"absent_for"`
}
type GuestTracker struct {
mu sync.RWMutex
current string
items map[string]*GuestStatus
}
func NewGuestTracker(names []string) *GuestTracker {
items := make(map[string]*GuestStatus, len(names))
for _, name := range names {
items[name] = &GuestStatus{Name: name}
}
return &GuestTracker{items: items}
}
func (t *GuestTracker) SetCurrentInstance(label string) {
t.mu.Lock()
defer t.mu.Unlock()
t.current = label
}
func (t *GuestTracker) 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 {
t.mu.RLock()
defer t.mu.RUnlock()
return t.current
}
func (t *GuestTracker) MarkJoin(name string, at time.Time) {
t.mu.Lock()
defer t.mu.Unlock()
s := t.ensure(name)
s.Present = true
s.LastJoin = at
s.AbsentFor = ""
_ = t.persistLocked()
}
func (t *GuestTracker) MarkLeave(name string, at time.Time) {
t.mu.Lock()
defer t.mu.Unlock()
s := t.ensure(name)
s.Present = false
s.LastLeave = at
if !s.LastJoin.IsZero() {
s.AbsentFor = humanSince(at)
}
_ = t.persistLocked()
}
func (t *GuestTracker) Snapshot(now time.Time) []GuestStatus {
t.mu.RLock()
defer t.mu.RUnlock()
out := make([]GuestStatus, 0, len(t.items))
for _, s := range t.items {
cp := *s
if !cp.Present && !cp.LastLeave.IsZero() {
cp.AbsentFor = humanSince(cp.LastLeave, now)
}
out = append(out, cp)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func (t *GuestTracker) persistLocked() error {
dir := filepath.Join(common.RootDir(), "runtime")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
snapshot := make([]GuestStatus, 0, len(t.items))
for _, s := range t.items {
cp := *s
snapshot = append(snapshot, cp)
}
f, err := os.Create(filepath.Join(dir, "guest_snapshot.json"))
if err != nil {
return err
}
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
return enc.Encode(snapshot)
}
func (t *GuestTracker) ensure(name string) *GuestStatus {
if s, ok := t.items[name]; ok {
return s
}
s := &GuestStatus{Name: name}
t.items[name] = s
return s
}
func humanSince(at time.Time, now ...time.Time) string {
ref := time.Now()
if len(now) > 0 {
ref = now[0]
}
if at.IsZero() {
return ""
}
d := ref.Sub(at)
if d < time.Minute {
return "1分未満"
}
m := int(d.Minutes())
if m < 60 {
return fmt.Sprintf("%d分前", m)
}
h := m / 60
if h < 24 {
return fmt.Sprintf("%d時間前", h)
}
return fmt.Sprintf("%d日前", h/24)
}

View File

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

View File

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

View File

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

128
internal/app/ocr.go Normal file
View 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
}

View File

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

153
internal/app/runtime.go Normal file
View 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)
}

View File

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

141
internal/app/state.go Normal file
View File

@@ -0,0 +1,141 @@
package app
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"vrc_osc_go/internal/common"
)
type RuntimeState struct {
mu sync.RWMutex
DiscordMuted bool
DiscordWindow string
DiscordSource string
DiscordAction string
LastOCRText string
LastTranslate string
CurrentWorld string
CurrentWorldSince string
}
var runtimeState = &RuntimeState{}
var guestTracker *GuestTracker
func SetGuestTracker(t *GuestTracker) { guestTracker = t }
func InitGuestTracker(t *GuestTracker) {
guestTracker = t
if guestTracker != nil {
guestTracker.mu.Lock()
_ = guestTracker.persistLocked()
guestTracker.mu.Unlock()
}
}
func GetGuestTracker() *GuestTracker { return guestTracker }
func SetDiscordMuted(v bool) {
runtimeState.mu.Lock()
runtimeState.DiscordMuted = v
runtimeState.mu.Unlock()
_ = persistRuntimeState()
}
func 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) {
runtimeState.mu.Lock()
runtimeState.LastOCRText = v
runtimeState.mu.Unlock()
_ = persistRuntimeState()
}
func SetTranslateText(v string) {
runtimeState.mu.Lock()
runtimeState.LastTranslate = v
runtimeState.mu.Unlock()
_ = persistRuntimeState()
}
func SetCurrentWorld(v string) {
runtimeState.mu.Lock()
runtimeState.CurrentWorld = v
runtimeState.mu.Unlock()
_ = persistRuntimeState()
}
func 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 {
runtimeState.mu.RLock()
defer runtimeState.mu.RUnlock()
return *runtimeState
}
func persistRuntimeState() error {
s := SnapshotRuntime()
dir := filepath.Join(common.RootDir(), "runtime")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
f, err := os.Create(filepath.Join(dir, "state.json"))
if err != nil {
return err
}
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
return enc.Encode(map[string]any{
"discord_muted": s.DiscordMuted,
"discord_window": s.DiscordWindow,
"discord_source": s.DiscordSource,
"discord_action": s.DiscordAction,
"ocr": s.LastOCRText,
"translate": s.LastTranslate,
"world": s.CurrentWorld,
"world_since": s.CurrentWorldSince,
"updated_at": time.Now().Format(time.RFC3339),
})
}

573
internal/app/vrc_log.go Normal file
View File

@@ -0,0 +1,573 @@
package app
import (
"bytes"
"fmt"
"io"
"log"
"os"
"path/filepath"
"regexp"
"strings"
"syscall"
"time"
"unsafe"
)
var (
joinPattern = regexp.MustCompile(`OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
leftPattern = regexp.MustCompile(`OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
worldIdPattern = regexp.MustCompile(`worldId=(wrld_[0-9a-fA-F-]+)`)
instanceIdPattern = regexp.MustCompile(`instanceId=([^,}\s]+)`)
worldNamePattern = regexp.MustCompile(`worldName=([^,}]+)`)
worldLocationPattern = regexp.MustCompile(`worldId=(wrld_[0-9a-fA-F-]+):([^\s,\]\)\"']+)`)
worldPattern = regexp.MustCompile(`(wrld_[0-9a-fA-F-]+(?::[^\s\]\)\"']+)?)`)
enteringRoomPattern = regexp.MustCompile(`\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$`)
)
const vrchatLogTailBytes = 2 * 1024 * 1024
type vrcLogState struct {
location string
worldID string
instanceID string
worldName string
pendingWorldName string
presentSet map[string]struct{}
initialized bool
}
func getVrchatLogDir() (string, error) {
userProfile := os.Getenv("USERPROFILE")
if userProfile == "" {
return "", os.ErrNotExist
}
return filepath.Join(userProfile, "AppData", "LocalLow", "VRChat", "VRChat"), nil
}
func findLatestVrchatLog() (string, error) {
dir, err := getVrchatLogDir()
if err != nil {
return "", err
}
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
var latest string
var latestMod time.Time
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(strings.ToLower(name), ".log") && !strings.HasSuffix(strings.ToLower(name), ".txt") {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
if info.ModTime().After(latestMod) {
latestMod = info.ModTime()
latest = filepath.Join(dir, name)
}
}
if latest == "" {
return "", os.ErrNotExist
}
return latest, nil
}
func watchVrchatLog() {
log.Printf("vrchat log watcher started")
defer func() {
if r := recover(); r != nil {
log.Printf("vrchat log watcher panic: %v", r)
}
}()
lastPath := ""
lastSize := int64(0)
state := &vrcLogState{
presentSet: map[string]struct{}{},
}
for {
path, err := findLatestVrchatLog()
if err != nil {
log.Printf("vrchat log not found: %v", err)
time.Sleep(currentGUIRefreshInterval())
continue
}
info, err := os.Stat(path)
if err != nil {
log.Printf("vrchat log stat failed: %v", err)
time.Sleep(currentGUIRefreshInterval())
continue
}
log.Printf("vrchat log loop path=%s size=%d last=%d", path, info.Size(), lastSize)
if path != lastPath {
log.Printf("vrchat log watching %s", path)
lastPath = path
lastSize = 0
log.Printf("vrchat log initial scan begin path=%s", path)
if err := scanExistingVrchatLog(path, state); err != nil {
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 {
lastSize = info2.Size()
} else {
lastSize = info.Size()
}
time.Sleep(currentGUIRefreshInterval())
continue
}
if info.Size() < lastSize {
lastSize = 0
}
if info.Size() > lastSize {
log.Printf("vrchat log updated path=%s old=%d new=%d", path, lastSize, info.Size())
b, err := readFileFrom(path, lastSize)
if err != nil {
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
}
if err := appendRuntimeLog("VRC LOG", line); err != nil {
log.Printf("append runtime log failed: %v", err)
}
at := extractLineTime(line)
if changed, worldLabel := updateWorldState(state, line); changed {
log.Printf("world changed: %s", worldLabel)
state.presentSet = map[string]struct{}{}
handleWorldChange(state, worldLabel, at)
_ = appendRuntimeLog("VRC WORLD", worldLabel)
}
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1])
log.Printf("join detected: %s", name)
joinHits++
state.presentSet[name] = struct{}{}
if tracker := GetGuestTracker(); tracker != nil {
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))
log.Print(out)
if err := appendJoinLeaveLog(out); err != nil {
log.Printf("append join log failed: %v", err)
}
continue
}
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1])
log.Printf("leave detected: %s", name)
leaveHits++
delete(state.presentSet, name)
if tracker := GetGuestTracker(); tracker != nil {
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))
log.Print(out)
if err := appendJoinLeaveLog(out); err != nil {
log.Printf("append leave log failed: %v", err)
}
continue
}
}
log.Printf("vrchat log batch done join=%d leave=%d", joinHits, leaveHits)
lastSize = info.Size()
}
time.Sleep(currentGUIRefreshInterval())
}
}
func readFileFrom(path string, offset int64) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
if offset > 0 {
if _, err := f.Seek(offset, 0); err != nil {
return nil, err
}
}
return io.ReadAll(f)
}
func readFileTail(path string, maxBytes int64) ([]byte, error) {
if maxBytes <= 0 {
return os.ReadFile(path)
}
info, err := os.Stat(path)
if err != nil {
return nil, err
}
if info.Size() <= maxBytes {
return os.ReadFile(path)
}
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
if _, err := f.Seek(info.Size()-maxBytes, 0); err != nil {
return nil, err
}
b, err := io.ReadAll(f)
if err != nil {
return nil, err
}
for i, c := range b {
if c == '\n' && i+1 < len(b) {
return b[i+1:], nil
}
}
return b, nil
}
func scanExistingVrchatLog(path string, state *vrcLogState) error {
b, err := os.ReadFile(path)
if err != nil {
return err
}
state.presentSet = map[string]struct{}{}
state.location = ""
state.worldID = ""
state.instanceID = ""
state.worldName = ""
state.pendingWorldName = ""
state.initialized = false
text := decodeVRChatLog(b)
var lastWorldAt time.Time
sawWorldChange := false
for _, raw := range strings.Split(text, "\n") {
line := strings.TrimRight(raw, "\r")
if line == "" {
continue
}
if changed, _ := updateWorldState(state, line); changed {
sawWorldChange = true
lastWorldAt = extractLineTime(line)
state.presentSet = map[string]struct{}{}
handleWorldChange(state, state.worldName, lastWorldAt)
}
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1])
state.presentSet[name] = struct{}{}
if tracker := GetGuestTracker(); tracker != nil {
tracker.MarkJoin(name, extractLineTime(line))
}
continue
}
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1])
delete(state.presentSet, name)
if tracker := GetGuestTracker(); tracker != nil {
tracker.MarkLeave(name, extractLineTime(line))
}
continue
}
}
state.initialized = true
if state.worldName != "" {
if !sawWorldChange {
log.Printf("world changed: %s", state.worldName)
if lastWorldAt.IsZero() {
lastWorldAt = time.Now()
}
handleWorldChange(state, state.worldName, lastWorldAt)
}
if err := appendRuntimeLog("VRC WORLD", state.worldName); err != nil {
log.Printf("append world log failed: %v", err)
}
}
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(syscall.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(syscall.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 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 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 syscall.UTF16ToString(buf)
}
func updateWorldState(state *vrcLogState, line string) (bool, string) {
worldID := ""
instanceID := ""
worldName := ""
roomTitle := ""
if m := enteringRoomPattern.FindStringSubmatch(line); len(m) == 2 {
roomTitle = strings.TrimSpace(m[1])
state.pendingWorldName = roomTitle
}
if m := worldLocationPattern.FindStringSubmatch(line); len(m) == 3 {
worldID = strings.TrimSpace(m[1])
instanceID = strings.TrimSpace(m[2])
}
if m := worldIdPattern.FindStringSubmatch(line); len(m) == 2 {
worldID = strings.TrimSpace(m[1])
}
if m := instanceIdPattern.FindStringSubmatch(line); len(m) == 2 {
instanceID = strings.TrimSpace(m[1])
}
if m := worldNamePattern.FindStringSubmatch(line); len(m) == 2 {
worldName = strings.TrimSpace(m[1])
}
if m := enteringRoomPattern.FindStringSubmatch(line); len(m) == 2 && worldID == "" {
if worldMatch := worldPattern.FindStringSubmatch(m[1]); len(worldMatch) == 2 {
worldID = worldMatch[1]
}
}
nextLocation := ""
if worldID != "" {
nextLocation = worldID
if instanceID != "" {
nextLocation = worldID + ":" + instanceID
}
}
if nextLocation == "" && roomTitle != "" {
nextLocation = roomTitle
}
if nextLocation != "" && nextLocation != state.location {
state.location = nextLocation
state.worldID = worldID
state.instanceID = instanceID
if worldName != "" {
state.worldName = worldName
} else if shouldUseRoomTitlePreferLatest(roomTitle, state.worldName) {
state.worldName = roomTitle
}
label := state.worldName
if label == "" {
label = state.worldID
}
if label == "" {
label = nextLocation
}
if state.initialized {
log.Printf("world changed: %s", label)
}
state.initialized = true
return true, label
}
if worldName != "" {
state.worldName = worldName
state.pendingWorldName = ""
return false, ""
}
if shouldUseRoomTitlePreferLatest(roomTitle, state.worldName) {
state.worldName = roomTitle
}
return false, ""
}
func shouldUseRoomTitle(roomTitle, currentWorldName string) bool {
if roomTitle == "" {
return false
}
if strings.Contains(roomTitle, "Home Location") {
return false
}
if roomTitle == "Holiday-Cottage" && currentWorldName != "" && currentWorldName != roomTitle {
return false
}
if currentWorldName == "" {
return true
}
if roomTitle == currentWorldName {
return false
}
if len(roomTitle) > len(currentWorldName) {
return true
}
if strings.Contains(roomTitle, "「") || strings.Contains(roomTitle, "」") || strings.Contains(roomTitle, "[") {
return true
}
return false
}
func shouldUseRoomTitlePreferLatest(roomTitle, currentWorldName string) bool {
if roomTitle == "" {
return false
}
if strings.Contains(roomTitle, "Home Location") {
return false
}
if currentWorldName == "" {
return true
}
if roomTitle == currentWorldName {
return false
}
return true
}
func extractLineTime(line string) time.Time {
if len(line) < 19 {
return time.Now()
}
t, err := time.ParseInLocation("2006.01.02 15:04:05", line[:19], time.Local)
if err == nil {
return t
}
return time.Now()
}
func handleWorldChange(state *vrcLogState, worldLabel string, at time.Time) {
if state == nil {
return
}
SetCurrentWorldVisit(worldLabel, at)
if tracker := GetWorldHistoryTracker(); tracker != nil {
tracker.ObserveWorld(worldLabel, state.worldID, state.instanceID, at)
}
if tracker := GetGuestTracker(); tracker != nil {
tracker.ResetCurrentInstance(worldLabel)
}
}

View File

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

View File

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

View File

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

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

359
internal/config/config.go Normal file
View File

@@ -0,0 +1,359 @@
package config
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"vrc_osc_go/internal/common"
)
type Config struct {
OSC OSCConfig
VrcLog VrcLogConfig
GUI GUIConfig
}
type OSCConfig struct {
Host string
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) {
cfg := &Config{
OSC: OSCConfig{Host: "127.0.0.1", Port: 9001},
GUI: GUIConfig{
FontSize: 18,
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)
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 := parseConfigValue(parts[1])
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
}
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()
}
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
}

View File

@@ -0,0 +1,75 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoadDefaultsWhenMissing(t *testing.T) {
cfg, err := Load("does-not-exist.toml")
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.OSC.Host != "127.0.0.1" || cfg.OSC.Port != 9001 {
t.Fatalf("unexpected defaults: %+v", cfg.OSC)
}
}
func TestLoadOSCValues(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
if err := os.WriteFile(path, []byte("[osc]\nhost = \"0.0.0.0\"\nport = 9002\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.OSC.Host != "0.0.0.0" || cfg.OSC.Port != 9002 {
t.Fatalf("unexpected values: %+v", cfg.OSC)
}
}
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)
}
}

View File

@@ -0,0 +1,14 @@
package osc
import "testing"
func TestParseMessageAddress(t *testing.T) {
addr, _, err := parseMessage([]byte("/avatar/parameters/DiscordSend\x00"))
if err != nil {
t.Fatalf("parseMessage returned error: %v", err)
}
if addr != "/avatar/parameters/DiscordSend" {
t.Fatalf("unexpected address: %q", addr)
}
}

106
internal/update/manager.go Normal file
View File

@@ -0,0 +1,106 @@
package update
import (
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"vrc_osc_go/internal/buildinfo"
"vrc_osc_go/internal/common"
)
type Manager struct {
Client *Client
OwnerRepo string
AssetName string
CurrentLabel string
}
func (m *Manager) CheckAndUpdate() (bool, error) {
if m.Client == nil || m.OwnerRepo == "" || m.AssetName == "" {
return false, nil
}
latest, err := m.Client.LatestRelease(m.OwnerRepo)
if err != nil {
return false, err
}
if latest.TagName == "" || latest.TagName == m.CurrentLabel {
return false, nil
}
assetURL := ""
for _, a := range latest.Assets {
if a.Name == m.AssetName {
assetURL = a.URL
break
}
}
if assetURL == "" {
return false, fmt.Errorf("asset %q not found in release %s", m.AssetName, latest.TagName)
}
base := common.RootDir()
tmpDir := filepath.Join(base, "update", latest.TagName)
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
return false, err
}
zipPath := filepath.Join(tmpDir, m.AssetName)
log.Printf("update available: %s -> %s", m.CurrentLabel, latest.TagName)
if err := m.Client.DownloadAsset(assetURL, zipPath); err != nil {
return false, err
}
if err := ExtractZip(zipPath, tmpDir); err != nil {
return false, err
}
exePath := filepath.Join(base, CurrentExeName())
newExe := filepath.Join(tmpDir, CurrentExeName())
if _, err := os.Stat(newExe); err != nil {
return false, err
}
backup := exePath + ".bak"
_ = os.Remove(backup)
if err := os.Rename(exePath, backup); err != nil {
return false, err
}
if err := CopyFile(newExe, exePath); err != nil {
_ = os.Rename(backup, exePath)
return false, err
}
_ = os.Remove(backup)
return true, nil
}
func CopyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
func Launch(exe string, args []string) error {
cmd := exec.Command(exe, args...)
cmd.Dir = filepath.Dir(exe)
return cmd.Start()
}
func Version() string { return buildinfo.Version }
func CurrentExeName() string {
if runtime.GOOS == "windows" {
return "vrc_osc.exe"
}
return "vrc_osc"
}

125
internal/update/release.go Normal file
View File

@@ -0,0 +1,125 @@
package update
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
type ReleaseInfo struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
} `json:"assets"`
}
type Client struct {
BaseURL string
HTTP *http.Client
}
func (c *Client) LatestRelease(ownerRepo string) (*ReleaseInfo, error) {
req, err := http.NewRequest(http.MethodGet, c.apiURL(ownerRepo, "/releases/latest"), nil)
if err != nil {
return nil, err
}
var info ReleaseInfo
if err := c.doJSON(req, &info); err != nil {
return nil, err
}
return &info, nil
}
func (c *Client) DownloadAsset(url, dst string) error {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := c.httpClient().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: %s", resp.Status)
}
f, err := os.Create(dst)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, resp.Body)
return err
}
func ExtractZip(zipPath, dstDir string) error {
zr, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer zr.Close()
root := filepath.Clean(dstDir) + string(os.PathSeparator)
for _, f := range zr.File {
target := filepath.Clean(filepath.Join(dstDir, f.Name))
if target != filepath.Clean(dstDir) && !strings.HasPrefix(target, root) {
return fmt.Errorf("zip path escapes destination: %s", f.Name)
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return err
}
out, err := os.Create(target)
if err != nil {
rc.Close()
return err
}
_, copyErr := io.Copy(out, rc)
out.Close()
rc.Close()
if copyErr != nil {
return copyErr
}
}
return nil
}
func (c *Client) apiURL(ownerRepo, suffix string) string {
base := strings.TrimRight(c.BaseURL, "/")
return base + "/api/v1/repos/" + strings.Trim(ownerRepo, "/") + suffix
}
func (c *Client) doJSON(req *http.Request, out any) error {
resp, err := c.httpClient().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("request failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
return json.NewDecoder(resp.Body).Decode(out)
}
func (c *Client) httpClient() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return &http.Client{Timeout: 30 * time.Second}
}

View File

@@ -7,4 +7,6 @@ pykakasi
pyperclip
python-osc
rapidfuzz
pyinstaller
chardet==5.2.0
vrchatapi

View File

@@ -14,10 +14,6 @@ if str(SRC_DIR) not in sys.path:
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 vision.vision_actions import runVisionFromScreen
from vrc_log.log_actions import collectVrchatLog
from vrc_log.log_actions import startSelfMonitor
from vrc_log.log_actions import startVrcLogMonitor
@@ -68,6 +64,7 @@ def create_gateway():
if gateway.is_rising_edge(address, args[0]):
gateway.log("INFO", f"received {address} args={args}")
from ocr.ocr_actions import runOcrFromScreen
runOcrFromScreen()
def on_translate_ocr(address, *args):
@@ -78,6 +75,9 @@ def create_gateway():
if gateway.is_rising_edge(address, args[0]):
gateway.log("INFO", f"received {address} args={args}")
from ocr.ocr_actions import runOcrFromScreen
from translate.translate_actions import saveTranslationText
from translate.translate_actions import translateTextToJapanese
result = runOcrFromScreen()
if not result:
gateway.log("ERROR", "OCR result is None")
@@ -105,6 +105,7 @@ def create_gateway():
if gateway.is_rising_edge(address, args[0]):
gateway.log("INFO", f"received {address} args={args}")
from vision.vision_actions import runVisionFromScreen
runVisionFromScreen()
gateway.map(PARAM_DISCORD_MUTE, on_discord_mute)

View File

@@ -1,6 +1,15 @@
import sys
from pathlib import Path
SRC_DIR = Path(__file__).resolve().parents[1]
ROOT_DIR = SRC_DIR.parent
def _get_runtime_base_dir():
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parents[2]
ROOT_DIR = _get_runtime_base_dir()
SRC_DIR = ROOT_DIR / "src"
RUNTIME_DIR = ROOT_DIR / "runtime"
RUNTIME_LOG_FILE = RUNTIME_DIR / "runtime.log"
RUNTIME_JOIN_LEAVE_LOG_FILE = RUNTIME_DIR / "join_leave.log"

View File

@@ -1,6 +1,7 @@
from datetime import datetime
from common.project_paths import RUNTIME_LOG_FILE
from common.project_paths import RUNTIME_JOIN_LEAVE_LOG_FILE
def appendRuntimeLog(title, text):
@@ -17,3 +18,19 @@ def appendRuntimeLog(title, text):
file.write("\n")
return RUNTIME_LOG_FILE
def appendJoinLeaveLog(text):
RUNTIME_JOIN_LEAVE_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_JOIN_LEAVE_LOG_FILE.open("a", encoding="utf-8") as file:
file.write(f"\n[{now}] VRC JOIN/LEAVE\n")
file.write(body)
file.write("\n")
return RUNTIME_JOIN_LEAVE_LOG_FILE

View File

@@ -61,18 +61,12 @@ class VrcOscGateway:
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)
def serve_forever(self):
self.log("CHECK", "STEP=1 状態確認")
self.log("INFO", f"listen={self.host}:{self.port}")
try:
server = BlockingOSCUDPServer((self.host, self.port), self.dispatcher)
server.serve_forever()
except KeyboardInterrupt:
self.log("INFO", "Ctrl+C detected")

View File

@@ -18,6 +18,8 @@ from discord_control.actions import setDiscordMuted
_monitor_thread = None
_monitor_stop_event = threading.Event()
_last_sent_output = None
_last_join_leave_output = ""
_monitor_vrc_state = None
_self_monitor_thread = None
_self_monitor_stop_event = threading.Event()
_last_self_state = None
@@ -39,7 +41,10 @@ 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=([^,}]+)"
r"worldId=(wrld_[0-9a-fA-F-]+)(?::[^,}]*)?,\s*(?:instanceId=([^,}]+),\s*)?worldName=([^,}]+)"
)
WORLD_LOCATION_PATTERN = re.compile(
r"worldId=(wrld_[0-9a-fA-F-]+)(?::[^,}]*)?,\s*instanceId=([^,}]+)"
)
TIME_PATTERN = re.compile(
r"(\d{4}\.\d{2}\.\d{2}\s+\d{2}:\d{2}:\d{2})"
@@ -77,7 +82,9 @@ param(
$Payload = @{ content = $Message } | ConvertTo-Json -Compress
$Body = [System.Text.Encoding]::UTF8.GetBytes($Payload)
$Response = Invoke-WebRequest -Uri $WebhookUrl -Method Post -ContentType 'application/json; charset=utf-8' -Body $Body -UseBasicParsing
Write-Output ("STATUS=" + $Response.StatusCode)
if ($Response.StatusCode -ne 200) {
Write-Output ("STATUS=" + $Response.StatusCode)
}
'''
ps_path = None
@@ -127,7 +134,7 @@ param(
stdout = (result.stdout or "").strip()
if stdout:
log("INFO", f"Discord webhook PowerShell stdout={stdout}")
log("DEBUG", f"Discord webhook PowerShell stdout={stdout}")
log("INFO", "Discord webhook sent via PowerShell")
return True
@@ -205,7 +212,7 @@ def extractWorldNameMap(text):
match = WORLD_NAME_PATTERN.search(raw_line)
if match:
world_id = match.group(1).strip()
world_name = match.group(2).strip()
world_name = match.group(3).strip()
if world_id and world_name:
world_name_map[world_id] = world_name
continue
@@ -322,10 +329,9 @@ def matchesAnyPattern(text, compiled_patterns):
def formatJoinLeaveLine(line_time, action, name, state, guest_names):
staff_count = len(state["staff_present_set"])
member_count = len(state["member_present_set"])
total_count = len(state["present_set"])
guest_count = len(state["guest_names"])
return f'[{formatClock(line_time)}][{action}] {name} {member_count - staff_count}/{guest_count}'
return f'[{formatClock(line_time)}][{action}] {name} {total_count}/{guest_count}'
def parseVrchatEvents(text, config):
@@ -340,12 +346,11 @@ def parseVrchatEvents(text, config):
"location": "",
"world_id": "",
"instance_id": "",
"present_set": set(),
"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,
}
@@ -383,9 +388,8 @@ def parseVrchatEvents(text, config):
state["location"] = location or world_id
state["world_id"] = world_id
state["instance_id"] = instance_id
state["present_set"].clear()
state["guest_present_set"].clear()
state["staff_present_set"].clear()
state["member_present_set"].clear()
state["last_notice_key"] = None
output_lines = []
world_label = formatWorldLabel(world_id, world_name_map)
@@ -398,7 +402,7 @@ def parseVrchatEvents(text, config):
join_leave_line = None
if isSelf(name, self_name):
self_state = "joined"
state["member_present_set"].add(name)
state["present_set"].add(name)
join_leave_line = formatJoinLeaveLine(line_time, "join", name, state, guest_names)
output_lines.append(join_leave_line)
continue
@@ -407,16 +411,16 @@ def parseVrchatEvents(text, config):
if role == "guest":
state["guest_present_set"].add(name)
state["member_present_set"].add(name)
state["present_set"].add(name)
join_leave_line = formatJoinLeaveLine(line_time, "join", name, state, guest_names)
output_lines.append(join_leave_line)
appendNoticeIfNeeded(output_lines, state)
elif role == "staff":
state["staff_present_set"].add(name)
state["present_set"].add(name)
join_leave_line = formatJoinLeaveLine(line_time, "join", name, state, guest_names)
output_lines.append(join_leave_line)
else:
state["member_present_set"].add(name)
state["present_set"].add(name)
join_leave_line = formatJoinLeaveLine(line_time, "join", name, state, guest_names)
output_lines.append(join_leave_line)
continue
@@ -427,7 +431,7 @@ def parseVrchatEvents(text, config):
join_leave_line = None
if isSelf(name, self_name):
self_state = "left"
state["member_present_set"].discard(name)
state["present_set"].discard(name)
join_leave_line = formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
output_lines.append(join_leave_line)
continue
@@ -436,16 +440,16 @@ def parseVrchatEvents(text, config):
if role == "guest":
state["guest_present_set"].discard(name)
state["member_present_set"].discard(name)
state["present_set"].discard(name)
join_leave_line = formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
output_lines.append(join_leave_line)
appendNoticeIfNeeded(output_lines, state)
elif role == "staff":
state["staff_present_set"].discard(name)
state["present_set"].discard(name)
join_leave_line = formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
output_lines.append(join_leave_line)
else:
state["member_present_set"].discard(name)
state["present_set"].discard(name)
join_leave_line = formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
output_lines.append(join_leave_line)
continue
@@ -453,6 +457,142 @@ def parseVrchatEvents(text, config):
return output_lines, self_state
def createVrcState(config, world_name_map=None):
guest_names = config["guest_names"]
return {
"location": "",
"world_id": "",
"instance_id": "",
"present_set": set(),
"guest_names": guest_names,
"guest_name_set": set(guest_names),
"staff_names": set(config["staff_names"]),
"guest_present_set": set(),
"missing_count": config["missing_count"],
"last_notice_key": None,
"world_name_map": world_name_map or {},
"last_join_leave_output": "",
}
def processVrcText(text, config, state):
return processVrcLines(text.splitlines(), config, state)
def processVrcLines(raw_lines, config, state):
output_lines = []
world_name_map = state.get("world_name_map") or {}
guest_names = state["guest_names"]
staff_names = state["staff_names"]
self_name = config.get("self_name", "")
compiled_patterns = compileLogPatterns(config.get("log_patterns", []))
for raw_line in raw_lines:
line_time = parseLineTime(raw_line)
if not line_time:
continue
line_matches = matchesAnyPattern(raw_line, compiled_patterns)
room_match = ENTERING_ROOM_PATTERN.search(raw_line)
world_name_match = WORLD_NAME_PATTERN.search(raw_line)
world_location_match = WORLD_LOCATION_PATTERN.search(raw_line)
world_match = WORLD_PATTERN.search(raw_line)
location = ""
world_id = ""
instance_id = ""
if world_name_match:
world_id = world_name_match.group(1).strip()
instance_id = (world_name_match.group(2) or "").strip()
location = world_id
elif world_location_match:
world_id = world_location_match.group(1).strip()
instance_id = world_location_match.group(2).strip()
location = f"{world_id}:{instance_id}" if instance_id else world_id
elif room_match and world_match:
location = world_match.group(1).strip()
world_id, instance_id = splitWorldLocation(location)
elif world_match:
location = world_match.group(1).strip()
world_id, instance_id = splitWorldLocation(location)
next_location = f"{world_id}:{instance_id}" if world_id else ""
if next_location and next_location != state["location"]:
state["location"] = next_location
state["world_id"] = world_id
state["instance_id"] = instance_id
state["present_set"].clear()
state["guest_present_set"].clear()
state["last_notice_key"] = None
state["last_join_leave_output"] = ""
world_label = formatWorldLabel(world_id, world_name_map)
if world_label != "不明":
output_lines.append(f"ワールド入室: {world_label}")
join_match = JOIN_PATTERN.search(raw_line)
if join_match and line_matches:
name = join_match.group(1).strip()
if isSelf(name, self_name):
state["present_set"].add(name)
output_lines.append(
formatJoinLeaveLine(line_time, "join", name, state, guest_names)
)
continue
role = classifyUser(name, state["guest_name_set"], staff_names)
if role == "guest":
state["guest_present_set"].add(name)
state["present_set"].add(name)
output_lines.append(
formatJoinLeaveLine(line_time, "join", name, state, guest_names)
)
appendNoticeIfNeeded(output_lines, state)
elif role == "staff":
state["present_set"].add(name)
output_lines.append(
formatJoinLeaveLine(line_time, "join", name, state, guest_names)
)
else:
state["present_set"].add(name)
output_lines.append(
formatJoinLeaveLine(line_time, "join", name, state, guest_names)
)
continue
left_match = LEFT_PATTERN.search(raw_line)
if left_match and line_matches:
name = left_match.group(1).strip()
if isSelf(name, self_name):
state["present_set"].discard(name)
output_lines.append(
formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
)
continue
role = classifyUser(name, state["guest_name_set"], staff_names)
if role == "guest":
state["guest_present_set"].discard(name)
state["present_set"].discard(name)
output_lines.append(
formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
)
appendNoticeIfNeeded(output_lines, state)
elif role == "staff":
state["present_set"].discard(name)
output_lines.append(
formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
)
else:
state["present_set"].discard(name)
output_lines.append(
formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
)
continue
return output_lines, state
def extractLatestSelfLogState(text, self_name):
if not self_name:
return None
@@ -481,7 +621,9 @@ def collectVrchatLog(pattern=None, notify_changed=True):
latest_log = findLatestVrchatLog()
text = readTextSafe(latest_log)
lines, self_state = parseVrchatEvents(text, config)
world_name_map = extractWorldNameMap(text)
state = createVrcState(config, world_name_map=world_name_map)
lines, state = processVrcText(text, config, state)
output_text = "\n".join(lines) if lines else ""
output_path = None
@@ -489,13 +631,22 @@ def collectVrchatLog(pattern=None, notify_changed=True):
output_path = appendRuntimeLog("VRC LOG", output_text)
join_leave_lines = [line for line in lines if "[join]" in line or "[leave]" in line]
if join_leave_lines:
appendJoinLeaveLog("\n".join(join_leave_lines))
global _last_join_leave_output
current_join_leave_output = "\n".join(join_leave_lines)
join_leave_diff = buildDiffMessage(
_last_join_leave_output,
current_join_leave_output,
)
if join_leave_diff:
appendJoinLeaveLog(join_leave_diff)
_last_join_leave_output = current_join_leave_output
if notify_changed:
global _last_sent_output
diff_text = buildDiffMessage(_last_sent_output, output_text)
if diff_text:
try:
appendRuntimeLog("VRC LOG DIFF", diff_text)
ok = sendWebhook(diff_text)
if ok:
log("INFO", "Discord webhook diff sent")
@@ -512,6 +663,16 @@ def collectVrchatLog(pattern=None, notify_changed=True):
return output_path
def initializeVrcMonitorState():
global _monitor_vrc_state
config = loadVrcLogConfig()
latest_log = findLatestVrchatLog()
text = readTextSafe(latest_log)
world_name_map = extractWorldNameMap(text)
_monitor_vrc_state = createVrcState(config, world_name_map=world_name_map)
return _monitor_vrc_state
def _readNewText(path, offset):
for encoding in ("utf-8", "utf-8-sig", "cp932", "shift_jis"):
try:
@@ -561,11 +722,54 @@ def _watchVrchatLogChanges(stop_event, label, on_relevant_change):
def _monitor_loop():
global _monitor_vrc_state
if _monitor_vrc_state is None:
try:
initializeVrcMonitorState()
except Exception as e:
log("ERROR", f"VRC log monitor init failed detail={e}")
def on_relevant_change(text):
global _monitor_vrc_state
global _last_sent_output
global _last_join_leave_output
if not any(token in text for token in ("OnPlayerJoined", "OnPlayerLeft", "Entering Room", "Joining or Creating Room", "worldId=", "wrld_")):
return
collectVrchatLog(notify_changed=True)
try:
config = loadVrcLogConfig()
if _monitor_vrc_state is None:
initializeVrcMonitorState()
lines, state = processVrcText(text, config, _monitor_vrc_state)
_monitor_vrc_state = state
output_text = "\n".join(lines) if lines else ""
if not output_text:
return
appendRuntimeLog("VRC LOG", output_text)
join_leave_lines = [line for line in lines if "[join]" in line or "[leave]" in line]
if join_leave_lines:
current_join_leave_output = "\n".join(join_leave_lines)
if current_join_leave_output != _last_join_leave_output:
join_leave_diff = buildDiffMessage(_last_join_leave_output, current_join_leave_output)
if join_leave_diff:
appendJoinLeaveLog(join_leave_diff)
_last_join_leave_output = current_join_leave_output
global _last_sent_output
diff_text = buildDiffMessage(_last_sent_output, output_text)
if diff_text:
appendRuntimeLog("VRC LOG DIFF", diff_text)
ok = sendWebhook(diff_text)
if ok:
log("INFO", "Discord webhook diff sent")
_last_sent_output = output_text
else:
log("ERROR", "Discord webhook diff send failed")
for line in lines:
print(line, flush=True)
except Exception as e:
log("ERROR", f"VRC log monitor chunk failed detail={e}")
_watchVrchatLogChanges(
_monitor_stop_event,

59
vrc_osc.spec Normal file
View File

@@ -0,0 +1,59 @@
# -*- mode: python ; coding: utf-8 -*-
from pathlib import Path
block_cipher = None
project_dir = Path(SPECPATH).resolve()
src_dir = project_dir / "src"
hiddenimports = [
"paddleocr",
"pygetwindow",
"pyautogui",
"pythonosc",
"rapidfuzz",
"deep_translator",
"chardet.pipeline.orchestrator__mypyc",
]
a = Analysis(
[str(src_dir / "app.py")],
pathex=[str(src_dir)],
binaries=[],
datas=[
(str(project_dir / "config"), "config"),
(str(project_dir / "doc"), "doc"),
],
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name="vrc_osc",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)