Compare commits
27 Commits
6add92ef40
...
75f2d8b9c2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75f2d8b9c2 | ||
|
|
23d5fcfcda | ||
|
|
329bcd0c50 | ||
|
|
93a0251082 | ||
|
|
63700d9d29 | ||
|
|
ab9551c241 | ||
|
|
b6421958af | ||
|
|
8bff7af6bc | ||
|
|
760d6988cb | ||
|
|
74ff0d3c76 | ||
|
|
72aa10de46 | ||
|
|
b34c2fafb4 | ||
|
|
a4394bfde6 | ||
|
|
450316d861 | ||
|
|
e3f497def4 | ||
|
|
1975bf6e08 | ||
|
|
15a24f32ba | ||
|
|
ad81bc6985 | ||
|
|
b189adebe5 | ||
|
|
bd29099425 | ||
|
|
fad20a5677 | ||
|
|
66f94eaa9e | ||
|
|
6a2c9bb39c | ||
|
|
da8a9b3c3f | ||
|
|
6f22223394 | ||
|
|
e8eb9463fa | ||
|
|
54854969b9 |
80
.gitea/workflows/build-windows-exe.yml
Normal file
80
.gitea/workflows/build-windows-exe.yml
Normal file
@@ -0,0 +1,80 @@
|
||||
name: build-windows-exe
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
run: |
|
||||
git clone --depth 1 --branch "${GITHUB_REF_NAME}" https://git.vrcworldtour.com/every_holiday/VRCWT-OSC.git .
|
||||
|
||||
- name: Install Go
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y golang-go
|
||||
go version
|
||||
|
||||
- name: Test
|
||||
shell: bash
|
||||
run: |
|
||||
cd VRWT_Tool/VRC_OSC
|
||||
GO111MODULE=on go test ./...
|
||||
|
||||
- name: Build executables
|
||||
shell: bash
|
||||
run: |
|
||||
cd VRWT_Tool/VRC_OSC
|
||||
mkdir -p dist
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-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 vrc_osc_launcher.exe vrc_osc_gui.exe vrwt_tool.exe config.example.toml secrets.example.toml guests.example.txt)
|
||||
|
||||
- name: Create Release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_BASE_URL: ${{ secrets.GITEA_BASE_URL }}
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
api="${GITEA_BASE_URL:-https://git.vrcworldtour.com}/api/v1"
|
||||
owner_repo="every_holiday/VRCWT-OSC"
|
||||
release_json=$(curl -fsSL \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${api}/repos/${owner_repo}/releases/tags/${TAG_NAME}" || true)
|
||||
if [ -n "${release_json}" ]; then
|
||||
release_id=$(printf '%s' "${release_json}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||
else
|
||||
release_json=$(curl -fsSL -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${TAG_NAME}\",\"name\":\"${TAG_NAME}\",\"draft\":false,\"prerelease\":false}" \
|
||||
"${api}/repos/${owner_repo}/releases")
|
||||
release_id=$(printf '%s' "${release_json}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||
fi
|
||||
asset_path="VRWT_Tool/VRC_OSC/dist/vrc_osc-windows.zip"
|
||||
curl -fsSL -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${asset_path}" \
|
||||
"${api}/repos/${owner_repo}/releases/${release_id}/assets?name=vrc_osc-windows.zip"
|
||||
36
EXE.md
36
EXE.md
@@ -1,25 +1,33 @@
|
||||
# exe Packaging
|
||||
# Windows Release Packaging
|
||||
|
||||
## Build
|
||||
|
||||
Release の zip は Gitea Actions で作ります。
|
||||
|
||||
```powershell
|
||||
.\build_exe.ps1
|
||||
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 -o .\dist\vrwt_tool.exe ./cmd\vrwt_tool
|
||||
```
|
||||
|
||||
This uses `PyInstaller` and writes the result to `dist\vrc_osc\`.
|
||||
## 起動
|
||||
|
||||
## Distribution Layout
|
||||
通常は `vrc_osc_launcher.exe` を起動してください。
|
||||
|
||||
Place these next to `vrc_osc.exe`:
|
||||
- 起動時に最新 Release をチェック
|
||||
- 新しい版があれば自動更新
|
||||
- 更新後に本体を起動
|
||||
|
||||
- `config\config.toml`
|
||||
- `config\secrets.toml`
|
||||
- `config\guests.txt`
|
||||
`vrc_osc.exe` を直接起動した場合は更新チェックしません。
|
||||
|
||||
## 配置
|
||||
|
||||
Release zip を展開したフォルダに以下を置きます。
|
||||
|
||||
- `vrc_osc.exe`
|
||||
- `vrc_osc_launcher.exe`
|
||||
- `vrwt_tool.exe`
|
||||
- `config\`
|
||||
- `runtime\`
|
||||
|
||||
`runtime\` is created on startup if it does not exist.
|
||||
|
||||
## Notes
|
||||
|
||||
- `src\common\project_paths.py` resolves paths relative to the exe when frozen.
|
||||
- Keep `config\` beside the exe. The app does not look inside the PyInstaller temp directory for persistent files.
|
||||
`runtime\` は起動時に作られます。
|
||||
|
||||
134
README.md
134
README.md
@@ -1,133 +1,73 @@
|
||||
# VRC_OSC
|
||||
|
||||
VRC_OSC は、VRChat の状態を見て Discord やログ連携を行うためのツールです。
|
||||
VRChat のログ監視、Discord ミュート連携、OCR、同意リスト生成をまとめたツールです。
|
||||
|
||||
一番の使い方は、VRChat の `ExMenu` から操作することです。
|
||||
`ExMenu` は VRChat のメニュー画面に出る、追加の操作メニューです。
|
||||
## 主な機能
|
||||
|
||||
## できること
|
||||
|
||||
- VRChat のログを見て Discord に通知する
|
||||
- 変更があった部分だけを差分で送る
|
||||
- Discord のミュートを VRChat 側の操作に合わせて切り替える
|
||||
- VRChat 画面を読み取って OCR する
|
||||
- OCR した文字を翻訳してコピーする
|
||||
|
||||
## 画面イメージ
|
||||
|
||||
### ExMenu
|
||||
|
||||
VRChat のメニュー画面から、このツールの機能を操作します。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### VRC Log
|
||||
|
||||
VRChat のログ確認や通知の流れは、次のようなイメージです。
|
||||
|
||||

|
||||
|
||||
## 使い方
|
||||
|
||||
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 -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.*` を見てください。
|
||||
|
||||
## 生成されるログ
|
||||
## ログ
|
||||
|
||||
- `runtime/runtime.log`
|
||||
- 通常の実行ログ
|
||||
- `runtime/join_leave.log`
|
||||
- join / leave だけを抜き出したログ
|
||||
- `runtime/latest_ocr.png`
|
||||
- `runtime/latest_screenshot.png`
|
||||
|
||||
## 詳しい資料
|
||||
|
||||
- [設計書](doc/design.md)
|
||||
- [シーケンス図](doc/sequence.md)
|
||||
- [ワークフロー](doc/workflow.md)
|
||||
|
||||
50
VRWT_Tool/.gitea/workflows/build-windows-exe.yml
Normal file
50
VRWT_Tool/.gitea/workflows/build-windows-exe.yml
Normal file
@@ -0,0 +1,50 @@
|
||||
name: build-windows-exe
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
shell: pwsh
|
||||
run: |
|
||||
choco install golang --version=1.22.0 -y --no-progress
|
||||
$env:PATH = "C:\Program Files\Go\bin;$env:PATH"
|
||||
go version
|
||||
|
||||
- name: Test
|
||||
working-directory: VRC_OSC
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:PATH = "C:\Program Files\Go\bin;$env:PATH"
|
||||
go test ./...
|
||||
|
||||
- name: Build executables
|
||||
working-directory: VRC_OSC
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:PATH = "C:\Program Files\Go\bin;$env:PATH"
|
||||
go build -o dist\vrc_osc.exe ./cmd/vrc_osc
|
||||
go build -o dist\vrwt_tool.exe ./cmd/vrwt_tool
|
||||
|
||||
- name: Package executables
|
||||
working-directory: VRC_OSC
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
||||
Copy-Item config\config.example.toml dist\config.example.toml
|
||||
Copy-Item config\secrets.example.toml dist\secrets.example.toml
|
||||
Copy-Item config\guests.example.txt dist\guests.example.txt
|
||||
Compress-Archive -Path dist\vrc_osc.exe,dist\vrwt_tool.exe,dist\config.example.toml,dist\secrets.example.toml,dist\guests.example.txt -DestinationPath dist\vrc_osc-windows.zip -Force
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vrc_osc-windows
|
||||
path: VRC_OSC/dist/vrc_osc-windows.zip
|
||||
13
VRWT_Tool/VRC_OSC/.gitignore
vendored
Normal file
13
VRWT_Tool/VRC_OSC/.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
config/config.toml
|
||||
config/secrets.toml
|
||||
config/guests.txt
|
||||
runtime/
|
||||
.vrchat_cookie.txt
|
||||
src/.vrchat_cookie.txt
|
||||
|
||||
*.bak
|
||||
*.bak_*
|
||||
backup_*/
|
||||
BIN
VRWT_Tool/VRC_OSC/assets/icon.ico
Normal file
BIN
VRWT_Tool/VRC_OSC/assets/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
BIN
VRWT_Tool/VRC_OSC/assets/icon.png
Normal file
BIN
VRWT_Tool/VRC_OSC/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
7
VRWT_Tool/VRC_OSC/bin/vrc_osc.ps1
Normal file
7
VRWT_Tool/VRC_OSC/bin/vrc_osc.ps1
Normal file
@@ -0,0 +1,7 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$RootDir = Resolve-Path (Join-Path $ScriptDir "..")
|
||||
$EntryPoint = Join-Path $RootDir "bin\vrc_osc.py"
|
||||
|
||||
python $EntryPoint
|
||||
42
VRWT_Tool/VRC_OSC/cmd/vrc_osc/main.go
Normal file
42
VRWT_Tool/VRC_OSC/cmd/vrc_osc/main.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"vrc_osc_go/internal/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var configPath string
|
||||
var mode string
|
||||
var noGUI bool
|
||||
flag.StringVar(&configPath, "config", "config/config.toml", "path to config.toml")
|
||||
flag.StringVar(&mode, "mode", "", "generate-json or extract-log")
|
||||
flag.BoolVar(&noGUI, "no-gui", false, "do not launch the GUI companion")
|
||||
flag.Parse()
|
||||
|
||||
if mode == "" && !noGUI {
|
||||
launchGUI()
|
||||
}
|
||||
|
||||
if err := app.Run(configPath, mode); err != nil {
|
||||
log.SetOutput(os.Stderr)
|
||||
log.Fatalf("vrc_osc_go: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func launchGUI() {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
gui := filepath.Join(filepath.Dir(exe), "vrc_osc_gui.exe")
|
||||
if _, err := os.Stat(gui); err != nil {
|
||||
return
|
||||
}
|
||||
_ = exec.Command(gui).Start()
|
||||
}
|
||||
198
VRWT_Tool/VRC_OSC/cmd/vrc_osc_gui/main.go
Normal file
198
VRWT_Tool/VRC_OSC/cmd/vrc_osc_gui/main.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func runtimeDir() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "runtime"
|
||||
}
|
||||
return filepath.Join(filepath.Dir(exe), "runtime")
|
||||
}
|
||||
|
||||
func main() {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", handleIndex)
|
||||
mux.HandleFunc("/api/state", handleState)
|
||||
|
||||
addr := "127.0.0.1:18181"
|
||||
go openBrowser("http://" + addr)
|
||||
log.Printf("gui listening on http://%s", addr)
|
||||
log.Fatal(http.ListenAndServe(addr, mux))
|
||||
}
|
||||
|
||||
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
html := `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="refresh" content="3">
|
||||
<title>VRTW_Tool</title>
|
||||
<style>
|
||||
body{font-family:sans-serif;background:#111;color:#eee;margin:20px}
|
||||
.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||
.card{background:#1b1b1b;border:1px solid #333;border-radius:12px;padding:16px}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
td,th{border-bottom:1px solid #333;padding:6px;text-align:left}
|
||||
th{cursor:pointer;user-select:none}
|
||||
th:hover{color:#8ff}
|
||||
.on{color:#5ff28f}.off{color:#ff8a8a}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>VRTW_Tool</h1>
|
||||
<div id="root">loading...</div>
|
||||
<script>
|
||||
const sortKey = 'vrc_osc_gui_sort';
|
||||
const defaultSort = { key: 'name', dir: 1 };
|
||||
let sortState = (() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(sortKey);
|
||||
return raw ? JSON.parse(raw) : defaultSort;
|
||||
} catch (_) {
|
||||
return defaultSort;
|
||||
}
|
||||
})();
|
||||
|
||||
function setSort(key) {
|
||||
if (sortState.key === key) {
|
||||
sortState.dir = sortState.dir * -1;
|
||||
} else {
|
||||
sortState = { key: key, dir: 1 };
|
||||
}
|
||||
localStorage.setItem(sortKey, JSON.stringify(sortState));
|
||||
load();
|
||||
}
|
||||
|
||||
function compareGuests(a, b) {
|
||||
if (sortState.key === 'name') {
|
||||
const v = a.name.localeCompare(b.name, 'ja');
|
||||
return v * sortState.dir;
|
||||
}
|
||||
if (sortState.key === 'presence') {
|
||||
const av = Number(a.present);
|
||||
const bv = Number(b.present);
|
||||
if (av !== bv) {
|
||||
return (bv - av) * sortState.dir;
|
||||
}
|
||||
return a.name.localeCompare(b.name, 'ja');
|
||||
}
|
||||
if (sortState.key === 'status') {
|
||||
const av = Number(!a.present);
|
||||
const bv = Number(!b.present);
|
||||
if (av !== bv) {
|
||||
return (av - bv) * sortState.dir;
|
||||
}
|
||||
return a.name.localeCompare(b.name, 'ja');
|
||||
}
|
||||
return a.name.localeCompare(b.name, 'ja');
|
||||
}
|
||||
|
||||
async function load(){
|
||||
const r = await fetch('/api/state');
|
||||
const j = await r.json();
|
||||
const guests = [...j.guests].sort(compareGuests);
|
||||
const presentCount = guests.filter(g => g.present).length;
|
||||
const rows = guests.map(g => {
|
||||
const status = g.present ? '在席' : (g.absent_for || '1分未満');
|
||||
return '<tr><td>' + esc(g.name) + '</td><td>' + esc(status) + '</td></tr>';
|
||||
}).join('');
|
||||
document.getElementById('root').innerHTML =
|
||||
'<div class="grid">' +
|
||||
'<div class="card">' +
|
||||
'<h2>状態</h2>' +
|
||||
'<p>World: ' + esc(j.world || '') + '</p>' +
|
||||
'<p>Discord: <span class="' + (j.discord_muted ? 'on' : 'off') + '">' + (j.discord_muted ? 'Muted' : 'Unmuted') + '</span></p>' +
|
||||
'<p>OCR: ' + esc(j.ocr || '') + '</p>' +
|
||||
'<p>翻訳: ' + esc(j.translate || '未実装') + '</p>' +
|
||||
'</div>' +
|
||||
'<div class="card">' +
|
||||
'<h2>ゲスト (' + presentCount + '/' + j.guest_count + ')</h2>' +
|
||||
'<table>' +
|
||||
'<tr>' +
|
||||
'<th data-sort="name">名前' + sortMark('name') + '</th>' +
|
||||
'<th data-sort="presence">在席' + sortMark('presence') + '</th>' +
|
||||
'</tr>' +
|
||||
rows +
|
||||
'</table>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
document.querySelectorAll('th[data-sort]').forEach(function(el) {
|
||||
el.style.cursor = 'pointer';
|
||||
el.onclick = function() {
|
||||
setSort(el.getAttribute('data-sort'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function sortMark(key) {
|
||||
if (sortState.key !== key) return '';
|
||||
return sortState.dir > 0 ? ' ▲' : ' ▼';
|
||||
}
|
||||
|
||||
function esc(s){ return (s || '').replace(/[&<>"]/g, function(m){ return {'&':'&','<':'<','>':'>','"':'"'}[m]; }); }
|
||||
setInterval(load, 3000);
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
_, _ = fmt.Fprint(w, html)
|
||||
}
|
||||
|
||||
func handleState(w http.ResponseWriter, r *http.Request) {
|
||||
statePath := filepath.Join(runtimeDir(), "state.json")
|
||||
body, err := os.ReadFile(statePath)
|
||||
if err != nil {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"world": "",
|
||||
"discord_muted": false,
|
||||
"ocr": "",
|
||||
"translate": "",
|
||||
"guests": []any{},
|
||||
"guest_count": 0,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
var state map[string]any
|
||||
if err := json.Unmarshal(body, &state); err != nil {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
guests := loadGuestSnapshot()
|
||||
state["guests"] = guests
|
||||
state["guest_count"] = len(guests)
|
||||
_ = json.NewEncoder(w).Encode(state)
|
||||
}
|
||||
|
||||
func loadGuestSnapshot() []map[string]any {
|
||||
statePath := filepath.Join(runtimeDir(), "guest_snapshot.json")
|
||||
body, err := os.ReadFile(statePath)
|
||||
if err != nil {
|
||||
return []map[string]any{}
|
||||
}
|
||||
var guests []map[string]any
|
||||
if err := json.Unmarshal(body, &guests); err != nil {
|
||||
return []map[string]any{}
|
||||
}
|
||||
return guests
|
||||
}
|
||||
|
||||
func openBrowser(url string) {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
_ = exec.Command("cmd", "/c", "start", "", url).Start()
|
||||
default:
|
||||
_ = exec.Command("xdg-open", url).Start()
|
||||
}
|
||||
}
|
||||
40
VRWT_Tool/VRC_OSC/cmd/vrc_osc_launcher/main.go
Normal file
40
VRWT_Tool/VRC_OSC/cmd/vrc_osc_launcher/main.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"vrc_osc_go/internal/buildinfo"
|
||||
"vrc_osc_go/internal/common"
|
||||
"vrc_osc_go/internal/update"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var baseURL string
|
||||
var ownerRepo string
|
||||
var assetName string
|
||||
flag.StringVar(&baseURL, "base-url", "https://git.vrcworldtour.com", "Gitea base URL")
|
||||
flag.StringVar(&ownerRepo, "repo", "every_holiday/VRCWT-OSC", "owner/repo")
|
||||
flag.StringVar(&assetName, "asset", "vrc_osc-windows.zip", "release asset name")
|
||||
flag.Parse()
|
||||
|
||||
client := &update.Client{BaseURL: baseURL}
|
||||
mgr := &update.Manager{
|
||||
Client: client,
|
||||
OwnerRepo: ownerRepo,
|
||||
AssetName: assetName,
|
||||
CurrentLabel: buildinfo.Version,
|
||||
}
|
||||
if _, err := mgr.CheckAndUpdate(); err != nil {
|
||||
log.Printf("auto update skipped: %v", err)
|
||||
}
|
||||
exe := filepath.Join(common.RootDir(), "vrc_osc.exe")
|
||||
if _, err := os.Stat(exe); err != nil {
|
||||
log.Fatalf("missing exe: %v", err)
|
||||
}
|
||||
if err := update.Launch(exe, os.Args[1:]); err != nil {
|
||||
log.Fatalf("launch failed: %v", err)
|
||||
}
|
||||
}
|
||||
33
VRWT_Tool/VRC_OSC/cmd/vrwt_tool/main.go
Normal file
33
VRWT_Tool/VRC_OSC/cmd/vrwt_tool/main.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"vrc_osc_go/internal/consenttool"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var cfgPath string
|
||||
var mode string
|
||||
|
||||
flag.StringVar(&cfgPath, "config", "config/consent.toml", "path to consent tool config")
|
||||
flag.StringVar(&mode, "mode", "", "generate-json or extract-log")
|
||||
flag.Parse()
|
||||
|
||||
if mode == "" && flag.NArg() > 0 {
|
||||
mode = flag.Arg(0)
|
||||
}
|
||||
|
||||
if mode == "" {
|
||||
fmt.Fprintln(os.Stderr, "missing mode: generate-json or extract-log")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
if err := consenttool.Run(cfgPath, mode); err != nil {
|
||||
log.SetOutput(os.Stderr)
|
||||
log.Fatalf("vrwt_tool: %v", err)
|
||||
}
|
||||
}
|
||||
22
VRWT_Tool/VRC_OSC/config/config.example.toml
Normal file
22
VRWT_Tool/VRC_OSC/config/config.example.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[self]
|
||||
name = ""
|
||||
|
||||
[staff]
|
||||
names = []
|
||||
|
||||
[guest]
|
||||
file = "config/guests.txt"
|
||||
|
||||
[notice]
|
||||
missing_count = 0
|
||||
|
||||
[ocr.crop]
|
||||
left = 0.05
|
||||
top = 0.35
|
||||
right = 0.95
|
||||
bottom = 0.95
|
||||
|
||||
[ocr]
|
||||
preprocess = true
|
||||
scale = 1.5
|
||||
use_angle_cls = false
|
||||
3
VRWT_Tool/VRC_OSC/config/guests.example.txt
Normal file
3
VRWT_Tool/VRC_OSC/config/guests.example.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
GuestUserA
|
||||
GuestUserB
|
||||
GuestUserC
|
||||
6
VRWT_Tool/VRC_OSC/config/secrets.example.toml
Normal file
6
VRWT_Tool/VRC_OSC/config/secrets.example.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[discord]
|
||||
webhook_url = ""
|
||||
|
||||
[vrchat]
|
||||
username = ""
|
||||
password = ""
|
||||
4
VRWT_Tool/VRC_OSC/go.mod
Normal file
4
VRWT_Tool/VRC_OSC/go.mod
Normal file
@@ -0,0 +1,4 @@
|
||||
module vrc_osc_go
|
||||
|
||||
go 1.22
|
||||
|
||||
75
VRWT_Tool/VRC_OSC/internal/app/app.go
Normal file
75
VRWT_Tool/VRC_OSC/internal/app/app.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/config"
|
||||
"vrc_osc_go/internal/consenttool"
|
||||
"vrc_osc_go/internal/osc"
|
||||
)
|
||||
|
||||
type discordMuteState struct {
|
||||
mu sync.Mutex
|
||||
muted bool
|
||||
buttonPressed bool
|
||||
lastAction time.Time
|
||||
}
|
||||
|
||||
func Run(configPath string, mode string) error {
|
||||
if mode != "" {
|
||||
return consenttool.Run(configPath, mode)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
InitGuestTracker(NewGuestTracker(cfg.VrcLog.GuestNames))
|
||||
log.Printf("vrc_osc_go starting osc=%s:%d config=%s", cfg.OSC.Host, cfg.OSC.Port, configPath)
|
||||
|
||||
server := osc.NewServer(cfg.OSC.Host, cfg.OSC.Port)
|
||||
discordState := &discordMuteState{muted: true}
|
||||
SetDiscordMuted(true)
|
||||
server.Map("DiscordSend", func(_ string, args []osc.Value) error {
|
||||
log.Printf("received DiscordSend args=%+v", args)
|
||||
if err := handleDiscordSend(discordState, args); err != nil {
|
||||
log.Printf("discord mute failed: %v", err)
|
||||
return err
|
||||
}
|
||||
SetDiscordMuted(discordState.muted)
|
||||
return nil
|
||||
})
|
||||
server.Map("ocrEnabled", func(_ string, args []osc.Value) error {
|
||||
log.Printf("received ocrEnabled args=%+v", args)
|
||||
if err := runPythonOcrFromScreen(); err != nil {
|
||||
log.Printf("ocr failed: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
log.Printf("osc server listening on %s:%d", cfg.OSC.Host, cfg.OSC.Port)
|
||||
errCh <- server.Serve()
|
||||
}()
|
||||
go watchVrchatLog()
|
||||
startSelfMonitor(cfg, discordState)
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
select {
|
||||
case err := <-errCh:
|
||||
log.Printf("osc server stopped: %v", err)
|
||||
return err
|
||||
case <-sigCh:
|
||||
log.Printf("shutdown signal received")
|
||||
server.Close()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
84
VRWT_Tool/VRC_OSC/internal/app/discord.go
Normal file
84
VRWT_Tool/VRC_OSC/internal/app/discord.go
Normal 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
|
||||
}
|
||||
89
VRWT_Tool/VRC_OSC/internal/app/discord_mute.go
Normal file
89
VRWT_Tool/VRC_OSC/internal/app/discord_mute.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/osc"
|
||||
)
|
||||
|
||||
const discordDebounce = 300 * time.Millisecond
|
||||
|
||||
func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("OSC args is empty")
|
||||
}
|
||||
isPressed, err := toBool(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if !state.lastAction.IsZero() && now.Sub(state.lastAction) < discordDebounce {
|
||||
return nil
|
||||
}
|
||||
|
||||
if isPressed && !state.buttonPressed {
|
||||
state.buttonPressed = true
|
||||
if state.muted {
|
||||
log.Printf("ACTION VRChat button pressed -> Discord unmute hotkey")
|
||||
if err := pressDiscordMuteHotkey(); err != nil {
|
||||
return err
|
||||
}
|
||||
state.muted = false
|
||||
SetDiscordMuted(state.muted)
|
||||
state.lastAction = now
|
||||
} else {
|
||||
log.Printf("INFO already unmuted")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !isPressed && state.buttonPressed {
|
||||
state.buttonPressed = false
|
||||
if !state.muted {
|
||||
log.Printf("ACTION VRChat button released -> Discord mute hotkey")
|
||||
if err := pressDiscordMuteHotkey(); err != nil {
|
||||
return err
|
||||
}
|
||||
state.muted = true
|
||||
SetDiscordMuted(state.muted)
|
||||
state.lastAction = now
|
||||
} else {
|
||||
log.Printf("INFO already muted")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func toBool(v osc.Value) (bool, error) {
|
||||
switch v.Type {
|
||||
case 'T':
|
||||
return true, nil
|
||||
case 'F':
|
||||
return false, nil
|
||||
case 'i':
|
||||
return v.Int != 0, nil
|
||||
case 'f':
|
||||
return v.Float != 0, nil
|
||||
case 's':
|
||||
switch strings.ToLower(strings.TrimSpace(v.Str)) {
|
||||
case "true", "1", "on", "yes":
|
||||
return true, nil
|
||||
case "false", "0", "off", "no":
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("unsupported OSC value: %q", v.Str)
|
||||
}
|
||||
case 0:
|
||||
return false, fmt.Errorf("unsupported OSC value")
|
||||
default:
|
||||
return false, fmt.Errorf("unsupported OSC type: %q", v.Type)
|
||||
}
|
||||
}
|
||||
136
VRWT_Tool/VRC_OSC/internal/app/guest_status.go
Normal file
136
VRWT_Tool/VRC_OSC/internal/app/guest_status.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/common"
|
||||
)
|
||||
|
||||
type GuestStatus struct {
|
||||
Name string `json:"name"`
|
||||
Present bool `json:"present"`
|
||||
LastJoin time.Time `json:"last_join"`
|
||||
LastLeave time.Time `json:"last_leave"`
|
||||
AbsentFor string `json:"absent_for"`
|
||||
}
|
||||
|
||||
type GuestTracker struct {
|
||||
mu sync.RWMutex
|
||||
current string
|
||||
items map[string]*GuestStatus
|
||||
}
|
||||
|
||||
func NewGuestTracker(names []string) *GuestTracker {
|
||||
items := make(map[string]*GuestStatus, len(names))
|
||||
for _, name := range names {
|
||||
items[name] = &GuestStatus{Name: name}
|
||||
}
|
||||
return &GuestTracker{items: items}
|
||||
}
|
||||
|
||||
func (t *GuestTracker) SetCurrentInstance(label string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.current = label
|
||||
}
|
||||
|
||||
func (t *GuestTracker) CurrentInstance() string {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return t.current
|
||||
}
|
||||
|
||||
func (t *GuestTracker) MarkJoin(name string, at time.Time) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
s := t.ensure(name)
|
||||
s.Present = true
|
||||
s.LastJoin = at
|
||||
s.AbsentFor = ""
|
||||
_ = t.persistLocked()
|
||||
}
|
||||
|
||||
func (t *GuestTracker) MarkLeave(name string, at time.Time) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
s := t.ensure(name)
|
||||
s.Present = false
|
||||
s.LastLeave = at
|
||||
if !s.LastJoin.IsZero() {
|
||||
s.AbsentFor = humanSince(at)
|
||||
}
|
||||
_ = t.persistLocked()
|
||||
}
|
||||
|
||||
func (t *GuestTracker) Snapshot(now time.Time) []GuestStatus {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
out := make([]GuestStatus, 0, len(t.items))
|
||||
for _, s := range t.items {
|
||||
cp := *s
|
||||
if !cp.Present && !cp.LastLeave.IsZero() {
|
||||
cp.AbsentFor = humanSince(cp.LastLeave, now)
|
||||
}
|
||||
out = append(out, cp)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *GuestTracker) persistLocked() error {
|
||||
dir := filepath.Join(common.RootDir(), "runtime")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
snapshot := make([]GuestStatus, 0, len(t.items))
|
||||
for _, s := range t.items {
|
||||
cp := *s
|
||||
snapshot = append(snapshot, cp)
|
||||
}
|
||||
f, err := os.Create(filepath.Join(dir, "guest_snapshot.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
enc := json.NewEncoder(f)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(snapshot)
|
||||
}
|
||||
|
||||
func (t *GuestTracker) ensure(name string) *GuestStatus {
|
||||
if s, ok := t.items[name]; ok {
|
||||
return s
|
||||
}
|
||||
s := &GuestStatus{Name: name}
|
||||
t.items[name] = s
|
||||
return s
|
||||
}
|
||||
|
||||
func humanSince(at time.Time, now ...time.Time) string {
|
||||
ref := time.Now()
|
||||
if len(now) > 0 {
|
||||
ref = now[0]
|
||||
}
|
||||
if at.IsZero() {
|
||||
return ""
|
||||
}
|
||||
d := ref.Sub(at)
|
||||
if d < time.Minute {
|
||||
return "1分未満"
|
||||
}
|
||||
m := int(d.Minutes())
|
||||
if m < 60 {
|
||||
return fmt.Sprintf("%d分前", m)
|
||||
}
|
||||
h := m / 60
|
||||
if h < 24 {
|
||||
return fmt.Sprintf("%d時間前", h)
|
||||
}
|
||||
return fmt.Sprintf("%d日前", h/24)
|
||||
}
|
||||
30
VRWT_Tool/VRC_OSC/internal/app/ocr.go
Normal file
30
VRWT_Tool/VRC_OSC/internal/app/ocr.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func runPythonOcrFromScreen() error {
|
||||
script := `
|
||||
import sys
|
||||
from pathlib import Path
|
||||
root = Path(r"C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC")
|
||||
sys.path.insert(0, str(root / "src"))
|
||||
from ocr.ocr_actions import runOcrFromScreen
|
||||
runOcrFromScreen()
|
||||
`
|
||||
cmd := exec.Command("python", "-c", script)
|
||||
cmd.Dir = `C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC`
|
||||
out, err := cmd.CombinedOutput()
|
||||
if len(out) > 0 {
|
||||
log.Printf("ocr output: %s", string(out))
|
||||
SetOCRText(string(out))
|
||||
SetTranslateText("翻訳: 未実装")
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("ocr failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
48
VRWT_Tool/VRC_OSC/internal/app/runtime.go
Normal file
48
VRWT_Tool/VRC_OSC/internal/app/runtime.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/common"
|
||||
)
|
||||
|
||||
func appendRuntimeLog(title, text string) error {
|
||||
dir := filepath.Join(common.RootDir(), "runtime")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dir, "runtime.log")
|
||||
body := text
|
||||
if body == "" {
|
||||
body = "(empty)"
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = fmt.Fprintf(f, "\n[%s] %s\n%s\n", time.Now().Format("2006-01-02 15:04:05"), title, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func appendJoinLeaveLog(text string) error {
|
||||
dir := filepath.Join(common.RootDir(), "runtime")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dir, "join_leave.log")
|
||||
body := text
|
||||
if body == "" {
|
||||
body = "(empty)"
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = fmt.Fprintf(f, "\n[%s] VRC JOIN/LEAVE\n%s\n", time.Now().Format("2006-01-02 15:04:05"), body)
|
||||
return err
|
||||
}
|
||||
69
VRWT_Tool/VRC_OSC/internal/app/self_monitor.go
Normal file
69
VRWT_Tool/VRC_OSC/internal/app/self_monitor.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/config"
|
||||
"vrc_osc_go/internal/osc"
|
||||
)
|
||||
|
||||
var (
|
||||
selfJoinPattern = regexp.MustCompile(`OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
|
||||
selfLeftPattern = regexp.MustCompile(`OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
|
||||
)
|
||||
|
||||
func startSelfMonitor(cfg *config.Config, state *discordMuteState) {
|
||||
if cfg.VrcLog.SelfName == "" {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
lastState := ""
|
||||
for {
|
||||
path, err := findLatestVrchatLog()
|
||||
if err != nil {
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
text, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
nextState := extractLatestSelfLogState(string(text), cfg.VrcLog.SelfName)
|
||||
if nextState != "" && nextState != lastState {
|
||||
if nextState == "joined" {
|
||||
if err := handleDiscordSend(state, []osc.Value{{Type: 'T', Bool: true}}); err != nil {
|
||||
log.Printf("self monitor discord mute failed: %v", err)
|
||||
}
|
||||
} else if nextState == "left" {
|
||||
if err := handleDiscordSend(state, []osc.Value{{Type: 'F', Bool: false}}); err != nil {
|
||||
log.Printf("self monitor discord mute failed: %v", err)
|
||||
}
|
||||
}
|
||||
lastState = nextState
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func extractLatestSelfLogState(text, selfName string) string {
|
||||
if selfName == "" {
|
||||
return ""
|
||||
}
|
||||
state := ""
|
||||
for _, rawLine := range strings.Split(text, "\n") {
|
||||
if m := selfJoinPattern.FindStringSubmatch(rawLine); len(m) == 2 && strings.TrimSpace(m[1]) == selfName {
|
||||
state = "joined"
|
||||
continue
|
||||
}
|
||||
if m := selfLeftPattern.FindStringSubmatch(rawLine); len(m) == 2 && strings.TrimSpace(m[1]) == selfName {
|
||||
state = "left"
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
89
VRWT_Tool/VRC_OSC/internal/app/state.go
Normal file
89
VRWT_Tool/VRC_OSC/internal/app/state.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/common"
|
||||
)
|
||||
|
||||
type RuntimeState struct {
|
||||
mu sync.RWMutex
|
||||
DiscordMuted bool
|
||||
LastOCRText string
|
||||
LastTranslate string
|
||||
CurrentWorld string
|
||||
}
|
||||
|
||||
var runtimeState = &RuntimeState{}
|
||||
var guestTracker *GuestTracker
|
||||
|
||||
func SetGuestTracker(t *GuestTracker) { guestTracker = t }
|
||||
func InitGuestTracker(t *GuestTracker) {
|
||||
guestTracker = t
|
||||
if guestTracker != nil {
|
||||
guestTracker.mu.Lock()
|
||||
_ = guestTracker.persistLocked()
|
||||
guestTracker.mu.Unlock()
|
||||
}
|
||||
}
|
||||
func GetGuestTracker() *GuestTracker { return guestTracker }
|
||||
|
||||
func SetDiscordMuted(v bool) {
|
||||
runtimeState.mu.Lock()
|
||||
runtimeState.DiscordMuted = v
|
||||
runtimeState.mu.Unlock()
|
||||
_ = persistRuntimeState()
|
||||
}
|
||||
|
||||
func SetOCRText(v string) {
|
||||
runtimeState.mu.Lock()
|
||||
runtimeState.LastOCRText = v
|
||||
runtimeState.mu.Unlock()
|
||||
_ = persistRuntimeState()
|
||||
}
|
||||
|
||||
func SetTranslateText(v string) {
|
||||
runtimeState.mu.Lock()
|
||||
runtimeState.LastTranslate = v
|
||||
runtimeState.mu.Unlock()
|
||||
_ = persistRuntimeState()
|
||||
}
|
||||
|
||||
func SetCurrentWorld(v string) {
|
||||
runtimeState.mu.Lock()
|
||||
runtimeState.CurrentWorld = v
|
||||
runtimeState.mu.Unlock()
|
||||
_ = persistRuntimeState()
|
||||
}
|
||||
|
||||
func SnapshotRuntime() RuntimeState {
|
||||
runtimeState.mu.RLock()
|
||||
defer runtimeState.mu.RUnlock()
|
||||
return *runtimeState
|
||||
}
|
||||
|
||||
func persistRuntimeState() error {
|
||||
s := SnapshotRuntime()
|
||||
dir := filepath.Join(common.RootDir(), "runtime")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Create(filepath.Join(dir, "state.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
enc := json.NewEncoder(f)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(map[string]any{
|
||||
"discord_muted": s.DiscordMuted,
|
||||
"ocr": s.LastOCRText,
|
||||
"translate": s.LastTranslate,
|
||||
"world": s.CurrentWorld,
|
||||
"updated_at": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
345
VRWT_Tool/VRC_OSC/internal/app/vrc_log.go
Normal file
345
VRWT_Tool/VRC_OSC/internal/app/vrc_log.go
Normal file
@@ -0,0 +1,345 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
joinPattern = regexp.MustCompile(`OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
|
||||
leftPattern = regexp.MustCompile(`OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
|
||||
worldIdPattern = regexp.MustCompile(`worldId=(wrld_[0-9a-fA-F-]+)`)
|
||||
instanceIdPattern = regexp.MustCompile(`instanceId=([^,}\s]+)`)
|
||||
worldNamePattern = regexp.MustCompile(`worldName=([^,}]+)`)
|
||||
worldLocationPattern = regexp.MustCompile(`worldId=(wrld_[0-9a-fA-F-]+):([^\s,\]\)\"']+)`)
|
||||
worldPattern = regexp.MustCompile(`(wrld_[0-9a-fA-F-]+(?::[^\s\]\)\"']+)?)`)
|
||||
enteringRoomPattern = regexp.MustCompile(`\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$`)
|
||||
)
|
||||
|
||||
type vrcLogState struct {
|
||||
location string
|
||||
worldID string
|
||||
instanceID string
|
||||
worldName string
|
||||
pendingWorldName string
|
||||
presentSet map[string]struct{}
|
||||
initialized bool
|
||||
}
|
||||
|
||||
func getVrchatLogDir() (string, error) {
|
||||
userProfile := os.Getenv("USERPROFILE")
|
||||
if userProfile == "" {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
return filepath.Join(userProfile, "AppData", "LocalLow", "VRChat", "VRChat"), nil
|
||||
}
|
||||
|
||||
func findLatestVrchatLog() (string, error) {
|
||||
dir, err := getVrchatLogDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var latest string
|
||||
var latestMod time.Time
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".log") && !strings.HasSuffix(strings.ToLower(name), ".txt") {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.ModTime().After(latestMod) {
|
||||
latestMod = info.ModTime()
|
||||
latest = filepath.Join(dir, name)
|
||||
}
|
||||
}
|
||||
if latest == "" {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
return latest, nil
|
||||
}
|
||||
|
||||
func watchVrchatLog() {
|
||||
lastPath := ""
|
||||
lastSize := int64(0)
|
||||
state := &vrcLogState{
|
||||
presentSet: map[string]struct{}{},
|
||||
}
|
||||
for {
|
||||
path, err := findLatestVrchatLog()
|
||||
if err != nil {
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
if path != lastPath {
|
||||
log.Printf("vrchat log watching %s", path)
|
||||
lastPath = path
|
||||
lastSize = 0
|
||||
if err := scanExistingVrchatLog(path, state); err != nil {
|
||||
log.Printf("vrchat log initial scan failed: %v", err)
|
||||
}
|
||||
if info2, err := os.Stat(path); err == nil {
|
||||
lastSize = info2.Size()
|
||||
} else {
|
||||
lastSize = info.Size()
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
if info.Size() < lastSize {
|
||||
lastSize = 0
|
||||
}
|
||||
if info.Size() > lastSize {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
_, _ = f.Seek(lastSize, 0)
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if err := appendRuntimeLog("VRC LOG", line); err != nil {
|
||||
log.Printf("append runtime log failed: %v", err)
|
||||
}
|
||||
if changed, worldLabel := updateWorldState(state, line); changed {
|
||||
log.Printf("world changed: %s", worldLabel)
|
||||
state.presentSet = map[string]struct{}{}
|
||||
SetCurrentWorld(worldLabel)
|
||||
if tracker := GetGuestTracker(); tracker != nil {
|
||||
tracker.SetCurrentInstance(worldLabel)
|
||||
}
|
||||
_ = appendRuntimeLog("VRC WORLD", worldLabel)
|
||||
}
|
||||
at := extractLineTime(line)
|
||||
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||
name := strings.TrimSpace(m[1])
|
||||
state.presentSet[name] = struct{}{}
|
||||
if tracker := GetGuestTracker(); tracker != nil {
|
||||
tracker.MarkJoin(name, at)
|
||||
}
|
||||
out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet))
|
||||
log.Print(out)
|
||||
_ = appendJoinLeaveLog(out)
|
||||
continue
|
||||
}
|
||||
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||
name := strings.TrimSpace(m[1])
|
||||
delete(state.presentSet, name)
|
||||
if tracker := GetGuestTracker(); tracker != nil {
|
||||
tracker.MarkLeave(name, at)
|
||||
}
|
||||
out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet))
|
||||
log.Print(out)
|
||||
_ = appendJoinLeaveLog(out)
|
||||
continue
|
||||
}
|
||||
}
|
||||
lastSize = info.Size()
|
||||
_ = f.Close()
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
state.presentSet = map[string]struct{}{}
|
||||
state.location = ""
|
||||
state.worldID = ""
|
||||
state.instanceID = ""
|
||||
state.worldName = ""
|
||||
state.pendingWorldName = ""
|
||||
state.initialized = false
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if changed, _ := updateWorldState(state, line); changed {
|
||||
state.presentSet = map[string]struct{}{}
|
||||
}
|
||||
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||
name := strings.TrimSpace(m[1])
|
||||
state.presentSet[name] = struct{}{}
|
||||
if tracker := GetGuestTracker(); tracker != nil {
|
||||
tracker.MarkJoin(name, extractLineTime(line))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||
name := strings.TrimSpace(m[1])
|
||||
delete(state.presentSet, name)
|
||||
if tracker := GetGuestTracker(); tracker != nil {
|
||||
tracker.MarkLeave(name, extractLineTime(line))
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
state.initialized = true
|
||||
if state.worldName != "" {
|
||||
log.Printf("world changed: %s", state.worldName)
|
||||
SetCurrentWorld(state.worldName)
|
||||
if tracker := GetGuestTracker(); tracker != nil {
|
||||
tracker.SetCurrentInstance(state.worldName)
|
||||
}
|
||||
_ = appendRuntimeLog("VRC WORLD", state.worldName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateWorldState(state *vrcLogState, line string) (bool, string) {
|
||||
worldID := ""
|
||||
instanceID := ""
|
||||
worldName := ""
|
||||
roomTitle := ""
|
||||
|
||||
if m := enteringRoomPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||
roomTitle = strings.TrimSpace(m[1])
|
||||
state.pendingWorldName = roomTitle
|
||||
}
|
||||
if m := worldLocationPattern.FindStringSubmatch(line); len(m) == 3 {
|
||||
worldID = strings.TrimSpace(m[1])
|
||||
instanceID = strings.TrimSpace(m[2])
|
||||
}
|
||||
|
||||
if m := worldIdPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||
worldID = strings.TrimSpace(m[1])
|
||||
}
|
||||
if m := instanceIdPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||
instanceID = strings.TrimSpace(m[1])
|
||||
}
|
||||
if m := worldNamePattern.FindStringSubmatch(line); len(m) == 2 {
|
||||
worldName = strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
if m := enteringRoomPattern.FindStringSubmatch(line); len(m) == 2 && worldID == "" {
|
||||
if worldMatch := worldPattern.FindStringSubmatch(m[1]); len(worldMatch) == 2 {
|
||||
worldID = worldMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
nextLocation := ""
|
||||
if worldID != "" {
|
||||
nextLocation = worldID
|
||||
if instanceID != "" {
|
||||
nextLocation = worldID + ":" + instanceID
|
||||
}
|
||||
}
|
||||
|
||||
if nextLocation != "" && nextLocation != state.location {
|
||||
state.location = nextLocation
|
||||
state.worldID = worldID
|
||||
state.instanceID = instanceID
|
||||
if worldName != "" {
|
||||
state.worldName = worldName
|
||||
} else if shouldUseRoomTitlePreferLatest(roomTitle, state.worldName) {
|
||||
state.worldName = roomTitle
|
||||
}
|
||||
label := state.worldName
|
||||
if label == "" {
|
||||
label = state.worldID
|
||||
}
|
||||
if label == "" {
|
||||
label = nextLocation
|
||||
}
|
||||
if state.initialized {
|
||||
log.Printf("world changed: %s", label)
|
||||
_ = appendRuntimeLog("VRC WORLD", label)
|
||||
}
|
||||
return true, label
|
||||
}
|
||||
|
||||
if worldName != "" {
|
||||
state.worldName = worldName
|
||||
state.pendingWorldName = ""
|
||||
return false, ""
|
||||
}
|
||||
if shouldUseRoomTitlePreferLatest(roomTitle, state.worldName) {
|
||||
state.worldName = roomTitle
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func shouldUseRoomTitle(roomTitle, currentWorldName string) bool {
|
||||
if roomTitle == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(roomTitle, "Home Location") {
|
||||
return false
|
||||
}
|
||||
if roomTitle == "Holiday-Cottage" && currentWorldName != "" && currentWorldName != roomTitle {
|
||||
return false
|
||||
}
|
||||
if currentWorldName == "" {
|
||||
return true
|
||||
}
|
||||
if roomTitle == currentWorldName {
|
||||
return false
|
||||
}
|
||||
if len(roomTitle) > len(currentWorldName) {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(roomTitle, "「") || strings.Contains(roomTitle, "」") || strings.Contains(roomTitle, "[") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shouldUseRoomTitlePreferLatest(roomTitle, currentWorldName string) bool {
|
||||
if roomTitle == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(roomTitle, "Home Location") {
|
||||
return false
|
||||
}
|
||||
if currentWorldName == "" {
|
||||
return true
|
||||
}
|
||||
if roomTitle == currentWorldName {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func extractLineTime(line string) time.Time {
|
||||
if len(line) < 19 {
|
||||
return time.Now()
|
||||
}
|
||||
t, err := time.ParseInLocation("2006.01.02 15:04:05", line[:19], time.Local)
|
||||
if err == nil {
|
||||
return t
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
6
VRWT_Tool/VRC_OSC/internal/buildinfo/buildinfo.go
Normal file
6
VRWT_Tool/VRC_OSC/internal/buildinfo/buildinfo.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package buildinfo
|
||||
|
||||
var (
|
||||
Version = "dev"
|
||||
BuildTime = "unknown"
|
||||
)
|
||||
16
VRWT_Tool/VRC_OSC/internal/common/project_paths.go
Normal file
16
VRWT_Tool/VRC_OSC/internal/common/project_paths.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func RuntimeBaseDir() string {
|
||||
exe, err := os.Executable()
|
||||
if err == nil {
|
||||
return filepath.Dir(exe)
|
||||
}
|
||||
return "."
|
||||
}
|
||||
|
||||
func RootDir() string { return RuntimeBaseDir() }
|
||||
146
VRWT_Tool/VRC_OSC/internal/config/config.go
Normal file
146
VRWT_Tool/VRC_OSC/internal/config/config.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"vrc_osc_go/internal/common"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
OSC OSCConfig
|
||||
VrcLog VrcLogConfig
|
||||
}
|
||||
|
||||
type OSCConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
}
|
||||
|
||||
type VrcLogConfig struct {
|
||||
SelfName string
|
||||
GuestNames []string
|
||||
GuestFile string
|
||||
StaffNames []string
|
||||
MissingCount int
|
||||
LogPatterns []string
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
cfg := &Config{
|
||||
OSC: OSCConfig{Host: "127.0.0.1", Port: 9001},
|
||||
}
|
||||
if path == "" {
|
||||
path = filepath.Join(common.RootDir(), "config", "config.toml")
|
||||
} else if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(common.RootDir(), path)
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return cfg, nil
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var section string
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
line = strings.TrimPrefix(line, "\ufeff")
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
section = strings.Trim(line, "[]")
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.TrimSpace(parts[1])
|
||||
val = strings.Trim(val, "\"'")
|
||||
switch section {
|
||||
case "osc":
|
||||
switch key {
|
||||
case "host":
|
||||
cfg.OSC.Host = val
|
||||
case "port":
|
||||
if n, err := strconv.Atoi(val); err == nil {
|
||||
cfg.OSC.Port = n
|
||||
}
|
||||
}
|
||||
case "self":
|
||||
if key == "name" {
|
||||
cfg.VrcLog.SelfName = val
|
||||
}
|
||||
case "notice":
|
||||
if key == "missing_count" {
|
||||
if n, err := strconv.Atoi(val); err == nil {
|
||||
cfg.VrcLog.MissingCount = n
|
||||
}
|
||||
}
|
||||
case "vrc_log":
|
||||
if key == "patterns" {
|
||||
cfg.VrcLog.LogPatterns = parseList(val)
|
||||
}
|
||||
case "staff":
|
||||
if key == "names" {
|
||||
cfg.VrcLog.StaffNames = parseList(val)
|
||||
}
|
||||
case "guest":
|
||||
switch key {
|
||||
case "names":
|
||||
cfg.VrcLog.GuestNames = parseList(val)
|
||||
case "file":
|
||||
cfg.VrcLog.GuestFile = val
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.VrcLog.GuestFile != "" {
|
||||
if !filepath.IsAbs(cfg.VrcLog.GuestFile) {
|
||||
cfg.VrcLog.GuestFile = filepath.Join(common.RootDir(), cfg.VrcLog.GuestFile)
|
||||
}
|
||||
if names, err := loadLines(cfg.VrcLog.GuestFile); err == nil {
|
||||
cfg.VrcLog.GuestNames = names
|
||||
}
|
||||
}
|
||||
return cfg, scanner.Err()
|
||||
}
|
||||
|
||||
func parseList(value string) []string {
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.TrimPrefix(value, "[")
|
||||
value = strings.TrimSuffix(value, "]")
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(strings.Trim(part, "\"'"))
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loadLines(path string) ([]string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
out := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "\ufeff"))
|
||||
if line != "" && !strings.HasPrefix(line, "#") {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
144
VRWT_Tool/VRC_OSC/internal/consenttool/config.go
Normal file
144
VRWT_Tool/VRC_OSC/internal/consenttool/config.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package consenttool
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Consent ConsentConfig
|
||||
Log LogConfig
|
||||
}
|
||||
|
||||
type ConsentConfig struct {
|
||||
InputCSV string
|
||||
OutputJSON string
|
||||
DisplayFields []string
|
||||
ConsentFields []string
|
||||
ConsentValues []string
|
||||
NormalizeCase bool
|
||||
AllowDuplicates bool
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
InputPath string
|
||||
OutputPath string
|
||||
IncludeRegex string
|
||||
KeepNonMatch bool
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
cfg := &Config{
|
||||
Consent: ConsentConfig{
|
||||
InputCSV: "config/consent.csv",
|
||||
OutputJSON: "config/consent_list.json",
|
||||
DisplayFields: []string{"display_name", "vrchat_name", "name"},
|
||||
ConsentFields: []string{"consent", "agree", "agreed", "checked"},
|
||||
ConsentValues: []string{"1", "true", "yes", "y", "on", "checked"},
|
||||
NormalizeCase: false,
|
||||
AllowDuplicates: false,
|
||||
},
|
||||
Log: LogConfig{
|
||||
InputPath: "runtime/output_log.txt",
|
||||
OutputPath: "runtime/research_log.txt",
|
||||
IncludeRegex: `\[[A-Z0-9:_ -]+\]`,
|
||||
KeepNonMatch: false,
|
||||
},
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var section string
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
line = strings.TrimPrefix(line, "\ufeff")
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
section = strings.Trim(line, "[]")
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.TrimSpace(parts[1])
|
||||
val = strings.Trim(val, "\"'")
|
||||
|
||||
switch section {
|
||||
case "consent":
|
||||
switch key {
|
||||
case "input_csv":
|
||||
cfg.Consent.InputCSV = val
|
||||
case "output_json":
|
||||
cfg.Consent.OutputJSON = val
|
||||
case "display_fields":
|
||||
cfg.Consent.DisplayFields = parseList(val)
|
||||
case "consent_fields":
|
||||
cfg.Consent.ConsentFields = parseList(val)
|
||||
case "consent_values":
|
||||
cfg.Consent.ConsentValues = parseList(val)
|
||||
case "normalize_case":
|
||||
cfg.Consent.NormalizeCase = parseBool(val)
|
||||
case "allow_duplicates":
|
||||
cfg.Consent.AllowDuplicates = parseBool(val)
|
||||
}
|
||||
case "log":
|
||||
switch key {
|
||||
case "input_path":
|
||||
cfg.Log.InputPath = val
|
||||
case "output_path":
|
||||
cfg.Log.OutputPath = val
|
||||
case "include_regex":
|
||||
cfg.Log.IncludeRegex = val
|
||||
case "keep_non_match":
|
||||
cfg.Log.KeepNonMatch = parseBool(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cfg, scanner.Err()
|
||||
}
|
||||
|
||||
func parseList(value string) []string {
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.TrimPrefix(value, "[")
|
||||
value = strings.TrimSuffix(value, "]")
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
items := strings.Split(value, ",")
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
item = strings.TrimSpace(item)
|
||||
item = strings.Trim(item, "\"'")
|
||||
if item != "" {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseBool(value string) bool {
|
||||
n, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
return err == nil && n
|
||||
}
|
||||
|
||||
func resolvePath(baseDir, value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if filepath.IsAbs(value) {
|
||||
return value
|
||||
}
|
||||
return filepath.Clean(filepath.Join(baseDir, value))
|
||||
}
|
||||
183
VRWT_Tool/VRC_OSC/internal/consenttool/tool.go
Normal file
183
VRWT_Tool/VRC_OSC/internal/consenttool/tool.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package consenttool
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Run(configPath string, mode string) error {
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
baseDir := filepath.Dir(configPath)
|
||||
|
||||
switch mode {
|
||||
case "generate-json":
|
||||
return generateJSON(cfg, baseDir)
|
||||
case "extract-log":
|
||||
return extractLog(cfg, baseDir)
|
||||
default:
|
||||
return fmt.Errorf("unknown mode %q", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func generateJSON(cfg *Config, baseDir string) error {
|
||||
inputPath := resolvePath(baseDir, cfg.Consent.InputCSV)
|
||||
outputPath := resolvePath(baseDir, cfg.Consent.OutputJSON)
|
||||
|
||||
rows, err := readCSVRows(inputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
names := collectConsentedNames(rows, cfg.Consent)
|
||||
body, err := json.MarshalIndent(names, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(outputPath, append(body, '\n'), 0o644)
|
||||
}
|
||||
|
||||
func extractLog(cfg *Config, baseDir string) error {
|
||||
inputPath := resolvePath(baseDir, cfg.Log.InputPath)
|
||||
outputPath := resolvePath(baseDir, cfg.Log.OutputPath)
|
||||
|
||||
data, err := os.ReadFile(inputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
re, err := regexp.Compile(cfg.Log.IncludeRegex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var kept []string
|
||||
for _, line := range strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") {
|
||||
if re.MatchString(line) {
|
||||
kept = append(kept, line)
|
||||
continue
|
||||
}
|
||||
if cfg.Log.KeepNonMatch {
|
||||
kept = append(kept, line)
|
||||
}
|
||||
}
|
||||
|
||||
body := strings.Join(kept, "\n")
|
||||
if body != "" {
|
||||
body += "\n"
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(outputPath, []byte(body), 0o644)
|
||||
}
|
||||
|
||||
func readCSVRows(path string) ([]map[string]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
r := csv.NewReader(file)
|
||||
headers, err := r.Read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, header := range headers {
|
||||
headers[i] = strings.TrimPrefix(strings.TrimSpace(header), "\ufeff")
|
||||
}
|
||||
|
||||
var rows []map[string]string
|
||||
for {
|
||||
record, err := r.Read()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
row := make(map[string]string, len(headers))
|
||||
for i, header := range headers {
|
||||
if i < len(record) {
|
||||
row[strings.TrimSpace(header)] = strings.TrimPrefix(strings.TrimSpace(record[i]), "\ufeff")
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func collectConsentedNames(rows []map[string]string, cfg ConsentConfig) []string {
|
||||
displayFields := cfg.DisplayFields
|
||||
if len(displayFields) == 0 {
|
||||
displayFields = []string{"display_name", "vrchat_name", "name"}
|
||||
}
|
||||
consentFields := cfg.ConsentFields
|
||||
if len(consentFields) == 0 {
|
||||
consentFields = []string{"consent", "agree", "agreed", "checked"}
|
||||
}
|
||||
accepted := make(map[string]struct{})
|
||||
var names []string
|
||||
|
||||
for _, row := range rows {
|
||||
if !rowIsConsented(row, consentFields, cfg.ConsentValues) {
|
||||
continue
|
||||
}
|
||||
name := firstNonEmpty(row, displayFields)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
key := name
|
||||
if cfg.NormalizeCase {
|
||||
key = strings.ToLower(key)
|
||||
}
|
||||
if !cfg.AllowDuplicates {
|
||||
if _, ok := accepted[key]; ok {
|
||||
continue
|
||||
}
|
||||
accepted[key] = struct{}{}
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func rowIsConsented(row map[string]string, fields []string, values []string) bool {
|
||||
if len(values) == 0 {
|
||||
values = []string{"1", "true", "yes", "y", "on", "checked"}
|
||||
}
|
||||
allowed := make(map[string]struct{}, len(values))
|
||||
for _, v := range values {
|
||||
allowed[strings.ToLower(strings.TrimSpace(v))] = struct{}{}
|
||||
}
|
||||
for _, field := range fields {
|
||||
if v, ok := row[field]; ok {
|
||||
if _, ok := allowed[strings.ToLower(strings.TrimSpace(v))]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func firstNonEmpty(row map[string]string, fields []string) string {
|
||||
for _, field := range fields {
|
||||
if v := strings.TrimSpace(row[field]); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
44
VRWT_Tool/VRC_OSC/internal/consenttool/tool_test.go
Normal file
44
VRWT_Tool/VRC_OSC/internal/consenttool/tool_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package consenttool
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCollectConsentedNames(t *testing.T) {
|
||||
rows := []map[string]string{
|
||||
{"display_name": "Alice", "consent": "true"},
|
||||
{"display_name": "Bob", "consent": "false"},
|
||||
{"display_name": "Alice", "consent": "yes"},
|
||||
}
|
||||
|
||||
names := collectConsentedNames(rows, ConsentConfig{})
|
||||
if len(names) != 1 || names[0] != "Alice" {
|
||||
t.Fatalf("unexpected names: %#v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLog(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := &Config{
|
||||
Log: LogConfig{
|
||||
InputPath: "input.log",
|
||||
OutputPath: "out.log",
|
||||
IncludeRegex: `ID:[0-9]+`,
|
||||
},
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "input.log"), []byte("x\nID:1 hello\nnope\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
if err := extractLog(cfg, dir); err != nil {
|
||||
t.Fatalf("extractLog: %v", err)
|
||||
}
|
||||
out, err := os.ReadFile(filepath.Join(dir, "out.log"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if string(out) != "ID:1 hello\n" {
|
||||
t.Fatalf("unexpected output: %q", string(out))
|
||||
}
|
||||
}
|
||||
141
VRWT_Tool/VRC_OSC/internal/osc/server.go
Normal file
141
VRWT_Tool/VRC_OSC/internal/osc/server.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package osc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Value struct {
|
||||
Type byte
|
||||
Float float32
|
||||
Int int32
|
||||
Bool bool
|
||||
Str string
|
||||
}
|
||||
|
||||
type Handler func(address string, args []Value) error
|
||||
|
||||
type Server struct {
|
||||
addr string
|
||||
conn *net.UDPConn
|
||||
handlers map[string]Handler
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewServer(host string, port int) *Server {
|
||||
return &Server{
|
||||
addr: fmt.Sprintf("%s:%d", host, port),
|
||||
handlers: map[string]Handler{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Map(param string, h Handler) { s.handlers["/avatar/parameters/"+param] = h }
|
||||
func (s *Server) Close() error {
|
||||
if s.conn != nil {
|
||||
return s.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Serve() error {
|
||||
addr, err := net.ResolveUDPAddr("udp", s.addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn, err := net.ListenUDP("udp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.conn = conn
|
||||
buf := make([]byte, 2048)
|
||||
for {
|
||||
n, _, err := conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a, v, err := parseMessage(buf[:n])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s.mu.RLock()
|
||||
h := s.handlers[a]
|
||||
s.mu.RUnlock()
|
||||
if h != nil {
|
||||
_ = h(a, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseMessage(b []byte) (string, []Value, error) {
|
||||
address, offset, err := parsePaddedString(b, 0)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if !strings.HasPrefix(address, "/") {
|
||||
return "", nil, fmt.Errorf("invalid address")
|
||||
}
|
||||
if offset >= len(b) {
|
||||
return address, nil, nil
|
||||
}
|
||||
typetags, offset, err := parsePaddedString(b, offset)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if !strings.HasPrefix(typetags, ",") {
|
||||
return address, nil, nil
|
||||
}
|
||||
var values []Value
|
||||
for _, tag := range typetags[1:] {
|
||||
switch tag {
|
||||
case 'i':
|
||||
if offset+4 > len(b) {
|
||||
return "", nil, fmt.Errorf("invalid int")
|
||||
}
|
||||
values = append(values, Value{Type: 'i', Int: int32(binary.BigEndian.Uint32(b[offset : offset+4]))})
|
||||
offset += 4
|
||||
case 'f':
|
||||
if offset+4 > len(b) {
|
||||
return "", nil, fmt.Errorf("invalid float")
|
||||
}
|
||||
bits := binary.BigEndian.Uint32(b[offset : offset+4])
|
||||
values = append(values, Value{Type: 'f', Float: math.Float32frombits(bits)})
|
||||
offset += 4
|
||||
case 's':
|
||||
s, next, err := parsePaddedString(b, offset)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
values = append(values, Value{Type: 's', Str: s})
|
||||
offset = next
|
||||
case 'T':
|
||||
values = append(values, Value{Type: 'T', Bool: true})
|
||||
case 'F':
|
||||
values = append(values, Value{Type: 'F', Bool: false})
|
||||
}
|
||||
}
|
||||
return address, values, nil
|
||||
}
|
||||
|
||||
func parsePaddedString(b []byte, offset int) (string, int, error) {
|
||||
if offset >= len(b) {
|
||||
return "", offset, fmt.Errorf("invalid osc string")
|
||||
}
|
||||
end := bytes.IndexByte(b[offset:], 0)
|
||||
if end < 0 {
|
||||
return "", offset, fmt.Errorf("invalid osc string")
|
||||
}
|
||||
s := string(b[offset : offset+end])
|
||||
next := offset + end + 1
|
||||
for next%4 != 0 {
|
||||
next++
|
||||
}
|
||||
if next > len(b) {
|
||||
next = len(b)
|
||||
}
|
||||
return s, next, nil
|
||||
}
|
||||
106
VRWT_Tool/VRC_OSC/internal/update/manager.go
Normal file
106
VRWT_Tool/VRC_OSC/internal/update/manager.go
Normal 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
VRWT_Tool/VRC_OSC/internal/update/release.go
Normal file
125
VRWT_Tool/VRC_OSC/internal/update/release.go
Normal 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}
|
||||
}
|
||||
238
VRWT_Tool/VRC_OSC/readme.txt
Normal file
238
VRWT_Tool/VRC_OSC/readme.txt
Normal file
@@ -0,0 +1,238 @@
|
||||
# VRC_OSC
|
||||
|
||||
VRChat の OSC パラメータを受けて、Discord ミュート、VRChat ログ監視、OCR、翻訳を実行するローカルツール。
|
||||
|
||||
## 起動
|
||||
|
||||
依存関係を入れる。
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
PowerShell から起動する。
|
||||
|
||||
```powershell
|
||||
bin\vrc_osc.ps1
|
||||
```
|
||||
|
||||
Python から直接起動する場合。
|
||||
|
||||
```powershell
|
||||
python src\bin\vrc_osc.py
|
||||
```
|
||||
|
||||
OSC は `127.0.0.1:9001` で待ち受ける。
|
||||
|
||||
## 構成
|
||||
|
||||
```text
|
||||
bin/ PowerShell 起動スクリプト
|
||||
config/ example とローカル設定置き場
|
||||
src/app.py アプリ本体のエントリ
|
||||
src/bin/ Python 起動スクリプト
|
||||
src/common/ パス、設定ロード、runtime ログなどの共通基盤
|
||||
src/discord_control/ Discord ウィンドウ操作
|
||||
src/ocr/ OCR 実行
|
||||
src/osc/ OSC gateway
|
||||
src/screen/ VRChat ウィンドウ検出とキャプチャ
|
||||
src/tools/ 単体実行用の補助ツール
|
||||
src/translate/ 翻訳
|
||||
src/vrc_log/ VRChat ログ解析と名簿照合
|
||||
```
|
||||
|
||||
## 設定ファイル
|
||||
|
||||
実設定は `config/config.toml` に置く。このファイルは Git に入れない。
|
||||
|
||||
雛形は `config/config.example.toml`。
|
||||
|
||||
```toml
|
||||
[self]
|
||||
name = "自分のVRChat表示名"
|
||||
|
||||
[staff]
|
||||
names = [
|
||||
"StaffUserA"
|
||||
]
|
||||
|
||||
[guest]
|
||||
file = "config/guests.txt"
|
||||
|
||||
[notice]
|
||||
missing_count = 3
|
||||
|
||||
[ocr.crop]
|
||||
left = 0.05
|
||||
top = 0.35
|
||||
right = 0.95
|
||||
bottom = 0.95
|
||||
|
||||
[ocr]
|
||||
preprocess = true
|
||||
scale = 1.5
|
||||
use_angle_cls = false
|
||||
```
|
||||
|
||||
`self.name` は Discord 自動ミュート判定に使う。
|
||||
|
||||
`staff.names` は表示対象だが guest 人数には含めない。
|
||||
|
||||
`guest.file` は guest 名簿ファイル。guest 人数の母数と在室判定に使う。
|
||||
|
||||
`notice.missing_count` は「あと何人以内で全員集合か」の通知範囲。例えば guest が 23 人で `missing_count = 3` の場合、20〜22 人のときに未入室 guest を出す。
|
||||
|
||||
OCR 設定は速度と精度の調整用。
|
||||
|
||||
`ocr.crop` は VRChat ウィンドウ内の相対座標。`0.0` から `1.0` で指定する。デフォルトは画面下側の中央寄りだけを読む。
|
||||
|
||||
フル画面 OCR に戻す場合。
|
||||
|
||||
```toml
|
||||
[ocr.crop]
|
||||
left = 0
|
||||
top = 0
|
||||
right = 1
|
||||
bottom = 1
|
||||
```
|
||||
|
||||
`ocr.preprocess = true` はグレースケール化、コントラスト補正、シャープ化、拡大を行う。
|
||||
|
||||
`ocr.scale` は前処理時の拡大率。大きいほど小さい文字に強くなるが遅くなる。
|
||||
|
||||
`ocr.use_angle_cls = false` は角度分類を切って高速化する。縦書きや回転文字を読む必要がある場合だけ `true` にする。
|
||||
|
||||
## guest 名簿
|
||||
|
||||
`config/guests.txt` に 1 行 1 名で書く。このファイルは Git に入れない。
|
||||
|
||||
雛形は `config/guests.example.txt`。
|
||||
|
||||
```text
|
||||
GuestUserA
|
||||
GuestUserB
|
||||
GuestUserC
|
||||
```
|
||||
|
||||
空行と `#` で始まる行は無視する。
|
||||
|
||||
## 秘密情報
|
||||
|
||||
秘密情報は `config/secrets.toml` に置く。このファイルは Git に入れない。
|
||||
|
||||
雛形は `config/secrets.example.toml`。
|
||||
|
||||
```toml
|
||||
[discord]
|
||||
webhook_url = ""
|
||||
|
||||
[vrchat]
|
||||
username = ""
|
||||
password = ""
|
||||
```
|
||||
|
||||
`discord.webhook_url` は VRC ログ結果の Discord Webhook 通知に使う。
|
||||
|
||||
`vrchat.username` / `vrchat.password` は `src/tools/resolve_vrchat_user.py` を使う場合だけ必要。
|
||||
|
||||
## OSC パラメータ
|
||||
|
||||
Avatar Parameters 側の名前と一致させる。
|
||||
|
||||
| Parameter | 動作 |
|
||||
| --- | --- |
|
||||
| `DiscordSend` | 押した瞬間に Discord の mute hotkey を送る |
|
||||
| `vrc_log` | VRC ログ監視の on/off |
|
||||
| `ocrEnabled` | VRChat 画面を撮って OCR |
|
||||
| `translation` | OCR して日本語翻訳 |
|
||||
|
||||
## VRC ログ監視
|
||||
|
||||
`vrc_log` を押すと、現在の VRChat ログを一度解析して Discord Webhook に送る。その後はログファイル末尾を追い、新しい join / leave / world 系ログが追記されたタイミングで即再解析する。
|
||||
|
||||
以前のような `output_log = 10` の秒数ポーリング設定は使わない。
|
||||
|
||||
解析対象は昼 12:00 以降。実行時刻が昼 12:00 前なら前日 12:00 以降を見る。
|
||||
|
||||
インスタンスが変わったら在室状態はリセットする。
|
||||
|
||||
出力例。
|
||||
|
||||
```text
|
||||
ワールド入室: Midnight Rooftop
|
||||
現在Guest: 0/3
|
||||
[01:20] [join] GuestUserA 1/3
|
||||
[01:21] [join] GuestUserB 2/3
|
||||
[01:25] [leave] GuestUserA 1/3
|
||||
[01:26] [join] [staff] StaffUserA 1/3
|
||||
```
|
||||
|
||||
notice 条件に入った場合。
|
||||
|
||||
```text
|
||||
現在Guest: 2/3
|
||||
未入室Guest:
|
||||
- GuestUserC
|
||||
```
|
||||
|
||||
## Discord 自動ミュート
|
||||
|
||||
常時 self monitor が VRChat ログの新規追記だけを見る。
|
||||
|
||||
新しく追記されたログの中で、自分の最後のイベントが `OnPlayerJoined` なら Discord を mute、`OnPlayerLeft` なら unmute する。
|
||||
|
||||
過去ログ全体の最後が self leave / join だっただけでは反応しない。
|
||||
|
||||
`setDiscordMuted()` にはロックがあり、同時実行で mute hotkey が連続送信されないようにしている。
|
||||
|
||||
## OCR / 翻訳
|
||||
|
||||
`ocrEnabled` は VRChat ウィンドウを撮影して PaddleOCR にかける。結果はクリップボードへコピーする。
|
||||
|
||||
`translation` は OCR 結果を翻訳し、翻訳結果をクリップボードへコピーする。
|
||||
|
||||
OCR はデフォルトで画面全体ではなく、画面下側の中央寄りを切り出して読む。会話ログや字幕を読む用途では速く、余計な UI 文字を拾いにくい。
|
||||
|
||||
## runtime
|
||||
|
||||
新規の実行ログは基本的に以下へまとめる。
|
||||
|
||||
```text
|
||||
runtime/runtime.log
|
||||
runtime/latest_ocr.png
|
||||
runtime/latest_screenshot.png
|
||||
```
|
||||
|
||||
`runtime/` は Git に入れない。
|
||||
|
||||
OCR text、翻訳 text、VRC ログ結果は `runtime/runtime.log` に追記する。
|
||||
|
||||
画像は最新ファイルに上書きする。日時別ファイルを増やさない。
|
||||
|
||||
## ユーザー名照合
|
||||
|
||||
`src/vrc_log/member_name_matcher.py` は TOML 登録名と VRChat 表示名を安全な順番で紐づける補助実装。
|
||||
|
||||
照合順。
|
||||
|
||||
1. 完全一致
|
||||
2. 正規化一致
|
||||
3. 高信頼 fuzzy
|
||||
4. guest のみ低信頼 fuzzy
|
||||
5. pending / ambiguous / unmatched
|
||||
|
||||
完全一致・正規化一致で確定した名前は fuzzy 候補から除外する。
|
||||
|
||||
## Git に入れないもの
|
||||
|
||||
以下はローカル専用。
|
||||
|
||||
- `config/config.toml`
|
||||
- `config/secrets.toml`
|
||||
- `config/guests.txt`
|
||||
- `runtime/`
|
||||
- `__pycache__/`
|
||||
- `*.pyc`
|
||||
- `.vrchat_cookie.txt`
|
||||
|
||||
秘密情報を含む可能性があるものはコミットしない。
|
||||
10
VRWT_Tool/VRC_OSC/requirements.txt
Normal file
10
VRWT_Tool/VRC_OSC/requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
deep-translator
|
||||
paddleocr
|
||||
pillow
|
||||
pyautogui
|
||||
pygetwindow
|
||||
pykakasi
|
||||
pyperclip
|
||||
python-osc
|
||||
rapidfuzz
|
||||
vrchatapi
|
||||
114
VRWT_Tool/VRC_OSC/src/app.py
Normal file
114
VRWT_Tool/VRC_OSC/src/app.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
os.environ["FLAGS_enable_pir_api"] = "0"
|
||||
os.environ["FLAGS_use_onednn"] = "0"
|
||||
os.environ["FLAGS_use_mkldnn"] = "0"
|
||||
os.environ["FLAGS_use_onednn_bfloat16"] = "0"
|
||||
os.environ["ONEDNN_VERBOSE"] = "0"
|
||||
|
||||
SRC_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(SRC_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_DIR))
|
||||
|
||||
from osc.gateway import VrcOscGateway
|
||||
from discord_control.actions import setDiscordMute
|
||||
from ocr.ocr_actions import runOcrFromScreen
|
||||
from translate.translate_actions import saveTranslationText
|
||||
from translate.translate_actions import translateTextToJapanese
|
||||
from vrc_log.log_actions import collectVrchatLog
|
||||
from vrc_log.log_actions import startSelfMonitor
|
||||
from vrc_log.log_actions import startVrcLogMonitor
|
||||
from vrc_log.log_actions import stopSelfMonitor
|
||||
from vrc_log.log_actions import stopVrcLogMonitor
|
||||
|
||||
PARAM_DISCORD_MUTE = "DiscordSend"
|
||||
PARAM_LOG_GREP = "vrc_log"
|
||||
PARAM_OCR = "ocrEnabled"
|
||||
PARAM_TRANSLATE_OCR = "translation"
|
||||
|
||||
|
||||
def create_gateway():
|
||||
gateway = VrcOscGateway(host="127.0.0.1", port=9001)
|
||||
|
||||
def on_discord_mute(address, *args):
|
||||
if not args:
|
||||
gateway.log("ERROR", "DiscordSend args empty")
|
||||
return
|
||||
|
||||
if gateway.is_rising_edge(address, args[0]):
|
||||
gateway.log("INFO", f"received {address} args={args}")
|
||||
setDiscordMute()
|
||||
|
||||
vrc_log_enabled = False
|
||||
def on_log_grep(address, *args):
|
||||
nonlocal vrc_log_enabled
|
||||
|
||||
if not args:
|
||||
gateway.log("ERROR", "vrc_log args empty")
|
||||
return
|
||||
|
||||
if gateway.is_rising_edge(address, args[0]):
|
||||
vrc_log_enabled = not vrc_log_enabled
|
||||
gateway.log("INFO", f"received {address} args={args} enabled={vrc_log_enabled}")
|
||||
|
||||
if vrc_log_enabled:
|
||||
collectVrchatLog(notify_changed=True)
|
||||
startVrcLogMonitor()
|
||||
else:
|
||||
stopVrcLogMonitor()
|
||||
|
||||
def on_ocr(address, *args):
|
||||
if not args:
|
||||
gateway.log("ERROR", "ocrEnabled args empty")
|
||||
return
|
||||
|
||||
if gateway.is_rising_edge(address, args[0]):
|
||||
gateway.log("INFO", f"received {address} args={args}")
|
||||
runOcrFromScreen()
|
||||
|
||||
def on_translate_ocr(address, *args):
|
||||
if not args:
|
||||
gateway.log("ERROR", "translateOcr args empty")
|
||||
return
|
||||
|
||||
if gateway.is_rising_edge(address, args[0]):
|
||||
gateway.log("INFO", f"received {address} args={args}")
|
||||
|
||||
result = runOcrFromScreen()
|
||||
if not result:
|
||||
gateway.log("ERROR", "OCR result is None")
|
||||
return
|
||||
|
||||
text = result.get("text", "")
|
||||
if not text.strip():
|
||||
gateway.log("INFO", "OCR text is empty. translation skipped")
|
||||
return
|
||||
|
||||
translated = translateTextToJapanese(text)
|
||||
if not translated.strip():
|
||||
gateway.log("INFO", "translation result is empty")
|
||||
return
|
||||
|
||||
image_path = result.get("image_path")
|
||||
stem = image_path.stem if image_path else "translation"
|
||||
saved = saveTranslationText(stem, translated)
|
||||
gateway.log("INFO", f"translation saved={saved}")
|
||||
|
||||
gateway.map(PARAM_DISCORD_MUTE, on_discord_mute)
|
||||
gateway.map(PARAM_LOG_GREP, on_log_grep)
|
||||
gateway.map(PARAM_OCR, on_ocr)
|
||||
gateway.map(PARAM_TRANSLATE_OCR, on_translate_ocr)
|
||||
gateway.set_default_debug(enabled=False)
|
||||
return gateway
|
||||
|
||||
|
||||
def main():
|
||||
gateway = create_gateway()
|
||||
startSelfMonitor()
|
||||
gateway.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
13
VRWT_Tool/VRC_OSC/src/bin/vrc_osc.py
Normal file
13
VRWT_Tool/VRC_OSC/src/bin/vrc_osc.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
SRC_DIR = Path(__file__).resolve().parents[1]
|
||||
|
||||
if str(SRC_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_DIR))
|
||||
|
||||
from app import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
130
VRWT_Tool/VRC_OSC/src/common/config_loader.py
Normal file
130
VRWT_Tool/VRC_OSC/src/common/config_loader.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
|
||||
from common.project_paths import ROOT_DIR
|
||||
|
||||
CONFIG_DIR = ROOT_DIR / "config"
|
||||
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
||||
SECRETS_FILE = CONFIG_DIR / "secrets.toml"
|
||||
DEFAULT_EVENT_FILE = CONFIG_DIR / "event.toml"
|
||||
DEFAULT_GUEST_FILE = CONFIG_DIR / "guests.txt"
|
||||
|
||||
|
||||
def loadTomlFile(path):
|
||||
if not path.exists():
|
||||
return {}
|
||||
|
||||
return tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def resolveConfigPath(value, default_path):
|
||||
if not value:
|
||||
return default_path
|
||||
|
||||
candidate = Path(value)
|
||||
if candidate.is_absolute():
|
||||
return candidate
|
||||
|
||||
return (ROOT_DIR / candidate).resolve()
|
||||
|
||||
|
||||
def loadNameListFile(path):
|
||||
if not path.exists():
|
||||
return []
|
||||
|
||||
names = []
|
||||
|
||||
for line in path.read_text(encoding="utf-8-sig", errors="replace").splitlines():
|
||||
name = line.strip()
|
||||
if not name or name.startswith("#"):
|
||||
continue
|
||||
names.append(name)
|
||||
|
||||
return names
|
||||
|
||||
|
||||
def loadSecretConfig():
|
||||
return loadTomlFile(SECRETS_FILE)
|
||||
|
||||
|
||||
def getSecretValue(section_name, key, default=""):
|
||||
secrets = loadSecretConfig()
|
||||
section = secrets.get(section_name, {})
|
||||
return str(section.get(key, default)).strip()
|
||||
|
||||
|
||||
def loadOcrConfig():
|
||||
config = loadTomlFile(CONFIG_FILE)
|
||||
ocr = config.get("ocr", {})
|
||||
crop = ocr.get("crop", {})
|
||||
|
||||
return {
|
||||
"crop_left": float(crop.get("left", 0.05)),
|
||||
"crop_top": float(crop.get("top", 0.35)),
|
||||
"crop_right": float(crop.get("right", 0.95)),
|
||||
"crop_bottom": float(crop.get("bottom", 0.95)),
|
||||
"preprocess": bool(ocr.get("preprocess", True)),
|
||||
"scale": float(ocr.get("scale", 1.5)),
|
||||
"use_angle_cls": bool(ocr.get("use_angle_cls", False)),
|
||||
}
|
||||
|
||||
|
||||
def loadVrcLogConfig():
|
||||
config = loadTomlFile(CONFIG_FILE)
|
||||
vrc_log = config.get("vrc_log", {})
|
||||
self_section = config.get("self", {})
|
||||
event_file_value = (
|
||||
vrc_log.get("event_toml")
|
||||
or vrc_log.get("event_file")
|
||||
or config.get("event_toml")
|
||||
or config.get("event_file")
|
||||
)
|
||||
|
||||
event_file = resolveConfigPath(event_file_value, DEFAULT_EVENT_FILE)
|
||||
event_config = loadTomlFile(event_file)
|
||||
|
||||
if not event_config:
|
||||
event_config = vrc_log or config
|
||||
|
||||
guest_section = event_config.get("guest", {})
|
||||
guest_names = guest_section.get("names", [])
|
||||
guest_file_value = (
|
||||
guest_section.get("file")
|
||||
or event_config.get("guest_file")
|
||||
or config.get("guest_file")
|
||||
)
|
||||
guest_file = resolveConfigPath(guest_file_value, DEFAULT_GUEST_FILE)
|
||||
|
||||
if not guest_names:
|
||||
guest_names = loadNameListFile(guest_file)
|
||||
|
||||
staff_names = event_config.get("staff", {}).get("names", [])
|
||||
|
||||
if not guest_names and isinstance(event_config.get("guest_names"), list):
|
||||
guest_names = event_config.get("guest_names", [])
|
||||
if not staff_names and isinstance(event_config.get("staff_names"), list):
|
||||
staff_names = event_config.get("staff_names", [])
|
||||
|
||||
notice_section = event_config.get("notice", {})
|
||||
missing_count = notice_section.get("missing_count", 0)
|
||||
|
||||
if not missing_count:
|
||||
missing_count = event_config.get("missing_count", 0)
|
||||
|
||||
self_name = (
|
||||
self_section.get("name")
|
||||
or config.get("self_name")
|
||||
or vrc_log.get("self_name")
|
||||
or event_config.get("self", {}).get("name")
|
||||
or ""
|
||||
)
|
||||
|
||||
return {
|
||||
"config_file": CONFIG_FILE,
|
||||
"event_file": event_file,
|
||||
"guest_file": guest_file,
|
||||
"guest_names": [str(name).strip() for name in guest_names if str(name).strip()],
|
||||
"staff_names": [str(name).strip() for name in staff_names if str(name).strip()],
|
||||
"missing_count": int(missing_count),
|
||||
"self_name": str(self_name).strip(),
|
||||
}
|
||||
6
VRWT_Tool/VRC_OSC/src/common/project_paths.py
Normal file
6
VRWT_Tool/VRC_OSC/src/common/project_paths.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from pathlib import Path
|
||||
|
||||
SRC_DIR = Path(__file__).resolve().parents[1]
|
||||
ROOT_DIR = SRC_DIR.parent
|
||||
RUNTIME_DIR = ROOT_DIR / "runtime"
|
||||
RUNTIME_LOG_FILE = RUNTIME_DIR / "runtime.log"
|
||||
19
VRWT_Tool/VRC_OSC/src/common/runtime_log.py
Normal file
19
VRWT_Tool/VRC_OSC/src/common/runtime_log.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from datetime import datetime
|
||||
|
||||
from common.project_paths import RUNTIME_LOG_FILE
|
||||
|
||||
|
||||
def appendRuntimeLog(title, text):
|
||||
RUNTIME_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
body = str(text).strip()
|
||||
if not body:
|
||||
body = "(empty)"
|
||||
|
||||
with RUNTIME_LOG_FILE.open("a", encoding="utf-8") as file:
|
||||
file.write(f"\n[{now}] {title}\n")
|
||||
file.write(body)
|
||||
file.write("\n")
|
||||
|
||||
return RUNTIME_LOG_FILE
|
||||
203
VRWT_Tool/VRC_OSC/src/discord_control/actions.py
Normal file
203
VRWT_Tool/VRC_OSC/src/discord_control/actions.py
Normal file
@@ -0,0 +1,203 @@
|
||||
from datetime import datetime
|
||||
import ctypes
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pyautogui
|
||||
import pygetwindow as gw
|
||||
|
||||
BROWSER_KEYWORDS = [
|
||||
"google chrome",
|
||||
"microsoft edge",
|
||||
"mozilla firefox",
|
||||
]
|
||||
|
||||
_discord_muted = True
|
||||
_discord_mute_lock = threading.Lock()
|
||||
|
||||
|
||||
def log(level, message):
|
||||
now = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[{level}] {now} {message}", flush=True)
|
||||
|
||||
|
||||
def findDiscordWindow():
|
||||
candidates = []
|
||||
|
||||
for window in gw.getAllWindows():
|
||||
title = (window.title or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
if "discord" not in title.lower():
|
||||
continue
|
||||
candidates.append(window)
|
||||
|
||||
if not candidates:
|
||||
raise RuntimeError("Discordウィンドウが見つかりません")
|
||||
|
||||
candidates.sort(key=lambda window: -(window.width * window.height))
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def findBrowserWindow():
|
||||
candidates = []
|
||||
|
||||
for window in gw.getAllWindows():
|
||||
title = (window.title or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
lowered = title.lower()
|
||||
if not any(keyword in lowered for keyword in BROWSER_KEYWORDS):
|
||||
continue
|
||||
candidates.append(window)
|
||||
|
||||
if not candidates:
|
||||
raise RuntimeError("ブラウザウィンドウが見つかりません")
|
||||
|
||||
candidates.sort(key=lambda window: -(window.width * window.height))
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def findDiscordBrowserWindow():
|
||||
candidates = []
|
||||
|
||||
for window in gw.getAllWindows():
|
||||
title = (window.title or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
lowered = title.lower()
|
||||
if "discord" not in lowered:
|
||||
continue
|
||||
if not any(keyword in lowered for keyword in BROWSER_KEYWORDS):
|
||||
continue
|
||||
candidates.append(window)
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
candidates.sort(key=lambda window: -(window.width * window.height))
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def activateDiscordWindow():
|
||||
window = findDiscordWindow()
|
||||
hwnd = getattr(window, "_hWnd", None)
|
||||
|
||||
try:
|
||||
if window.isMinimized:
|
||||
window.restore()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if hwnd:
|
||||
try:
|
||||
ctypes.windll.user32.ShowWindow(hwnd, 9)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
window.activate()
|
||||
except Exception as e:
|
||||
log("ERROR", f"Discord activate() failed detail={e}")
|
||||
|
||||
if hwnd:
|
||||
try:
|
||||
ctypes.windll.user32.BringWindowToTop(hwnd)
|
||||
ctypes.windll.user32.SetForegroundWindow(hwnd)
|
||||
except Exception as e:
|
||||
log("ERROR", f"Discord SetForegroundWindow failed detail={e}")
|
||||
|
||||
time.sleep(0.15)
|
||||
|
||||
log("INFO", f"Discord window title={window.title}")
|
||||
return window
|
||||
|
||||
|
||||
def findVrchatWindow():
|
||||
candidates = []
|
||||
|
||||
for window in gw.getAllWindows():
|
||||
title = (window.title or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
lowered = title.lower()
|
||||
if lowered == "vrchat":
|
||||
candidates.append((0, window))
|
||||
continue
|
||||
if lowered.startswith("vrchat "):
|
||||
candidates.append((1, window))
|
||||
continue
|
||||
if lowered.startswith("vrchat"):
|
||||
candidates.append((2, window))
|
||||
|
||||
if not candidates:
|
||||
raise RuntimeError("VRChatウィンドウが見つかりません")
|
||||
|
||||
candidates.sort(key=lambda item: (item[0], -(item[1].width * item[1].height)))
|
||||
return candidates[0][1]
|
||||
|
||||
|
||||
def activateWindow(window, label):
|
||||
if not window:
|
||||
return
|
||||
|
||||
hwnd = getattr(window, "_hWnd", None)
|
||||
|
||||
try:
|
||||
if window.isMinimized:
|
||||
window.restore()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if hwnd:
|
||||
try:
|
||||
ctypes.windll.user32.ShowWindow(hwnd, 9)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
window.activate()
|
||||
except Exception as e:
|
||||
log("ERROR", f"{label} activate() failed detail={e}")
|
||||
|
||||
if hwnd:
|
||||
try:
|
||||
ctypes.windll.user32.BringWindowToTop(hwnd)
|
||||
ctypes.windll.user32.SetForegroundWindow(hwnd)
|
||||
except Exception as e:
|
||||
log("ERROR", f"{label} SetForegroundWindow failed detail={e}")
|
||||
|
||||
time.sleep(0.15)
|
||||
try:
|
||||
pyautogui.click(window.left + window.width // 2, window.top + window.height // 2)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.1)
|
||||
log("INFO", f"{label} window title={window.title}")
|
||||
|
||||
|
||||
def setDiscordMute():
|
||||
log("ACTION", "setDiscordMute called")
|
||||
try:
|
||||
activateDiscordWindow()
|
||||
except Exception as e:
|
||||
log("ERROR", f"Discord window unavailable detail={e}")
|
||||
browser = findDiscordBrowserWindow() or findBrowserWindow()
|
||||
activateWindow(browser, "Browser")
|
||||
log("INFO", "Discord hotkey Ctrl+Shift+M")
|
||||
pyautogui.hotkey("ctrl", "shift", "m")
|
||||
activateWindow(findVrchatWindow(), "VRChat")
|
||||
|
||||
|
||||
def setDiscordMuted(muted):
|
||||
global _discord_muted
|
||||
|
||||
muted = bool(muted)
|
||||
|
||||
with _discord_mute_lock:
|
||||
if _discord_muted == muted:
|
||||
log("INFO", f"Discord mute already {'on' if muted else 'off'}")
|
||||
return
|
||||
|
||||
setDiscordMute()
|
||||
_discord_muted = muted
|
||||
186
VRWT_Tool/VRC_OSC/src/ocr/ocr_actions.py
Normal file
186
VRWT_Tool/VRC_OSC/src/ocr/ocr_actions.py
Normal file
@@ -0,0 +1,186 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
os.environ["FLAGS_enable_pir_api"] = "0"
|
||||
os.environ["FLAGS_use_onednn"] = "0"
|
||||
os.environ["FLAGS_use_mkldnn"] = "0"
|
||||
os.environ["FLAGS_use_onednn_bfloat16"] = "0"
|
||||
os.environ["MKLDNN_DISABLE_WORKSPACE"] = "1"
|
||||
os.environ["ONEDNN_VERBOSE"] = "0"
|
||||
|
||||
import pyperclip
|
||||
from paddleocr import PaddleOCR
|
||||
from PIL import Image
|
||||
from PIL import ImageFilter
|
||||
from PIL import ImageOps
|
||||
|
||||
from common.config_loader import loadOcrConfig
|
||||
from common.runtime_log import appendRuntimeLog
|
||||
from screen.screen_actions import captureOcrRegion
|
||||
|
||||
_ocr_engine = None
|
||||
|
||||
|
||||
def log(level, message):
|
||||
now = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[{level}] {now} {message}", flush=True)
|
||||
|
||||
|
||||
def getOcrEngine():
|
||||
global _ocr_engine
|
||||
|
||||
if _ocr_engine is None:
|
||||
log("INFO", "PaddleOCR initializing")
|
||||
config = loadOcrConfig()
|
||||
options = {
|
||||
"lang": "japan",
|
||||
"use_angle_cls": config["use_angle_cls"],
|
||||
"show_log": False,
|
||||
}
|
||||
|
||||
try:
|
||||
_ocr_engine = PaddleOCR(**options)
|
||||
except (TypeError, ValueError):
|
||||
options.pop("show_log", None)
|
||||
_ocr_engine = PaddleOCR(**options)
|
||||
|
||||
return _ocr_engine
|
||||
|
||||
|
||||
def saveOcrText(image_path, text):
|
||||
return appendRuntimeLog(
|
||||
"OCR TEXT",
|
||||
f"image={image_path}\n{text}",
|
||||
)
|
||||
|
||||
|
||||
def extractTextFromAny(obj):
|
||||
lines = []
|
||||
|
||||
def walk(x):
|
||||
if x is None:
|
||||
return
|
||||
|
||||
if isinstance(x, dict):
|
||||
for key in ("rec_texts", "texts"):
|
||||
value = x.get(key)
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
text = str(item).strip()
|
||||
if text:
|
||||
lines.append(text)
|
||||
|
||||
for key in ("text", "rec_text"):
|
||||
value = x.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
lines.append(value.strip())
|
||||
|
||||
for value in x.values():
|
||||
walk(value)
|
||||
return
|
||||
|
||||
if isinstance(x, (list, tuple)):
|
||||
if len(x) >= 2 and isinstance(x[1], (list, tuple)) and len(x[1]) >= 1:
|
||||
candidate = x[1][0]
|
||||
if isinstance(candidate, str) and candidate.strip():
|
||||
lines.append(candidate.strip())
|
||||
|
||||
for item in x:
|
||||
walk(item)
|
||||
|
||||
walk(obj)
|
||||
|
||||
unique_lines = []
|
||||
seen = set()
|
||||
for line in lines:
|
||||
if line not in seen:
|
||||
unique_lines.append(line)
|
||||
seen.add(line)
|
||||
|
||||
return "\n".join(unique_lines)
|
||||
|
||||
|
||||
def printOcrResult(text):
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
log("INFO", "OCR結果は空です")
|
||||
return
|
||||
|
||||
log("CHECK", "OCR結果 full")
|
||||
for line in cleaned.splitlines():
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def preprocessOcrImage(image_path):
|
||||
config = loadOcrConfig()
|
||||
enabled = config["preprocess"]
|
||||
if not enabled:
|
||||
return image_path
|
||||
|
||||
try:
|
||||
image = Image.open(image_path)
|
||||
image = image.convert("L")
|
||||
image = ImageOps.autocontrast(image)
|
||||
image = image.filter(ImageFilter.SHARPEN)
|
||||
|
||||
scale = max(1.0, min(2.0, config["scale"]))
|
||||
if scale > 1.0:
|
||||
width, height = image.size
|
||||
resampling = getattr(
|
||||
getattr(Image, "Resampling", Image),
|
||||
"LANCZOS",
|
||||
)
|
||||
image = image.resize(
|
||||
(int(width * scale), int(height * scale)),
|
||||
resampling,
|
||||
)
|
||||
|
||||
image.save(image_path)
|
||||
log("INFO", f"OCR image preprocessed={image_path}")
|
||||
except Exception as e:
|
||||
log("ERROR", f"OCR preprocess failed detail={e}")
|
||||
|
||||
return image_path
|
||||
|
||||
|
||||
def runPaddleOcr(engine, image_path):
|
||||
if hasattr(engine, "predict"):
|
||||
return engine.predict(str(image_path))
|
||||
return engine.ocr(str(image_path))
|
||||
|
||||
|
||||
def runOcrFromImage(image_path, show_result=True):
|
||||
log("ACTION", f"PaddleOCR start image={image_path}")
|
||||
|
||||
try:
|
||||
image_path = preprocessOcrImage(image_path)
|
||||
engine = getOcrEngine()
|
||||
result = runPaddleOcr(engine, image_path)
|
||||
except Exception as e:
|
||||
log("ERROR", f"PaddleOCR failed detail={e}")
|
||||
return None
|
||||
|
||||
text = extractTextFromAny(result)
|
||||
text_path = saveOcrText(image_path, text)
|
||||
|
||||
try:
|
||||
pyperclip.copy(text)
|
||||
log("INFO", "OCR結果をクリップボードへコピーしました")
|
||||
except Exception as e:
|
||||
log("ERROR", f"clipboard copy failed detail={e}")
|
||||
|
||||
log("INFO", f"OCR text appended={text_path}")
|
||||
if show_result:
|
||||
printOcrResult(text)
|
||||
|
||||
return {
|
||||
"image_path": image_path,
|
||||
"text_path": text_path,
|
||||
"text": text,
|
||||
}
|
||||
|
||||
|
||||
def runOcrFromScreen():
|
||||
log("ACTION", "runOcrFromScreen called")
|
||||
image_path = captureOcrRegion()
|
||||
return runOcrFromImage(image_path, show_result=True)
|
||||
85
VRWT_Tool/VRC_OSC/src/osc/gateway.py
Normal file
85
VRWT_Tool/VRC_OSC/src/osc/gateway.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from pythonosc.dispatcher import Dispatcher
|
||||
from pythonosc.osc_server import BlockingOSCUDPServer
|
||||
from datetime import datetime
|
||||
import sys
|
||||
|
||||
class VrcOscGateway:
|
||||
def __init__(self, host="127.0.0.1", port=9001):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.dispatcher = Dispatcher()
|
||||
self.last_values = {}
|
||||
|
||||
def log(self, level, message):
|
||||
now = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[{level}] {now} {message}", flush=True)
|
||||
|
||||
def to_bool(self, value):
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
|
||||
if isinstance(value, int) or isinstance(value, float):
|
||||
return value != 0
|
||||
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in ("true", "1", "on"):
|
||||
return True
|
||||
if lowered in ("false", "0", "off"):
|
||||
return False
|
||||
|
||||
raise ValueError(f"unsupported OSC value: {value}")
|
||||
|
||||
def is_rising_edge(self, address, value):
|
||||
current = self.to_bool(value)
|
||||
previous = self.last_values.get(address, False)
|
||||
self.last_values[address] = current
|
||||
return current and not previous
|
||||
|
||||
def is_changed(self, address, value):
|
||||
current = self.to_bool(value)
|
||||
previous = self.last_values.get(address, None)
|
||||
|
||||
if previous == current:
|
||||
return False
|
||||
|
||||
self.last_values[address] = current
|
||||
return True
|
||||
|
||||
def map(self, parameter_name, handler):
|
||||
address = f"/avatar/parameters/{parameter_name}"
|
||||
self.dispatcher.map(address, handler)
|
||||
self.log("INFO", f"mapped {address}")
|
||||
|
||||
def set_default_debug(self, enabled=False):
|
||||
if not enabled:
|
||||
return
|
||||
|
||||
def on_any(address, *args):
|
||||
if address.startswith("/avatar/parameters/"):
|
||||
self.log("INFO", f"other address={address} args={args}")
|
||||
|
||||
self.dispatcher.set_default_handler(on_any)
|
||||
|
||||
def serve_forever(self):
|
||||
self.log("CHECK", "STEP=1 状態確認")
|
||||
self.log("INFO", f"listen={self.host}:{self.port}")
|
||||
|
||||
self.log("CHECK", "STEP=2 想定状態判定")
|
||||
self.log("INFO", "VRChat OSC Enabled")
|
||||
self.log("INFO", "Python は Windows PowerShell 側で実行")
|
||||
self.log("INFO", "Unity側 MA Parameters と OSC名が一致していること")
|
||||
|
||||
|
||||
try:
|
||||
server = BlockingOSCUDPServer((self.host, self.port), self.dispatcher)
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
self.log("INFO", "Ctrl+C detected")
|
||||
except Exception as e:
|
||||
self.log("FATAL", f"OSC server error STEP=3 detail={e}")
|
||||
input("Press Enter to exit...")
|
||||
sys.exit(1)
|
||||
|
||||
self.log("CHECK", "STEP=4 結果確認")
|
||||
self.log("INFO", "終了しました")
|
||||
167
VRWT_Tool/VRC_OSC/src/screen/screen_actions.py
Normal file
167
VRWT_Tool/VRC_OSC/src/screen/screen_actions.py
Normal file
@@ -0,0 +1,167 @@
|
||||
import pyautogui
|
||||
import pygetwindow as gw
|
||||
|
||||
from common.config_loader import loadOcrConfig
|
||||
from common.project_paths import RUNTIME_DIR
|
||||
|
||||
SCREENSHOT_PATH = RUNTIME_DIR / "latest_screenshot.png"
|
||||
OCR_SCREENSHOT_PATH = RUNTIME_DIR / "latest_ocr.png"
|
||||
|
||||
EXCLUDED_WINDOW_KEYWORDS = [
|
||||
"Google Chrome",
|
||||
"Microsoft Edge",
|
||||
"Mozilla Firefox",
|
||||
"Visual Studio Code",
|
||||
"PowerShell",
|
||||
"Windows Terminal",
|
||||
"ChatGPT",
|
||||
]
|
||||
|
||||
|
||||
def log(level, message):
|
||||
from datetime import datetime
|
||||
|
||||
now = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[{level}] {now} {message}", flush=True)
|
||||
|
||||
|
||||
def findVrchatWindow():
|
||||
windows = gw.getAllWindows()
|
||||
candidates = []
|
||||
|
||||
for window in windows:
|
||||
title = window.title or ""
|
||||
normalized = title.strip().lower()
|
||||
if not normalized:
|
||||
continue
|
||||
if any(keyword.lower() in normalized for keyword in EXCLUDED_WINDOW_KEYWORDS):
|
||||
continue
|
||||
|
||||
if normalized == "vrchat":
|
||||
candidates.append((0, window))
|
||||
continue
|
||||
if normalized.startswith("vrchat "):
|
||||
candidates.append((1, window))
|
||||
continue
|
||||
if normalized.startswith("vrchat"):
|
||||
candidates.append((2, window))
|
||||
|
||||
if not candidates:
|
||||
visible_titles = []
|
||||
for window in windows:
|
||||
title = (window.title or "").strip()
|
||||
if title:
|
||||
visible_titles.append(title)
|
||||
|
||||
log("ERROR", "VRChatウィンドウが見つかりません")
|
||||
log("CHECK", "現在検出できるウィンドウ")
|
||||
for title in visible_titles[:30]:
|
||||
print(title, flush=True)
|
||||
|
||||
raise RuntimeError("VRChatウィンドウが見つかりません")
|
||||
|
||||
candidates = sorted(
|
||||
candidates,
|
||||
key=lambda item: (item[0], -(item[1].width * item[1].height)),
|
||||
)
|
||||
window = candidates[0][1]
|
||||
|
||||
if window.width <= 0 or window.height <= 0:
|
||||
raise RuntimeError("VRChatウィンドウサイズが不正です")
|
||||
|
||||
return window
|
||||
|
||||
|
||||
def getVrchatRegion():
|
||||
window = findVrchatWindow()
|
||||
|
||||
try:
|
||||
if window.isMinimized:
|
||||
window.restore()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
left = int(window.left)
|
||||
top = int(window.top)
|
||||
width = int(window.width)
|
||||
height = int(window.height)
|
||||
|
||||
if width <= 0 or height <= 0:
|
||||
raise RuntimeError("VRChatウィンドウ範囲が不正です")
|
||||
|
||||
log("INFO", f"VRChat window title={window.title}")
|
||||
log("INFO", f"VRChat region left={left} top={top} width={width} height={height}")
|
||||
return left, top, width, height
|
||||
|
||||
|
||||
def clampRatio(value):
|
||||
return max(0.0, min(1.0, value))
|
||||
|
||||
|
||||
def getOcrRegion():
|
||||
left, top, width, height = getVrchatRegion()
|
||||
config = loadOcrConfig()
|
||||
|
||||
# Default to the lower central area. It is much faster than OCRing the
|
||||
# whole VRChat window and usually contains chat/subtitle text.
|
||||
crop_left = clampRatio(config["crop_left"])
|
||||
crop_top = clampRatio(config["crop_top"])
|
||||
crop_right = clampRatio(config["crop_right"])
|
||||
crop_bottom = clampRatio(config["crop_bottom"])
|
||||
|
||||
if crop_right <= crop_left:
|
||||
crop_left = 0.0
|
||||
crop_right = 1.0
|
||||
|
||||
if crop_bottom <= crop_top:
|
||||
crop_top = 0.0
|
||||
crop_bottom = 1.0
|
||||
|
||||
region_left = left + int(width * crop_left)
|
||||
region_top = top + int(height * crop_top)
|
||||
region_width = max(1, int(width * (crop_right - crop_left)))
|
||||
region_height = max(1, int(height * (crop_bottom - crop_top)))
|
||||
|
||||
log(
|
||||
"INFO",
|
||||
f"OCR crop left={region_left} top={region_top} width={region_width} height={region_height}",
|
||||
)
|
||||
return region_left, region_top, region_width, region_height
|
||||
|
||||
|
||||
def captureRegionTo(image_path, region):
|
||||
image_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
left, top, width, height = region
|
||||
|
||||
image = pyautogui.screenshot(region=(left, top, width, height))
|
||||
image.save(image_path)
|
||||
|
||||
log("ACTION", f"captureVrchatWindow saved={image_path}")
|
||||
return image_path
|
||||
|
||||
|
||||
def captureVrchatWindowTo(image_path):
|
||||
return captureRegionTo(
|
||||
image_path,
|
||||
getVrchatRegion(),
|
||||
)
|
||||
|
||||
|
||||
def captureVrchatWindow():
|
||||
return captureVrchatWindowTo(SCREENSHOT_PATH)
|
||||
|
||||
|
||||
def captureOcrRegion():
|
||||
return captureRegionTo(
|
||||
OCR_SCREENSHOT_PATH,
|
||||
getOcrRegion(),
|
||||
)
|
||||
|
||||
|
||||
def captureScreen():
|
||||
return captureVrchatWindow()
|
||||
|
||||
|
||||
def takeScreenshot():
|
||||
return captureVrchatWindow()
|
||||
124
VRWT_Tool/VRC_OSC/src/tools/discord_hold_mute_listener.py
Normal file
124
VRWT_Tool/VRC_OSC/src/tools/discord_hold_mute_listener.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from pythonosc.dispatcher import Dispatcher
|
||||
from pythonosc.osc_server import BlockingOSCUDPServer
|
||||
from datetime import datetime
|
||||
import pyautogui
|
||||
import time
|
||||
import sys
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 9001
|
||||
PARAMETER = "/avatar/parameters/DiscordSend"
|
||||
|
||||
# Discordの初期状態を「ミュート中」と仮定
|
||||
discord_muted = True
|
||||
button_pressed = False
|
||||
last_action_time = 0.0
|
||||
DEBOUNCE_SECONDS = 0.3
|
||||
|
||||
def log(level, message):
|
||||
now = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[{level}] {now} {message}", flush=True)
|
||||
|
||||
def wait_exit():
|
||||
input("Press Enter to exit...")
|
||||
|
||||
def press_discord_mute_hotkey():
|
||||
pyautogui.hotkey("ctrl", "shift", "m")
|
||||
|
||||
def handle_discord_send(address, *args):
|
||||
global discord_muted
|
||||
global button_pressed
|
||||
global last_action_time
|
||||
|
||||
if not args:
|
||||
log("ERROR", "OSC args is empty")
|
||||
return
|
||||
|
||||
value = args[0]
|
||||
now_ts = time.time()
|
||||
|
||||
if isinstance(value, bool):
|
||||
is_pressed = value
|
||||
elif isinstance(value, int) or isinstance(value, float):
|
||||
is_pressed = value != 0
|
||||
else:
|
||||
log("ERROR", f"unsupported value type value={value}")
|
||||
return
|
||||
|
||||
if now_ts - last_action_time < DEBOUNCE_SECONDS:
|
||||
return
|
||||
|
||||
# 押した瞬間: ミュート解除
|
||||
if is_pressed and not button_pressed:
|
||||
button_pressed = True
|
||||
|
||||
if discord_muted:
|
||||
log("ACTION", "VRChat button pressed -> Discord unmute hotkey")
|
||||
press_discord_mute_hotkey()
|
||||
discord_muted = False
|
||||
last_action_time = now_ts
|
||||
else:
|
||||
log("INFO", "already unmuted")
|
||||
|
||||
return
|
||||
|
||||
# 離した瞬間: ミュート
|
||||
if not is_pressed and button_pressed:
|
||||
button_pressed = False
|
||||
|
||||
if not discord_muted:
|
||||
log("ACTION", "VRChat button released -> Discord mute hotkey")
|
||||
press_discord_mute_hotkey()
|
||||
discord_muted = True
|
||||
last_action_time = now_ts
|
||||
else:
|
||||
log("INFO", "already muted")
|
||||
|
||||
return
|
||||
|
||||
def main():
|
||||
log("CHECK", "STEP=1 OSC受信状態確認")
|
||||
log("INFO", f"host={HOST}")
|
||||
log("INFO", f"port={PORT}")
|
||||
log("INFO", f"parameter={PARAMETER}")
|
||||
|
||||
log("CHECK", "STEP=2 想定状態判定")
|
||||
log("INFO", "Windows PowerShellで実行していること")
|
||||
log("INFO", "VRChat OSC Enabledであること")
|
||||
log("INFO", "Discordのミュート切替キーが Ctrl+Shift+M であること")
|
||||
log("INFO", "Discordの現在状態はミュート中として扱います")
|
||||
|
||||
answer = input("[ACTION] 続行しますか? yes/no: ").strip().lower()
|
||||
|
||||
if answer != "yes":
|
||||
if answer == "no":
|
||||
log("FATAL", "ユーザー操作により停止 STEP=2")
|
||||
wait_exit()
|
||||
sys.exit(1)
|
||||
|
||||
log("ERROR", "不正入力です STEP=2")
|
||||
wait_exit()
|
||||
sys.exit(1)
|
||||
|
||||
log("ACTION", "STEP=3 OSC受信開始")
|
||||
log("INFO", "VRChatのメニューを押している間だけDiscordミュート解除します")
|
||||
log("INFO", "停止する場合は Ctrl+C")
|
||||
|
||||
dispatcher = Dispatcher()
|
||||
dispatcher.map(PARAMETER, handle_discord_send)
|
||||
|
||||
try:
|
||||
server = BlockingOSCUDPServer((HOST, PORT), dispatcher)
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
log("INFO", "Ctrl+C detected")
|
||||
except Exception as e:
|
||||
log("FATAL", f"OSC server error STEP=3 detail={e}")
|
||||
wait_exit()
|
||||
sys.exit(1)
|
||||
|
||||
log("CHECK", "STEP=4 結果確認")
|
||||
log("INFO", "終了しました")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
21
VRWT_Tool/VRC_OSC/src/tools/osc_debug_listener.py
Normal file
21
VRWT_Tool/VRC_OSC/src/tools/osc_debug_listener.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from pythonosc.dispatcher import Dispatcher
|
||||
from pythonosc.osc_server import BlockingOSCUDPServer
|
||||
from datetime import datetime
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 9001
|
||||
|
||||
def on_any(address, *args):
|
||||
now = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[INFO] {now} address={address} args={args}", flush=True)
|
||||
|
||||
dispatcher = Dispatcher()
|
||||
dispatcher.set_default_handler(on_any)
|
||||
|
||||
print("[CHECK] OSC受信開始")
|
||||
print(f"[INFO] host={HOST}")
|
||||
print(f"[INFO] port={PORT}")
|
||||
print("[ACTION] VRChatでメニューを押してください")
|
||||
|
||||
server = BlockingOSCUDPServer((HOST, PORT), dispatcher)
|
||||
server.serve_forever()
|
||||
54
VRWT_Tool/VRC_OSC/src/tools/osc_test_print_listener.py
Normal file
54
VRWT_Tool/VRC_OSC/src/tools/osc_test_print_listener.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from pythonosc.dispatcher import Dispatcher
|
||||
from pythonosc.osc_server import BlockingOSCUDPServer
|
||||
from datetime import datetime
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 9001
|
||||
|
||||
def log(msg):
|
||||
print(msg, flush=True)
|
||||
|
||||
def on_test(address, *args):
|
||||
now = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
log("")
|
||||
log(f"[INFO] time={now}")
|
||||
log(f"[ACTION] OSC parameter received")
|
||||
log(f"[INFO] address={address}")
|
||||
log(f"[INFO] args={args}")
|
||||
|
||||
dispatcher = Dispatcher()
|
||||
|
||||
dispatcher.map(
|
||||
"/avatar/parameters/TestPrint",
|
||||
on_test
|
||||
)
|
||||
|
||||
log("[CHECK] STEP=1 OSC待受開始")
|
||||
log(f"[INFO] host={HOST}")
|
||||
log(f"[INFO] port={PORT}")
|
||||
|
||||
try:
|
||||
server = BlockingOSCUDPServer(
|
||||
(HOST, PORT),
|
||||
dispatcher
|
||||
)
|
||||
|
||||
log("[INFO] OSC server started")
|
||||
log("[ACTION] VRChatでTestPrintを押してください")
|
||||
|
||||
server.serve_forever()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
log("")
|
||||
log("[INFO] Ctrl+C detected")
|
||||
log("[INFO] shutdown")
|
||||
|
||||
except Exception as e:
|
||||
log("")
|
||||
log("[FATAL] OSC server error")
|
||||
log(f"[ERROR] detail={e}")
|
||||
|
||||
print("")
|
||||
input("Press Enter to exit...")
|
||||
raise SystemExit(1)
|
||||
134
VRWT_Tool/VRC_OSC/src/tools/resolve_vrchat_user.py
Normal file
134
VRWT_Tool/VRC_OSC/src/tools/resolve_vrchat_user.py
Normal file
@@ -0,0 +1,134 @@
|
||||
import re
|
||||
from http.cookiejar import MozillaCookieJar
|
||||
|
||||
import vrchatapi
|
||||
from vrchatapi.api import authentication_api
|
||||
from vrchatapi.api import users_api
|
||||
from vrchatapi.exceptions import UnauthorizedException
|
||||
from vrchatapi.models.two_factor_auth_code import TwoFactorAuthCode
|
||||
from vrchatapi.models.two_factor_email_code import TwoFactorEmailCode
|
||||
|
||||
from common.config_loader import getSecretValue
|
||||
|
||||
COOKIE_FILE = ".vrchat_cookie.txt"
|
||||
|
||||
USER_AGENT = (
|
||||
"VRC_OSC/1.0 "
|
||||
"(contact: test@gmail.com)"
|
||||
)
|
||||
|
||||
|
||||
def get_required_secret(section, key):
|
||||
value = getSecretValue(section, key)
|
||||
if not value:
|
||||
raise RuntimeError(f"{section}.{key} is not set in config/secrets.toml")
|
||||
return value
|
||||
|
||||
|
||||
def setup_cookie_jar(api_client):
|
||||
cookie_jar = MozillaCookieJar(COOKIE_FILE)
|
||||
|
||||
if os.path.exists(COOKIE_FILE):
|
||||
try:
|
||||
cookie_jar.load(
|
||||
ignore_discard=True,
|
||||
ignore_expires=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
api_client.rest_client.cookie_jar = cookie_jar
|
||||
return cookie_jar
|
||||
|
||||
|
||||
def save_cookie_jar(cookie_jar):
|
||||
try:
|
||||
cookie_jar.save(
|
||||
ignore_discard=True,
|
||||
ignore_expires=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def login(api_client, cookie_jar):
|
||||
auth_api = authentication_api.AuthenticationApi(api_client)
|
||||
|
||||
try:
|
||||
current_user = auth_api.get_current_user()
|
||||
save_cookie_jar(cookie_jar)
|
||||
return current_user
|
||||
|
||||
except UnauthorizedException as e:
|
||||
reason = str(e.reason)
|
||||
|
||||
if "Email 2 Factor Authentication" in reason:
|
||||
code = input("Email 2FA Code: ").strip()
|
||||
|
||||
auth_api.verify2_fa_email_code(
|
||||
TwoFactorEmailCode(code)
|
||||
)
|
||||
|
||||
elif "2 Factor Authentication" in reason:
|
||||
code = input("TOTP Code: ").strip()
|
||||
|
||||
auth_api.verify2_fa(
|
||||
TwoFactorAuthCode(code)
|
||||
)
|
||||
|
||||
else:
|
||||
raise
|
||||
|
||||
current_user = auth_api.get_current_user()
|
||||
|
||||
save_cookie_jar(cookie_jar)
|
||||
|
||||
return current_user
|
||||
|
||||
|
||||
def extract_user_id(text):
|
||||
match = re.search(
|
||||
r"(usr_[0-9a-fA-F\-]+)",
|
||||
text,
|
||||
)
|
||||
|
||||
if not match:
|
||||
return None
|
||||
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def main():
|
||||
text = input("参加者名: ").strip()
|
||||
|
||||
user_id = extract_user_id(text)
|
||||
|
||||
if not user_id:
|
||||
print(text)
|
||||
return
|
||||
|
||||
configuration = vrchatapi.Configuration(
|
||||
username=get_required_secret("vrchat", "username"),
|
||||
password=get_required_secret("vrchat", "password"),
|
||||
)
|
||||
|
||||
with vrchatapi.ApiClient(configuration) as api_client:
|
||||
api_client.user_agent = USER_AGENT
|
||||
|
||||
api_client.default_headers["User-Agent"] = USER_AGENT
|
||||
|
||||
cookie_jar = setup_cookie_jar(api_client)
|
||||
|
||||
login(api_client, cookie_jar)
|
||||
|
||||
users = users_api.UsersApi(api_client)
|
||||
|
||||
user = users.get_user(user_id)
|
||||
|
||||
print(user.display_name)
|
||||
|
||||
save_cookie_jar(cookie_jar)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
102
VRWT_Tool/VRC_OSC/src/translate/translate_actions.py
Normal file
102
VRWT_Tool/VRC_OSC/src/translate/translate_actions.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
import pyperclip
|
||||
from deep_translator import GoogleTranslator
|
||||
|
||||
from common.runtime_log import appendRuntimeLog
|
||||
|
||||
IGNORE_EXACT = {
|
||||
"戻る",
|
||||
"翻訳",
|
||||
"OCR",
|
||||
"VRCLOG",
|
||||
"Discord_Mute",
|
||||
"Screenshot",
|
||||
"OCR_Toggle",
|
||||
"DCR_Toggle",
|
||||
"HI",
|
||||
}
|
||||
|
||||
|
||||
def log(level, message):
|
||||
now = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[{level}] {now} {message}", flush=True)
|
||||
|
||||
|
||||
def saveTranslationText(stem, text):
|
||||
return appendRuntimeLog(
|
||||
"TRANSLATION TEXT",
|
||||
f"source={stem}\n{text}",
|
||||
)
|
||||
|
||||
|
||||
def looksLikeNoise(line):
|
||||
value = line.strip()
|
||||
if not value:
|
||||
return True
|
||||
if value in IGNORE_EXACT:
|
||||
return True
|
||||
if len(value) <= 2:
|
||||
return True
|
||||
if "_" in value:
|
||||
return True
|
||||
if re.fullmatch(r"[A-Za-z0-9\-_/]+", value):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def normalizeSourceText(text):
|
||||
lines = []
|
||||
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if looksLikeNoise(line):
|
||||
continue
|
||||
lines.append(line)
|
||||
|
||||
unique_lines = []
|
||||
seen = set()
|
||||
for line in lines:
|
||||
if line not in seen:
|
||||
unique_lines.append(line)
|
||||
seen.add(line)
|
||||
|
||||
return "\n".join(unique_lines).strip()
|
||||
|
||||
|
||||
def translateTextToJapanese(text):
|
||||
source = normalizeSourceText(text)
|
||||
if not source:
|
||||
log("INFO", "翻訳対象が空です")
|
||||
return ""
|
||||
|
||||
log("CHECK", "Translation source")
|
||||
for line in source.splitlines():
|
||||
print(line, flush=True)
|
||||
|
||||
try:
|
||||
translated = GoogleTranslator(
|
||||
source="auto",
|
||||
target="ja",
|
||||
).translate(source)
|
||||
except Exception as e:
|
||||
log("ERROR", f"translation failed detail={e}")
|
||||
return ""
|
||||
|
||||
translated = translated.strip()
|
||||
if not translated:
|
||||
log("INFO", "翻訳結果は空です")
|
||||
return ""
|
||||
|
||||
try:
|
||||
pyperclip.copy(translated)
|
||||
log("INFO", "翻訳結果をクリップボードへコピーしました")
|
||||
except Exception as e:
|
||||
log("ERROR", f"clipboard copy failed detail={e}")
|
||||
|
||||
log("CHECK", "Translation result")
|
||||
for line in translated.splitlines():
|
||||
print(line, flush=True)
|
||||
|
||||
return translated
|
||||
563
VRWT_Tool/VRC_OSC/src/vrc_log/log_actions.py
Normal file
563
VRWT_Tool/VRC_OSC/src/vrc_log/log_actions.py
Normal file
@@ -0,0 +1,563 @@
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from common.config_loader import loadVrcLogConfig
|
||||
from common.config_loader import getSecretValue
|
||||
from common.runtime_log import appendRuntimeLog
|
||||
from discord_control.actions import setDiscordMuted
|
||||
|
||||
_monitor_thread = None
|
||||
_monitor_stop_event = threading.Event()
|
||||
_last_sent_output = None
|
||||
_self_monitor_thread = None
|
||||
_self_monitor_stop_event = threading.Event()
|
||||
_last_self_state = None
|
||||
REALTIME_IDLE_SECONDS = 0.2
|
||||
|
||||
JOIN_PATTERN = re.compile(
|
||||
r"OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)"
|
||||
)
|
||||
LEFT_PATTERN = re.compile(
|
||||
r"OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)"
|
||||
)
|
||||
WORLD_PATTERN = re.compile(
|
||||
r"(wrld_[0-9a-fA-F-]+(?::[^\s\]\)\"']+)?)"
|
||||
)
|
||||
ENTERING_ROOM_PATTERN = re.compile(
|
||||
r"\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$"
|
||||
)
|
||||
WORLD_NAME_PATTERN = re.compile(
|
||||
r"worldId=(wrld_[0-9a-fA-F-]+)(?::[^,}]*)?,\s*worldName=([^,}]+)"
|
||||
)
|
||||
TIME_PATTERN = re.compile(
|
||||
r"(\d{4}\.\d{2}\.\d{2}\s+\d{2}:\d{2}:\d{2})"
|
||||
)
|
||||
RELEVANT_LOG_PATTERN = re.compile(
|
||||
r"OnPlayerJoined|OnPlayerLeft|Entering Room|Joining or Creating Room|worldId=|wrld_"
|
||||
)
|
||||
|
||||
|
||||
def log(level, message):
|
||||
now = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[{level}] {now} {message}", flush=True)
|
||||
|
||||
|
||||
def getWebhookUrl():
|
||||
return getSecretValue("discord", "webhook_url")
|
||||
|
||||
|
||||
def sendWebhook(text):
|
||||
webhook_url = getWebhookUrl().strip()
|
||||
if not webhook_url:
|
||||
log("ERROR", "Discord webhook URL is not set config=config/secrets.toml")
|
||||
return False
|
||||
|
||||
message = str(text).strip()
|
||||
if not message:
|
||||
log("INFO", "Webhook送信内容が空です")
|
||||
return False
|
||||
|
||||
if len(message) > 1800:
|
||||
message = message[-1800:]
|
||||
|
||||
ps_script = r'''
|
||||
param(
|
||||
[string]$WebhookUrl,
|
||||
[string]$Message
|
||||
)
|
||||
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
$Payload = @{ content = $Message } | ConvertTo-Json -Compress
|
||||
$Body = [System.Text.Encoding]::UTF8.GetBytes($Payload)
|
||||
Invoke-WebRequest -Uri $WebhookUrl -Method Post -ContentType 'application/json; charset=utf-8' -Body $Body -UseBasicParsing | Out-Null
|
||||
'''
|
||||
|
||||
ps_path = None
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
suffix=".ps1",
|
||||
delete=False,
|
||||
encoding="utf-8-sig",
|
||||
) as tmp:
|
||||
tmp.write(ps_script)
|
||||
ps_path = tmp.name
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
ps_path,
|
||||
webhook_url,
|
||||
message,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
except Exception as e:
|
||||
log("ERROR", f"Discord webhook PowerShell failed detail={e}")
|
||||
return False
|
||||
finally:
|
||||
if ps_path:
|
||||
try:
|
||||
Path(ps_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr = (result.stderr or "").strip()
|
||||
stdout = (result.stdout or "").strip()
|
||||
log("ERROR", f"Discord webhook PowerShell failed code={result.returncode} stderr={stderr} stdout={stdout}")
|
||||
return False
|
||||
|
||||
log("INFO", "Discord webhook sent via PowerShell")
|
||||
return True
|
||||
|
||||
|
||||
def getVrchatLogDir():
|
||||
userprofile = os.environ.get("USERPROFILE")
|
||||
if not userprofile:
|
||||
raise RuntimeError("USERPROFILE が取得できません")
|
||||
return Path(userprofile) / "AppData" / "LocalLow" / "VRChat" / "VRChat"
|
||||
|
||||
|
||||
def findLatestVrchatLog():
|
||||
log_dir = getVrchatLogDir()
|
||||
if not log_dir.exists():
|
||||
raise RuntimeError(f"VRChatログディレクトリが存在しません: {log_dir}")
|
||||
|
||||
candidates = []
|
||||
for pattern in ("output_log_*.txt", "Player.log", "*.log", "*.txt"):
|
||||
candidates.extend(log_dir.glob(pattern))
|
||||
|
||||
candidates = [path for path in candidates if path.is_file()]
|
||||
if not candidates:
|
||||
raise RuntimeError(f"VRChatログファイルが見つかりません: {log_dir}")
|
||||
|
||||
candidates.sort(key=lambda path: path.stat().st_mtime, reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def readTextSafe(path):
|
||||
for encoding in ("utf-8", "utf-8-sig", "cp932", "shift_jis"):
|
||||
try:
|
||||
return path.read_text(encoding=encoding, errors="replace")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
raise RuntimeError(f"ログファイルを読めません: {path}")
|
||||
|
||||
|
||||
def parseLineTime(line):
|
||||
matched = TIME_PATTERN.search(line)
|
||||
if not matched:
|
||||
return None
|
||||
|
||||
return datetime.strptime(matched.group(1), "%Y.%m.%d %H:%M:%S")
|
||||
|
||||
|
||||
def formatClock(dt):
|
||||
return dt.strftime("%H:%M")
|
||||
|
||||
|
||||
def getLogStartTime(now):
|
||||
noon_today = now.replace(hour=12, minute=0, second=0, microsecond=0)
|
||||
if now >= noon_today:
|
||||
return noon_today
|
||||
return noon_today - timedelta(days=1)
|
||||
|
||||
|
||||
def splitWorldLocation(location):
|
||||
if ":" not in location:
|
||||
return location, ""
|
||||
|
||||
world_id, instance_id = location.split(":", 1)
|
||||
return world_id, instance_id
|
||||
|
||||
|
||||
def extractWorldNameMap(text):
|
||||
world_name_map = {}
|
||||
pending_world_name = ""
|
||||
|
||||
for raw_line in text.splitlines():
|
||||
room_match = ENTERING_ROOM_PATTERN.search(raw_line)
|
||||
if room_match:
|
||||
pending_world_name = room_match.group(1).strip()
|
||||
|
||||
match = WORLD_NAME_PATTERN.search(raw_line)
|
||||
if match:
|
||||
world_id = match.group(1).strip()
|
||||
world_name = match.group(2).strip()
|
||||
if world_id and world_name:
|
||||
world_name_map[world_id] = world_name
|
||||
continue
|
||||
|
||||
world_match = WORLD_PATTERN.search(raw_line)
|
||||
if world_match and pending_world_name:
|
||||
location = world_match.group(1).strip()
|
||||
world_id, _ = splitWorldLocation(location)
|
||||
if world_id:
|
||||
world_name_map[world_id] = pending_world_name
|
||||
pending_world_name = ""
|
||||
|
||||
return world_name_map
|
||||
|
||||
|
||||
def formatWorldLabel(world_id, world_name_map):
|
||||
world_name = world_name_map.get(world_id, "").strip()
|
||||
if not world_name:
|
||||
return "不明"
|
||||
return world_name
|
||||
|
||||
|
||||
def classifyUser(name, guest_names, staff_names):
|
||||
if name in staff_names:
|
||||
return "staff"
|
||||
if name in guest_names:
|
||||
return "guest"
|
||||
return "other"
|
||||
|
||||
|
||||
def isSelf(name, self_name):
|
||||
return bool(self_name) and name.strip() == self_name.strip()
|
||||
|
||||
|
||||
def buildNoticeLines(guest_names, guest_present_set, missing_count):
|
||||
total_guest = len(guest_names)
|
||||
current_guest = len(guest_present_set)
|
||||
threshold = total_guest - missing_count
|
||||
|
||||
if missing_count <= 0:
|
||||
return []
|
||||
if not (threshold <= current_guest < total_guest):
|
||||
return []
|
||||
|
||||
missing_guests = [name for name in guest_names if name not in guest_present_set]
|
||||
if not missing_guests:
|
||||
return []
|
||||
|
||||
lines = [f"現在Guest: {current_guest}/{total_guest}", "未入室Guest:"]
|
||||
lines.extend(f"- {name}" for name in missing_guests)
|
||||
return lines
|
||||
|
||||
|
||||
def appendNoticeIfNeeded(output_lines, state):
|
||||
notice_lines = buildNoticeLines(
|
||||
state["guest_names"],
|
||||
state["guest_present_set"],
|
||||
state["missing_count"],
|
||||
)
|
||||
notice_key = tuple(notice_lines)
|
||||
|
||||
if not notice_lines:
|
||||
state["last_notice_key"] = None
|
||||
return
|
||||
|
||||
if state["last_notice_key"] == notice_key:
|
||||
return
|
||||
|
||||
output_lines.extend(notice_lines)
|
||||
state["last_notice_key"] = notice_key
|
||||
|
||||
|
||||
def parseVrchatEvents(text, config):
|
||||
start_time = getLogStartTime(datetime.now())
|
||||
guest_names = config["guest_names"]
|
||||
staff_names = set(config["staff_names"])
|
||||
self_name = config.get("self_name", "")
|
||||
world_name_map = extractWorldNameMap(text)
|
||||
|
||||
state = {
|
||||
"location": "",
|
||||
"world_id": "",
|
||||
"instance_id": "",
|
||||
"guest_names": guest_names,
|
||||
"guest_name_set": set(guest_names),
|
||||
"staff_names": staff_names,
|
||||
"guest_present_set": set(),
|
||||
"staff_present_set": set(),
|
||||
"member_present_set": set(),
|
||||
"missing_count": config["missing_count"],
|
||||
"last_notice_key": None,
|
||||
}
|
||||
output_lines = []
|
||||
self_state = None
|
||||
|
||||
for raw_line in text.splitlines():
|
||||
line_time = parseLineTime(raw_line)
|
||||
if not line_time or line_time < start_time:
|
||||
continue
|
||||
|
||||
world_match = WORLD_PATTERN.search(raw_line)
|
||||
if world_match:
|
||||
location = world_match.group(1).strip()
|
||||
world_id, instance_id = splitWorldLocation(location)
|
||||
|
||||
if location != state["location"]:
|
||||
state["location"] = location
|
||||
state["world_id"] = world_id
|
||||
state["instance_id"] = instance_id
|
||||
state["guest_present_set"].clear()
|
||||
state["staff_present_set"].clear()
|
||||
state["member_present_set"].clear()
|
||||
state["last_notice_key"] = None
|
||||
output_lines = []
|
||||
output_lines.append(f"ワールド入室: {formatWorldLabel(world_id, world_name_map)}")
|
||||
output_lines.append(f"現在Guest: 0/{len(guest_names)}")
|
||||
|
||||
join_match = JOIN_PATTERN.search(raw_line)
|
||||
if join_match and state["world_id"]:
|
||||
name = join_match.group(1).strip()
|
||||
if isSelf(name, self_name):
|
||||
self_state = "joined"
|
||||
state["member_present_set"].add(name)
|
||||
output_lines.append(
|
||||
f'[{formatClock(line_time)}] [join] [self] {name} {len(state["member_present_set"])}/{len(guest_names)}'
|
||||
)
|
||||
continue
|
||||
|
||||
role = classifyUser(name, state["guest_name_set"], state["staff_names"])
|
||||
|
||||
if role == "guest":
|
||||
state["guest_present_set"].add(name)
|
||||
state["member_present_set"].add(name)
|
||||
output_lines.append(
|
||||
f'[{formatClock(line_time)}] [join] {name} {len(state["member_present_set"])}/{len(guest_names)}'
|
||||
)
|
||||
appendNoticeIfNeeded(output_lines, state)
|
||||
elif role == "staff":
|
||||
state["staff_present_set"].add(name)
|
||||
output_lines.append(
|
||||
f'[{formatClock(line_time)}] [join] [staff] {name} {len(state["member_present_set"])}/{len(guest_names)}'
|
||||
)
|
||||
else:
|
||||
state["member_present_set"].add(name)
|
||||
output_lines.append(
|
||||
f'[{formatClock(line_time)}] [join] {name} {len(state["member_present_set"])}/{len(guest_names)}'
|
||||
)
|
||||
continue
|
||||
|
||||
left_match = LEFT_PATTERN.search(raw_line)
|
||||
if left_match and state["world_id"]:
|
||||
name = left_match.group(1).strip()
|
||||
if isSelf(name, self_name):
|
||||
self_state = "left"
|
||||
state["member_present_set"].discard(name)
|
||||
output_lines.append(
|
||||
f'[{formatClock(line_time)}] [leave] [self] {name} {len(state["member_present_set"])}/{len(guest_names)}'
|
||||
)
|
||||
continue
|
||||
|
||||
role = classifyUser(name, state["guest_name_set"], state["staff_names"])
|
||||
|
||||
if role == "guest":
|
||||
state["guest_present_set"].discard(name)
|
||||
state["member_present_set"].discard(name)
|
||||
output_lines.append(
|
||||
f'[{formatClock(line_time)}] [leave] {name} {len(state["member_present_set"])}/{len(guest_names)}'
|
||||
)
|
||||
appendNoticeIfNeeded(output_lines, state)
|
||||
elif role == "staff":
|
||||
state["staff_present_set"].discard(name)
|
||||
output_lines.append(
|
||||
f'[{formatClock(line_time)}] [leave] [staff] {name} {len(state["member_present_set"])}/{len(guest_names)}'
|
||||
)
|
||||
else:
|
||||
state["member_present_set"].discard(name)
|
||||
output_lines.append(
|
||||
f'[{formatClock(line_time)}] [leave] {name} {len(state["member_present_set"])}/{len(guest_names)}'
|
||||
)
|
||||
|
||||
return output_lines, self_state
|
||||
|
||||
|
||||
def extractLatestSelfLogState(text, self_name):
|
||||
if not self_name:
|
||||
return None
|
||||
|
||||
latest_state = None
|
||||
|
||||
for raw_line in text.splitlines():
|
||||
join_match = JOIN_PATTERN.search(raw_line)
|
||||
if join_match and isSelf(join_match.group(1).strip(), self_name):
|
||||
latest_state = "joined"
|
||||
continue
|
||||
|
||||
left_match = LEFT_PATTERN.search(raw_line)
|
||||
if left_match and isSelf(left_match.group(1).strip(), self_name):
|
||||
latest_state = "left"
|
||||
|
||||
return latest_state
|
||||
|
||||
|
||||
def grepVrchatLog(pattern=None):
|
||||
return collectVrchatLog(pattern=pattern, notify_changed=False)
|
||||
|
||||
|
||||
def collectVrchatLog(pattern=None, notify_changed=True):
|
||||
config = loadVrcLogConfig()
|
||||
latest_log = findLatestVrchatLog()
|
||||
log("ACTION", f"parseVrchatLog file={latest_log}")
|
||||
log("INFO", f'event_file={config["event_file"]}')
|
||||
|
||||
text = readTextSafe(latest_log)
|
||||
lines, self_state = parseVrchatEvents(text, config)
|
||||
|
||||
output_text = "\n".join(lines) if lines else "[INFO] 対象イベントなし"
|
||||
output_path = appendRuntimeLog("VRC LOG", output_text)
|
||||
|
||||
if notify_changed:
|
||||
global _last_sent_output
|
||||
if output_text != _last_sent_output:
|
||||
try:
|
||||
ok = sendWebhook(output_text)
|
||||
if ok:
|
||||
log("INFO", "Discord webhook sent")
|
||||
_last_sent_output = output_text
|
||||
else:
|
||||
log("ERROR", "Discord webhook send failed")
|
||||
except Exception as e:
|
||||
log("ERROR", f"Discord webhook failed detail={e}")
|
||||
|
||||
log("INFO", f"result appended={output_path}")
|
||||
for line in lines or ["[INFO] 対象イベントなし"]:
|
||||
print(line, flush=True)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def _readNewText(path, offset):
|
||||
for encoding in ("utf-8", "utf-8-sig", "cp932", "shift_jis"):
|
||||
try:
|
||||
with path.open("r", encoding=encoding, errors="replace") as file:
|
||||
file.seek(offset)
|
||||
return file.read(), file.tell()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
raise RuntimeError(f"ログファイルを読めません: {path}")
|
||||
|
||||
|
||||
def _watchVrchatLogChanges(stop_event, label, on_relevant_change):
|
||||
log("ACTION", f"{label} realtime watcher started")
|
||||
|
||||
current_log = None
|
||||
offset = 0
|
||||
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
latest_log = findLatestVrchatLog()
|
||||
latest_size = latest_log.stat().st_size
|
||||
|
||||
if current_log is None:
|
||||
current_log = latest_log
|
||||
offset = latest_size
|
||||
log("INFO", f"{label} watching file={current_log}")
|
||||
continue
|
||||
|
||||
if latest_log != current_log:
|
||||
current_log = latest_log
|
||||
offset = 0
|
||||
log("INFO", f"{label} watching new file={current_log}")
|
||||
|
||||
if latest_size < offset:
|
||||
offset = 0
|
||||
|
||||
if latest_size > offset:
|
||||
text, offset = _readNewText(latest_log, offset)
|
||||
|
||||
if RELEVANT_LOG_PATTERN.search(text):
|
||||
on_relevant_change(text)
|
||||
except Exception as e:
|
||||
log("ERROR", f"{label} realtime watcher failed detail={e}")
|
||||
|
||||
stop_event.wait(REALTIME_IDLE_SECONDS)
|
||||
|
||||
log("INFO", f"{label} realtime watcher stopped")
|
||||
|
||||
|
||||
def _monitor_loop():
|
||||
def on_relevant_change(_text):
|
||||
collectVrchatLog(notify_changed=True)
|
||||
|
||||
_watchVrchatLogChanges(
|
||||
_monitor_stop_event,
|
||||
"VRC log monitor",
|
||||
on_relevant_change,
|
||||
)
|
||||
|
||||
|
||||
def _self_monitor_loop():
|
||||
def on_relevant_change(text):
|
||||
try:
|
||||
config = loadVrcLogConfig()
|
||||
self_state = extractLatestSelfLogState(
|
||||
text,
|
||||
config.get("self_name", ""),
|
||||
)
|
||||
|
||||
global _last_self_state
|
||||
if self_state and self_state != _last_self_state:
|
||||
if self_state == "joined":
|
||||
setDiscordMuted(True)
|
||||
elif self_state == "left":
|
||||
setDiscordMuted(False)
|
||||
_last_self_state = self_state
|
||||
except Exception as e:
|
||||
log("ERROR", f"VRC self monitor failed detail={e}")
|
||||
|
||||
_watchVrchatLogChanges(
|
||||
_self_monitor_stop_event,
|
||||
"VRC self monitor",
|
||||
on_relevant_change,
|
||||
)
|
||||
|
||||
|
||||
def startVrcLogMonitor(_interval_seconds=None):
|
||||
global _monitor_thread
|
||||
|
||||
if _monitor_thread and _monitor_thread.is_alive():
|
||||
log("INFO", "VRC log monitor already running")
|
||||
return
|
||||
|
||||
_monitor_stop_event.clear()
|
||||
_monitor_thread = threading.Thread(
|
||||
target=_monitor_loop,
|
||||
daemon=True,
|
||||
)
|
||||
_monitor_thread.start()
|
||||
|
||||
|
||||
def startSelfMonitor(_interval_seconds=None):
|
||||
global _self_monitor_thread
|
||||
|
||||
if _self_monitor_thread and _self_monitor_thread.is_alive():
|
||||
log("INFO", "VRC self monitor already running")
|
||||
return
|
||||
|
||||
_self_monitor_stop_event.clear()
|
||||
_self_monitor_thread = threading.Thread(
|
||||
target=_self_monitor_loop,
|
||||
daemon=True,
|
||||
)
|
||||
_self_monitor_thread.start()
|
||||
|
||||
|
||||
def stopVrcLogMonitor():
|
||||
_monitor_stop_event.set()
|
||||
|
||||
|
||||
def stopSelfMonitor():
|
||||
_self_monitor_stop_event.set()
|
||||
623
VRWT_Tool/VRC_OSC/src/vrc_log/member_name_matcher.py
Normal file
623
VRWT_Tool/VRC_OSC/src/vrc_log/member_name_matcher.py
Normal file
@@ -0,0 +1,623 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
目的:
|
||||
VRChat表示名とTOML登録名を安全な順番で紐づける
|
||||
|
||||
特徴:
|
||||
- 完全一致を先に確定して候補から除外
|
||||
- 正規化一致を確定して候補から除外
|
||||
- 残った候補だけを high confidence fuzzy 判定
|
||||
- guest のみ low confidence fuzzy で再選別
|
||||
- pending / ambiguous / unmatched 判定
|
||||
- 記号除去
|
||||
- 全角半角統一
|
||||
- 小文字化
|
||||
- 日本語 -> ローマ字変換
|
||||
- rapidfuzz による類似度
|
||||
- テストケース付き
|
||||
|
||||
前提:
|
||||
pip install rapidfuzz pykakasi
|
||||
|
||||
期待結果:
|
||||
- 100%一致した名前を fuzzy 候補に残さない
|
||||
- 似た名前同士の誤リンクを減らす
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
try:
|
||||
from rapidfuzz import fuzz
|
||||
except Exception as e:
|
||||
if __name__ == "__main__":
|
||||
print(f"[FATAL] rapidfuzz import failed: {e}")
|
||||
input("Press Enter to exit...")
|
||||
sys.exit(1)
|
||||
raise
|
||||
|
||||
try:
|
||||
from pykakasi import kakasi
|
||||
except Exception as e:
|
||||
if __name__ == "__main__":
|
||||
print(f"[FATAL] pykakasi import failed: {e}")
|
||||
input("Press Enter to exit...")
|
||||
sys.exit(1)
|
||||
raise
|
||||
|
||||
|
||||
# =========================
|
||||
# 設定
|
||||
# =========================
|
||||
|
||||
AUTO_LINK_SCORE = 95
|
||||
LOW_CONFIDENCE_SCORE = 80
|
||||
AMBIGUOUS_DIFF = 10
|
||||
LOW_CONFIDENCE_MAX_REMAINING = 3
|
||||
WEAK_MATCH_SCORE = 50
|
||||
|
||||
STATUS_MATCHED = "matched"
|
||||
STATUS_MATCHED_LOW_CONFIDENCE = "matched_low_confidence"
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_AMBIGUOUS = "ambiguous"
|
||||
STATUS_UNMATCHED = "unmatched"
|
||||
STATUS_UNKNOWN_MEMBER = "unknown_member"
|
||||
|
||||
ROLE_GUEST = "guest"
|
||||
ROLE_SELF = "self"
|
||||
ROLE_STAFF = "staff"
|
||||
|
||||
TEST_GUESTS = [
|
||||
"okada",
|
||||
"tanaka",
|
||||
"yamada",
|
||||
"messykenty",
|
||||
]
|
||||
|
||||
TEST_CURRENT_USERS = [
|
||||
"岡田",
|
||||
"ikada",
|
||||
"okada_",
|
||||
"OKADA",
|
||||
"tanaka!!!",
|
||||
"田中",
|
||||
"やまだ",
|
||||
"messy_kenty",
|
||||
"messy.kenty",
|
||||
"MESSYKENTY",
|
||||
"山田",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TomlMember:
|
||||
name: str
|
||||
role: str = ROLE_GUEST
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchResult:
|
||||
toml_name: str
|
||||
role: str
|
||||
status: str
|
||||
vrchat_name: Optional[str] = None
|
||||
score: Optional[float] = None
|
||||
second_score: Optional[float] = None
|
||||
reason: str = ""
|
||||
candidates: List[Tuple[str, float]] = field(default_factory=list)
|
||||
|
||||
|
||||
# =========================
|
||||
# ローマ字変換
|
||||
# =========================
|
||||
|
||||
kks = kakasi()
|
||||
|
||||
|
||||
def jp_to_romaji(text: str) -> str:
|
||||
try:
|
||||
converted = kks.convert(text)
|
||||
|
||||
result = []
|
||||
for item in converted:
|
||||
result.append(item["hepburn"])
|
||||
|
||||
return "".join(result)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] romaji convert failed: {text} : {e}")
|
||||
return text
|
||||
|
||||
|
||||
# =========================
|
||||
# 正規化
|
||||
# =========================
|
||||
|
||||
def normalize_name(name: str) -> str:
|
||||
"""
|
||||
正規化内容:
|
||||
- 全角半角統一
|
||||
- 小文字化
|
||||
- 日本語→ローマ字
|
||||
- 記号除去
|
||||
"""
|
||||
|
||||
# Unicode normalize
|
||||
name = unicodedata.normalize("NFKC", name)
|
||||
|
||||
# 小文字化
|
||||
name = name.lower()
|
||||
|
||||
# 日本語→ローマ字
|
||||
name = jp_to_romaji(name)
|
||||
|
||||
# 記号除去
|
||||
name = re.sub(r"[^a-z0-9]", "", name)
|
||||
|
||||
return name.strip()
|
||||
|
||||
|
||||
# =========================
|
||||
# 類似候補検索
|
||||
# =========================
|
||||
|
||||
def find_candidates(
|
||||
target_guest: str,
|
||||
current_users: List[str],
|
||||
) -> List[Tuple[str, float]]:
|
||||
|
||||
normalized_target = normalize_name(target_guest)
|
||||
|
||||
normalized_map = {}
|
||||
|
||||
for user in current_users:
|
||||
normalized_map[user] = normalize_name(user)
|
||||
|
||||
results = []
|
||||
|
||||
for original_name, normalized_name in normalized_map.items():
|
||||
|
||||
score = fuzz.ratio(
|
||||
normalized_target,
|
||||
normalized_name,
|
||||
)
|
||||
|
||||
results.append((original_name, score))
|
||||
|
||||
results.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _to_toml_member(member) -> TomlMember:
|
||||
if isinstance(member, TomlMember):
|
||||
return member
|
||||
|
||||
if isinstance(member, str):
|
||||
return TomlMember(name=member)
|
||||
|
||||
if isinstance(member, dict):
|
||||
return TomlMember(
|
||||
name=str(member.get("name", "")),
|
||||
role=str(member.get("role", ROLE_GUEST)),
|
||||
)
|
||||
|
||||
name = getattr(member, "name", "")
|
||||
role = getattr(member, "role", ROLE_GUEST)
|
||||
|
||||
return TomlMember(
|
||||
name=str(name),
|
||||
role=str(role),
|
||||
)
|
||||
|
||||
|
||||
def _make_candidates(
|
||||
toml_name: str,
|
||||
current_users: Iterable[str],
|
||||
) -> List[Tuple[str, float]]:
|
||||
normalized_toml = normalize_name(toml_name)
|
||||
results = []
|
||||
|
||||
for user in current_users:
|
||||
score = fuzz.ratio(
|
||||
normalized_toml,
|
||||
normalize_name(user),
|
||||
)
|
||||
results.append((user, score))
|
||||
|
||||
results.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _classify_remaining(
|
||||
member: TomlMember,
|
||||
current_users: Iterable[str],
|
||||
) -> MatchResult:
|
||||
candidates = _make_candidates(
|
||||
member.name,
|
||||
current_users,
|
||||
)
|
||||
|
||||
if not candidates:
|
||||
return MatchResult(
|
||||
toml_name=member.name,
|
||||
role=member.role,
|
||||
status=STATUS_UNMATCHED,
|
||||
reason="no_candidate",
|
||||
)
|
||||
|
||||
top_name, top_score = candidates[0]
|
||||
second_score = candidates[1][1] if len(candidates) >= 2 else 0
|
||||
diff = top_score - second_score
|
||||
|
||||
if top_score < WEAK_MATCH_SCORE:
|
||||
status = STATUS_UNMATCHED
|
||||
reason = "weak_candidate"
|
||||
elif diff < AMBIGUOUS_DIFF:
|
||||
status = STATUS_AMBIGUOUS
|
||||
reason = "close_candidates"
|
||||
else:
|
||||
status = STATUS_PENDING
|
||||
reason = "below_auto_link_threshold"
|
||||
|
||||
return MatchResult(
|
||||
toml_name=member.name,
|
||||
role=member.role,
|
||||
status=status,
|
||||
vrchat_name=top_name,
|
||||
score=top_score,
|
||||
second_score=second_score,
|
||||
reason=reason,
|
||||
candidates=candidates,
|
||||
)
|
||||
|
||||
|
||||
def _best_fuzzy_match(
|
||||
member: TomlMember,
|
||||
current_users: Iterable[str],
|
||||
min_score: float,
|
||||
) -> Optional[MatchResult]:
|
||||
candidates = _make_candidates(
|
||||
member.name,
|
||||
current_users,
|
||||
)
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
top_name, top_score = candidates[0]
|
||||
second_score = candidates[1][1] if len(candidates) >= 2 else 0
|
||||
|
||||
if (
|
||||
top_score >= min_score
|
||||
and top_score - second_score >= AMBIGUOUS_DIFF
|
||||
):
|
||||
return MatchResult(
|
||||
toml_name=member.name,
|
||||
role=member.role,
|
||||
status=STATUS_MATCHED,
|
||||
vrchat_name=top_name,
|
||||
score=top_score,
|
||||
second_score=second_score,
|
||||
reason="fuzzy",
|
||||
candidates=candidates,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _apply_fuzzy_round(
|
||||
remaining_members: Dict[str, TomlMember],
|
||||
remaining_users: set,
|
||||
min_score: float,
|
||||
status: str,
|
||||
reason: str,
|
||||
eligible_roles: Optional[set] = None,
|
||||
) -> List[MatchResult]:
|
||||
results = []
|
||||
|
||||
while True:
|
||||
possible_matches = []
|
||||
|
||||
for key, member in remaining_members.items():
|
||||
if (
|
||||
eligible_roles is not None
|
||||
and member.role not in eligible_roles
|
||||
):
|
||||
continue
|
||||
|
||||
match = _best_fuzzy_match(
|
||||
member,
|
||||
remaining_users,
|
||||
min_score,
|
||||
)
|
||||
|
||||
if match is None:
|
||||
continue
|
||||
|
||||
possible_matches.append((key, match))
|
||||
|
||||
if not possible_matches:
|
||||
break
|
||||
|
||||
possible_matches.sort(
|
||||
key=lambda x: (
|
||||
x[1].score if x[1].score is not None else 0,
|
||||
(x[1].score or 0) - (x[1].second_score or 0),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
key, match = possible_matches[0]
|
||||
match.status = status
|
||||
match.reason = reason
|
||||
|
||||
results.append(match)
|
||||
remaining_members.pop(key)
|
||||
remaining_users.remove(match.vrchat_name)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def match_username_sets(
|
||||
toml_members,
|
||||
current_users: List[str],
|
||||
) -> Tuple[List[MatchResult], List[str]]:
|
||||
"""
|
||||
TOML登録名とVRChat現在ユーザーを段階的に1対1照合する。
|
||||
|
||||
戻り値:
|
||||
- TOML側の照合結果
|
||||
- TOMLに紐づかなかったVRChat名
|
||||
"""
|
||||
|
||||
members = [_to_toml_member(member) for member in toml_members]
|
||||
remaining_members = {
|
||||
f"{idx}:{member.name}": member
|
||||
for idx, member in enumerate(members)
|
||||
}
|
||||
remaining_users = set(current_users)
|
||||
results = []
|
||||
|
||||
# 1. 完全一致: fuzzy判定せず即確定して候補から除外
|
||||
for key, member in list(remaining_members.items()):
|
||||
if member.name in remaining_users:
|
||||
results.append(
|
||||
MatchResult(
|
||||
toml_name=member.name,
|
||||
role=member.role,
|
||||
status=STATUS_MATCHED,
|
||||
vrchat_name=member.name,
|
||||
score=100,
|
||||
second_score=None,
|
||||
reason="exact",
|
||||
)
|
||||
)
|
||||
remaining_members.pop(key)
|
||||
remaining_users.remove(member.name)
|
||||
|
||||
# 2. 正規化一致: 一意に対応できるものだけを確定して除外
|
||||
normalized_members: Dict[str, List[str]] = {}
|
||||
normalized_users: Dict[str, List[str]] = {}
|
||||
|
||||
for key, member in remaining_members.items():
|
||||
normalized_members.setdefault(
|
||||
normalize_name(member.name),
|
||||
[],
|
||||
).append(key)
|
||||
|
||||
for user in remaining_users:
|
||||
normalized_users.setdefault(
|
||||
normalize_name(user),
|
||||
[],
|
||||
).append(user)
|
||||
|
||||
for normalized_name, member_keys in normalized_members.items():
|
||||
users = normalized_users.get(normalized_name, [])
|
||||
|
||||
if len(member_keys) != 1 or len(users) != 1:
|
||||
continue
|
||||
|
||||
key = member_keys[0]
|
||||
user = users[0]
|
||||
member = remaining_members[key]
|
||||
|
||||
results.append(
|
||||
MatchResult(
|
||||
toml_name=member.name,
|
||||
role=member.role,
|
||||
status=STATUS_MATCHED,
|
||||
vrchat_name=user,
|
||||
score=100,
|
||||
second_score=None,
|
||||
reason="normalized",
|
||||
)
|
||||
)
|
||||
remaining_members.pop(key)
|
||||
remaining_users.remove(user)
|
||||
|
||||
# 3. 高信頼fuzzy: 残った候補だけで、確定ごとに候補集合を縮める
|
||||
results.extend(
|
||||
_apply_fuzzy_round(
|
||||
remaining_members,
|
||||
remaining_users,
|
||||
AUTO_LINK_SCORE,
|
||||
STATUS_MATCHED,
|
||||
"high_confidence_fuzzy",
|
||||
)
|
||||
)
|
||||
|
||||
# 4. 低信頼guest再選別: guestのみ、候補集合が少ない場合だけ
|
||||
if len(remaining_members) <= LOW_CONFIDENCE_MAX_REMAINING:
|
||||
results.extend(
|
||||
_apply_fuzzy_round(
|
||||
remaining_members,
|
||||
remaining_users,
|
||||
LOW_CONFIDENCE_SCORE,
|
||||
STATUS_MATCHED_LOW_CONFIDENCE,
|
||||
"guest_low_confidence_fuzzy",
|
||||
eligible_roles={ROLE_GUEST},
|
||||
)
|
||||
)
|
||||
|
||||
# 5. 残りは無理に確定しない
|
||||
for member in remaining_members.values():
|
||||
results.append(
|
||||
_classify_remaining(
|
||||
member,
|
||||
remaining_users,
|
||||
)
|
||||
)
|
||||
|
||||
unknown_users = sorted(remaining_users)
|
||||
|
||||
return results, unknown_users
|
||||
|
||||
|
||||
# =========================
|
||||
# 判定
|
||||
# =========================
|
||||
|
||||
def evaluate_guest_match(
|
||||
guest_name: str,
|
||||
current_users: List[str],
|
||||
):
|
||||
|
||||
print("")
|
||||
print("=" * 60)
|
||||
print(f"[CHECK] guest={guest_name}")
|
||||
|
||||
normalized_guest = normalize_name(guest_name)
|
||||
|
||||
print(f"[INFO] normalized_guest={normalized_guest}")
|
||||
|
||||
candidates = find_candidates(
|
||||
guest_name,
|
||||
current_users,
|
||||
)
|
||||
|
||||
if not candidates:
|
||||
print("[ERROR] no candidates")
|
||||
return
|
||||
|
||||
print("")
|
||||
print("[CHECK] candidate list")
|
||||
|
||||
for idx, (name, score) in enumerate(candidates[:5], start=1):
|
||||
print(f" {idx}. {name:<20} score={score:.2f}")
|
||||
|
||||
top_name, top_score = candidates[0]
|
||||
|
||||
second_score = 0
|
||||
|
||||
if len(candidates) >= 2:
|
||||
second_score = candidates[1][1]
|
||||
|
||||
diff = top_score - second_score
|
||||
|
||||
print("")
|
||||
print(f"[INFO] top_score={top_score:.2f}")
|
||||
print(f"[INFO] second_score={second_score:.2f}")
|
||||
print(f"[INFO] diff={diff:.2f}")
|
||||
|
||||
# 自動確定
|
||||
if (
|
||||
top_score >= AUTO_LINK_SCORE
|
||||
and diff >= AMBIGUOUS_DIFF
|
||||
):
|
||||
print("")
|
||||
print("[INFO] AUTO LINK SAFE")
|
||||
print(f"[INFO] linked => {top_name}")
|
||||
return
|
||||
|
||||
# 曖昧
|
||||
print("")
|
||||
print("[WARN] ambiguous match")
|
||||
print("[WARN] auto link disabled")
|
||||
|
||||
print("")
|
||||
print("[ACTION] manual review required")
|
||||
|
||||
|
||||
# =========================
|
||||
# main
|
||||
# =========================
|
||||
|
||||
def main():
|
||||
|
||||
print("[CHECK] normalize preview")
|
||||
|
||||
for user in TEST_CURRENT_USERS:
|
||||
print(
|
||||
f"{user:<20}"
|
||||
f" => "
|
||||
f"{normalize_name(user)}"
|
||||
)
|
||||
|
||||
print("")
|
||||
print("[CHECK] username sync ordered matching test")
|
||||
|
||||
results, unknown_users = match_username_sets(
|
||||
TEST_GUESTS,
|
||||
TEST_CURRENT_USERS,
|
||||
)
|
||||
|
||||
for result in results:
|
||||
if result.vrchat_name is None:
|
||||
print(
|
||||
f"{result.toml_name:<20}"
|
||||
f" => "
|
||||
f"{result.status:<22}"
|
||||
f" reason={result.reason}"
|
||||
)
|
||||
continue
|
||||
|
||||
score_text = ""
|
||||
if result.score is not None:
|
||||
score_text = f" score={result.score:.2f}"
|
||||
|
||||
second_text = ""
|
||||
if result.second_score is not None:
|
||||
second_text = f" second={result.second_score:.2f}"
|
||||
|
||||
print(
|
||||
f"{result.toml_name:<20}"
|
||||
f" => "
|
||||
f"{result.vrchat_name:<20}"
|
||||
f" {result.status:<22}"
|
||||
f" reason={result.reason}"
|
||||
f"{score_text}"
|
||||
f"{second_text}"
|
||||
)
|
||||
|
||||
for user in unknown_users:
|
||||
print(
|
||||
f"{user:<20}"
|
||||
f" => "
|
||||
f"{STATUS_UNKNOWN_MEMBER}"
|
||||
)
|
||||
|
||||
print("")
|
||||
print("[INFO] completed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
print("[CHECK] start")
|
||||
|
||||
answer = input(
|
||||
"[ACTION] run fuzzy test? (yes/no): "
|
||||
).strip().lower()
|
||||
|
||||
if answer != "yes":
|
||||
print("[FATAL] cancelled")
|
||||
input("Press Enter to exit...")
|
||||
sys.exit(1)
|
||||
|
||||
main()
|
||||
@@ -4,18 +4,39 @@ import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"vrc_osc_go/internal/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var configPath string
|
||||
var mode string
|
||||
var noGUI bool
|
||||
flag.StringVar(&configPath, "config", "config/config.toml", "path to config.toml")
|
||||
flag.StringVar(&mode, "mode", "", "generate-json or extract-log")
|
||||
flag.BoolVar(&noGUI, "no-gui", false, "do not launch the GUI companion")
|
||||
flag.Parse()
|
||||
|
||||
if err := app.Run(configPath); err != nil {
|
||||
if mode == "" && !noGUI {
|
||||
launchGUI()
|
||||
}
|
||||
|
||||
if err := app.Run(configPath, mode); err != nil {
|
||||
log.SetOutput(os.Stderr)
|
||||
log.Fatalf("vrc_osc_go: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func launchGUI() {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
gui := filepath.Join(filepath.Dir(exe), "vrc_osc_gui.exe")
|
||||
if _, err := os.Stat(gui); err != nil {
|
||||
return
|
||||
}
|
||||
_ = exec.Command(gui).Start()
|
||||
}
|
||||
|
||||
14
cmd/vrc_osc_gui/main.go
Normal file
14
cmd/vrc_osc_gui/main.go
Normal file
@@ -0,0 +1,14 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"vrc_osc_go/internal/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = app.AppendRuntimeLog("GUI", "starting vrc_osc_gui.exe")
|
||||
log.Fatal(runNativeGUI())
|
||||
}
|
||||
216
cmd/vrc_osc_gui/tray_windows.go
Normal file
216
cmd/vrc_osc_gui/tray_windows.go
Normal file
@@ -0,0 +1,216 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"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 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")
|
||||
loadIcon := user32.NewProc("LoadIconW")
|
||||
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")
|
||||
icon, _, _ := loadIcon.Call(0, 32512) // IDI_APPLICATION
|
||||
|
||||
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
|
||||
}
|
||||
531
cmd/vrc_osc_gui/window_windows.go
Normal file
531
cmd/vrc_osc_gui/window_windows.go
Normal file
@@ -0,0 +1,531 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"vrc_osc_go/internal/app"
|
||||
)
|
||||
|
||||
const (
|
||||
wsOverlappedWindow = 0x00CF0000
|
||||
wsVisible = 0x10000000
|
||||
wsChild = 0x40000000
|
||||
wsBorder = 0x00800000
|
||||
esMultiline = 0x0004
|
||||
esReadonly = 0x0800
|
||||
esAutovscroll = 0x0040
|
||||
esAutohscroll = 0x0080
|
||||
wmCreateGUI = 0x0001
|
||||
wmDestroyGUI = 0x0002
|
||||
wmTimerGUI = 0x0113
|
||||
wmCommandGUI = 0x0111
|
||||
idStatus = 2001
|
||||
idLog = 2002
|
||||
timerRefresh = 1
|
||||
)
|
||||
|
||||
var joinLeaveLinePattern = regexp.MustCompile(`^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]\s+\[(join|leave)\]\s+(.+?)\s+\((\d+)\)$`)
|
||||
var runtimeTimePattern = regexp.MustCompile(`^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]`)
|
||||
|
||||
type guiApp struct {
|
||||
hwnd uintptr
|
||||
leftHwnd uintptr
|
||||
rightHwnd uintptr
|
||||
tabJoin uintptr
|
||||
tabDiscord uintptr
|
||||
tabTranslate uintptr
|
||||
activeTab string
|
||||
lastInstanceCount int
|
||||
}
|
||||
|
||||
type userState struct {
|
||||
Name string
|
||||
Present bool
|
||||
LastJoin time.Time
|
||||
LastLeave time.Time
|
||||
}
|
||||
|
||||
func runtimeDir() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "runtime"
|
||||
}
|
||||
return filepath.Join(filepath.Dir(exe), "runtime")
|
||||
}
|
||||
|
||||
func guiLog(text string) {
|
||||
_ = app.AppendRuntimeLog("GUI", text)
|
||||
}
|
||||
|
||||
func runNativeGUI() error {
|
||||
user32 := syscall.NewLazyDLL("user32.dll")
|
||||
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
||||
registerClass := user32.NewProc("RegisterClassW")
|
||||
createWindowEx := user32.NewProc("CreateWindowExW")
|
||||
defWindowProc := user32.NewProc("DefWindowProcW")
|
||||
showWindow := user32.NewProc("ShowWindow")
|
||||
updateWindow := user32.NewProc("UpdateWindow")
|
||||
getMessage := user32.NewProc("GetMessageW")
|
||||
translateMessage := user32.NewProc("TranslateMessage")
|
||||
dispatchMessage := user32.NewProc("DispatchMessageW")
|
||||
postQuitMessage := user32.NewProc("PostQuitMessage")
|
||||
setTimer := user32.NewProc("SetTimer")
|
||||
loadCursor := user32.NewProc("LoadCursorW")
|
||||
setWindowText := user32.NewProc("SetWindowTextW")
|
||||
getModuleHandle := kernel32.NewProc("GetModuleHandleW")
|
||||
loadIcon := user32.NewProc("LoadIconW")
|
||||
|
||||
type wndClass struct {
|
||||
style uint32
|
||||
lpfnWndProc uintptr
|
||||
cbClsExtra int32
|
||||
cbWndExtra int32
|
||||
hInstance uintptr
|
||||
hIcon uintptr
|
||||
hCursor uintptr
|
||||
hbrBackground uintptr
|
||||
lpszMenuName *uint16
|
||||
lpszClassName *uint16
|
||||
}
|
||||
type msg struct {
|
||||
hwnd uintptr
|
||||
message uint32
|
||||
wParam uintptr
|
||||
lParam uintptr
|
||||
time uint32
|
||||
pt struct{ X, Y int32 }
|
||||
}
|
||||
|
||||
hInstance, _, _ := getModuleHandle.Call(0)
|
||||
className, _ := syscall.UTF16PtrFromString("VRC_OSC_NATIVE_GUI")
|
||||
title, _ := syscall.UTF16PtrFromString("VRC OSC")
|
||||
cursor, _, _ := loadCursor.Call(0, 32512)
|
||||
icon, _, _ := loadIcon.Call(0, 32512)
|
||||
guiLog("stage=prepare window")
|
||||
|
||||
var app guiApp
|
||||
wndProc := syscall.NewCallback(func(hwnd uintptr, message uint32, wParam, lParam uintptr) uintptr {
|
||||
switch message {
|
||||
case wmCreateGUI:
|
||||
buttonClass, _ := syscall.UTF16PtrFromString("BUTTON")
|
||||
leftClass, _ := syscall.UTF16PtrFromString("EDIT")
|
||||
rightClass, _ := syscall.UTF16PtrFromString("EDIT")
|
||||
leftTitle, _ := syscall.UTF16PtrFromString("")
|
||||
rightTitle, _ := syscall.UTF16PtrFromString("")
|
||||
joinTitle, _ := syscall.UTF16PtrFromString("Join Log")
|
||||
discordTitle, _ := syscall.UTF16PtrFromString("Discord")
|
||||
translateTitle, _ := syscall.UTF16PtrFromString("Translate")
|
||||
app.tabJoin, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(joinTitle)), wsVisible|wsChild, 370, 20, 90, 28, hwnd, 3001, hInstance, 0)
|
||||
app.tabDiscord, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(discordTitle)), wsVisible|wsChild, 470, 20, 90, 28, hwnd, 3002, hInstance, 0)
|
||||
app.tabTranslate, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(translateTitle)), wsVisible|wsChild, 570, 20, 90, 28, hwnd, 3003, hInstance, 0)
|
||||
app.leftHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(leftClass)), uintptr(unsafe.Pointer(leftTitle)), wsVisible|wsChild|wsBorder|esMultiline|esReadonly|esAutovscroll|esAutohscroll, 20, 20, 330, 300, hwnd, idStatus, hInstance, 0)
|
||||
app.rightHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(rightClass)), uintptr(unsafe.Pointer(rightTitle)), wsVisible|wsChild|wsBorder|esMultiline|esReadonly|esAutovscroll|esAutohscroll, 370, 60, 330, 260, hwnd, idLog, hInstance, 0)
|
||||
app.activeTab = "join"
|
||||
setTimer.Call(hwnd, timerRefresh, 2000, 0)
|
||||
refreshGUI(setWindowText, &app)
|
||||
return 0
|
||||
case wmCommandGUI:
|
||||
switch uint16(wParam & 0xffff) {
|
||||
case 3001:
|
||||
app.activeTab = "join"
|
||||
case 3002:
|
||||
app.activeTab = "discord"
|
||||
case 3003:
|
||||
app.activeTab = "translate"
|
||||
}
|
||||
refreshGUI(setWindowText, &app)
|
||||
return 0
|
||||
case wmTimerGUI:
|
||||
refreshGUI(setWindowText, &app)
|
||||
return 0
|
||||
case wmDestroyGUI:
|
||||
postQuitMessage.Call(0)
|
||||
return 0
|
||||
}
|
||||
ret, _, _ := defWindowProc.Call(hwnd, uintptr(message), wParam, lParam)
|
||||
return ret
|
||||
})
|
||||
|
||||
guiLog("stage=register class")
|
||||
atom, _, regErr := registerClass.Call(uintptr(unsafe.Pointer(&wndClass{
|
||||
style: 0,
|
||||
lpfnWndProc: wndProc,
|
||||
hInstance: hInstance,
|
||||
hIcon: icon,
|
||||
hCursor: cursor,
|
||||
hbrBackground: 6,
|
||||
lpszClassName: className,
|
||||
})))
|
||||
if atom == 0 {
|
||||
guiLog(fmt.Sprintf("stage=register class failed err=%v", regErr))
|
||||
return fmt.Errorf("register class: %v", regErr)
|
||||
}
|
||||
|
||||
guiLog("stage=create window")
|
||||
app.hwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(title)), wsOverlappedWindow, 200, 120, 760, 380, 0, 0, hInstance, 0)
|
||||
if app.hwnd == 0 {
|
||||
guiLog("stage=create window failed")
|
||||
return fmt.Errorf("create window failed")
|
||||
}
|
||||
|
||||
guiLog("stage=show window")
|
||||
showWindow.Call(app.hwnd, 5)
|
||||
updateWindow.Call(app.hwnd)
|
||||
guiLog("stage=message loop")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp) {
|
||||
current, instanceCount := currentUsersFromJoinLeave()
|
||||
if instanceCount == 0 && app.lastInstanceCount > 0 {
|
||||
instanceCount = app.lastInstanceCount
|
||||
}
|
||||
if instanceCount > 0 {
|
||||
app.lastInstanceCount = instanceCount
|
||||
}
|
||||
if app.leftHwnd != 0 {
|
||||
t, _ := syscall.UTF16PtrFromString(formatGuestPane("Join Log", current))
|
||||
setWindowText.Call(app.leftHwnd, uintptr(unsafe.Pointer(t)))
|
||||
}
|
||||
if app.rightHwnd != 0 {
|
||||
t, _ := syscall.UTF16PtrFromString(formatRightPane(app.activeTab, readRuntimeSnapshot(), currentWorldLabel(), instanceCount))
|
||||
setWindowText.Call(app.rightHwnd, uintptr(unsafe.Pointer(t)))
|
||||
}
|
||||
}
|
||||
|
||||
func currentUsersFromJoinLeave() ([]userState, int) {
|
||||
since := currentWorldStartTime()
|
||||
users := map[string]*userState{}
|
||||
lastLoggedCount := 0
|
||||
for _, s := range readGuestSnapshot() {
|
||||
if !s.Present {
|
||||
continue
|
||||
}
|
||||
cp := s
|
||||
users[s.Name] = &userState{
|
||||
Name: s.Name,
|
||||
Present: true,
|
||||
LastJoin: cp.LastJoin,
|
||||
}
|
||||
}
|
||||
|
||||
p := filepath.Join(runtimeDir(), "join_leave.log")
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
out := make([]userState, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, *u)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Present != out[j].Present {
|
||||
return out[i].Present
|
||||
}
|
||||
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
||||
})
|
||||
return out, countPresent(out)
|
||||
}
|
||||
|
||||
for _, raw := range strings.Split(string(b), "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if c := parseJoinLeaveCount(line); c > 0 {
|
||||
lastLoggedCount = c
|
||||
}
|
||||
at, kind, name := parseJoinLeaveLine(line)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if !since.IsZero() && at.Before(since) {
|
||||
continue
|
||||
}
|
||||
u := users[name]
|
||||
if u == nil {
|
||||
u = &userState{Name: name}
|
||||
users[name] = u
|
||||
}
|
||||
switch kind {
|
||||
case "join":
|
||||
u.Present = true
|
||||
if at.After(u.LastJoin) {
|
||||
u.LastJoin = at
|
||||
}
|
||||
case "leave":
|
||||
u.Present = false
|
||||
if at.After(u.LastLeave) {
|
||||
u.LastLeave = at
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep any snapshot-marked present users that the join/leave delta can't rebuild.
|
||||
for _, snap := range readGuestSnapshot() {
|
||||
if !snap.Present {
|
||||
continue
|
||||
}
|
||||
u := users[snap.Name]
|
||||
if u == nil {
|
||||
users[snap.Name] = &userState{
|
||||
Name: snap.Name,
|
||||
Present: true,
|
||||
LastJoin: snap.LastJoin,
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !u.Present {
|
||||
u.Present = true
|
||||
if u.LastJoin.IsZero() {
|
||||
u.LastJoin = snap.LastJoin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]userState, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, *u)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Present != out[j].Present {
|
||||
return out[i].Present
|
||||
}
|
||||
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
||||
})
|
||||
present := countPresent(out)
|
||||
if lastLoggedCount > present {
|
||||
present = lastLoggedCount
|
||||
}
|
||||
return out, present
|
||||
}
|
||||
|
||||
func readGuestSnapshot() []app.GuestStatus {
|
||||
p := filepath.Join(runtimeDir(), "guest_snapshot.json")
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []app.GuestStatus
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func formatGuestPane(title string, items []userState) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(title)
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString(strings.Repeat("=", len(title)))
|
||||
b.WriteString("\r\n")
|
||||
if len(items) == 0 {
|
||||
b.WriteString("Current in room: 0")
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString("(none)")
|
||||
return b.String()
|
||||
}
|
||||
b.WriteString("Current in room: ")
|
||||
b.WriteString(strconv.Itoa(countPresent(items)))
|
||||
b.WriteString("\r\n\r\n")
|
||||
for _, item := range items {
|
||||
b.WriteString("・ ")
|
||||
b.WriteString(item.Name)
|
||||
if item.Present {
|
||||
b.WriteString(" [present]")
|
||||
} else {
|
||||
b.WriteString(" [gray]")
|
||||
if !item.LastLeave.IsZero() {
|
||||
b.WriteString(" ")
|
||||
b.WriteString(timeAgo(item.LastLeave))
|
||||
}
|
||||
}
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\r\n")
|
||||
}
|
||||
|
||||
func formatRightPane(tab string, state map[string]any, worldLabel string, currentUsers int) string {
|
||||
var b strings.Builder
|
||||
switch tab {
|
||||
case "discord":
|
||||
b.WriteString("Discord")
|
||||
case "translate":
|
||||
b.WriteString("Translate")
|
||||
default:
|
||||
b.WriteString("Current World")
|
||||
}
|
||||
b.WriteString("\r\n")
|
||||
titleLine := "Current World"
|
||||
switch tab {
|
||||
case "discord":
|
||||
titleLine = "Discord"
|
||||
case "translate":
|
||||
titleLine = "Translate"
|
||||
}
|
||||
b.WriteString(strings.Repeat("=", len(titleLine)))
|
||||
b.WriteString("\r\n")
|
||||
discordMuted, _ := state["discord_muted"].(bool)
|
||||
ocr, _ := state["ocr"].(string)
|
||||
translate, _ := state["translate"].(string)
|
||||
switch tab {
|
||||
case "discord":
|
||||
b.WriteString("Muted: ")
|
||||
b.WriteString(fmt.Sprintf("%v", discordMuted))
|
||||
b.WriteString("\r\nWorld: ")
|
||||
b.WriteString(worldLabel)
|
||||
b.WriteString("\r\nUsers: ")
|
||||
b.WriteString(fmt.Sprintf("%d", currentUsers))
|
||||
case "translate":
|
||||
b.WriteString("Translate: ")
|
||||
b.WriteString(translate)
|
||||
b.WriteString("\r\nOCR: ")
|
||||
b.WriteString(ocr)
|
||||
b.WriteString("\r\nWorld: ")
|
||||
b.WriteString(worldLabel)
|
||||
default:
|
||||
b.WriteString("World: ")
|
||||
b.WriteString(worldLabel)
|
||||
b.WriteString("\r\nUsers: ")
|
||||
b.WriteString(fmt.Sprintf("%d", currentUsers))
|
||||
b.WriteString("\r\nDiscord muted: ")
|
||||
b.WriteString(fmt.Sprintf("%v", discordMuted))
|
||||
b.WriteString("\r\nOCR: ")
|
||||
b.WriteString(ocr)
|
||||
b.WriteString("\r\nTranslate: ")
|
||||
b.WriteString(translate)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func currentWorldLabel() string {
|
||||
p := filepath.Join(runtimeDir(), "runtime.log")
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(string(b), "\n")
|
||||
var latest string
|
||||
for i := 0; i < len(lines); i++ {
|
||||
if !strings.Contains(lines[i], "VRC WORLD") {
|
||||
continue
|
||||
}
|
||||
if i+1 < len(lines) {
|
||||
latest = strings.TrimSpace(lines[i+1])
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
func readRuntimeSnapshot() map[string]any {
|
||||
p := filepath.Join(runtimeDir(), "state.json")
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(b, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func currentWorldStartTime() time.Time {
|
||||
p := filepath.Join(runtimeDir(), "runtime.log")
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
var latest time.Time
|
||||
for _, raw := range strings.Split(string(b), "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" || !strings.Contains(line, "VRC WORLD") {
|
||||
continue
|
||||
}
|
||||
if at, ok := parseRuntimeTime(line); ok && at.After(latest) {
|
||||
latest = at
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
func parseJoinLeaveLine(line string) (time.Time, string, string) {
|
||||
m := joinLeaveLinePattern.FindStringSubmatch(line)
|
||||
if len(m) != 4 {
|
||||
return time.Time{}, "", ""
|
||||
}
|
||||
at, err := time.Parse("2006-01-02 15:04:05", m[1])
|
||||
if err != nil {
|
||||
return time.Time{}, "", ""
|
||||
}
|
||||
return at, m[2], strings.TrimSpace(m[3])
|
||||
}
|
||||
|
||||
func parseJoinLeaveCount(line string) int {
|
||||
m := regexp.MustCompile(`\((\d+)\)$`).FindStringSubmatch(line)
|
||||
if len(m) != 2 {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.Atoi(m[1])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func parseRuntimeTime(line string) (time.Time, bool) {
|
||||
m := runtimeTimePattern.FindStringSubmatch(line)
|
||||
if len(m) != 2 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
at, err := time.Parse("2006-01-02 15:04:05", m[1])
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return at, true
|
||||
}
|
||||
|
||||
func countPresent(items []userState) int {
|
||||
n := 0
|
||||
for _, item := range items {
|
||||
if item.Present {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func timeAgo(at time.Time) string {
|
||||
if at.IsZero() {
|
||||
return ""
|
||||
}
|
||||
d := time.Since(at)
|
||||
if d < time.Minute {
|
||||
return "0分前"
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%d分前", int(d.Minutes()))
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%d時間前", int(d.Hours()))
|
||||
}
|
||||
return fmt.Sprintf("%d日前", int(d.Hours()/24))
|
||||
}
|
||||
75
cmd/vrc_osc_launcher/main.go
Normal file
75
cmd/vrc_osc_launcher/main.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"vrc_osc_go/internal/buildinfo"
|
||||
"vrc_osc_go/internal/common"
|
||||
"vrc_osc_go/internal/update"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var baseURL string
|
||||
var ownerRepo string
|
||||
var assetName string
|
||||
flag.StringVar(&baseURL, "base-url", "https://git.vrcworldtour.com", "Gitea base URL")
|
||||
flag.StringVar(&ownerRepo, "repo", "every_holiday/VRCWT-OSC", "owner/repo")
|
||||
flag.StringVar(&assetName, "asset", "vrc_osc-windows.zip", "release asset name")
|
||||
flag.Parse()
|
||||
|
||||
client := &update.Client{BaseURL: baseURL}
|
||||
mgr := &update.Manager{
|
||||
Client: client,
|
||||
OwnerRepo: ownerRepo,
|
||||
AssetName: assetName,
|
||||
CurrentLabel: buildinfo.Version,
|
||||
}
|
||||
if _, err := mgr.CheckAndUpdate(); err != nil {
|
||||
log.Printf("auto update skipped: %v", err)
|
||||
}
|
||||
base := runtimeBaseDir()
|
||||
exe := filepath.Join(base, "vrc_osc.exe")
|
||||
gui := filepath.Join(base, "vrc_osc_gui.exe")
|
||||
if _, err := os.Stat(exe); err != nil {
|
||||
showError("vrc_osc_launcher", "missing exe: "+err.Error())
|
||||
log.Fatalf("missing exe: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(gui); err == nil {
|
||||
_ = exec.Command(gui).Start()
|
||||
}
|
||||
args := append([]string{"--no-gui"}, os.Args[1:]...)
|
||||
if err := update.Launch(exe, args); err != nil {
|
||||
showError("vrc_osc_launcher", "launch failed: "+err.Error())
|
||||
log.Fatalf("launch failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
33
cmd/vrwt_tool/main.go
Normal file
33
cmd/vrwt_tool/main.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"vrc_osc_go/internal/consenttool"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var cfgPath string
|
||||
var mode string
|
||||
|
||||
flag.StringVar(&cfgPath, "config", "config/consent.toml", "path to consent tool config")
|
||||
flag.StringVar(&mode, "mode", "", "generate-json or extract-log")
|
||||
flag.Parse()
|
||||
|
||||
if mode == "" && flag.NArg() > 0 {
|
||||
mode = flag.Arg(0)
|
||||
}
|
||||
|
||||
if mode == "" {
|
||||
fmt.Fprintln(os.Stderr, "missing mode: generate-json or extract-log")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
if err := consenttool.Run(cfgPath, mode); err != nil {
|
||||
log.SetOutput(os.Stderr)
|
||||
log.Fatalf("vrwt_tool: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,75 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/config"
|
||||
"vrc_osc_go/internal/consenttool"
|
||||
"vrc_osc_go/internal/osc"
|
||||
)
|
||||
|
||||
func Run(configPath string) error {
|
||||
type discordMuteState struct {
|
||||
mu sync.Mutex
|
||||
muted bool
|
||||
buttonPressed bool
|
||||
lastAction time.Time
|
||||
}
|
||||
|
||||
func Run(configPath string, mode string) error {
|
||||
if mode != "" {
|
||||
return consenttool.Run(configPath, mode)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
InitGuestTracker(NewGuestTracker(cfg.VrcLog.GuestNames))
|
||||
log.Printf("vrc_osc_go starting osc=%s:%d config=%s", cfg.OSC.Host, cfg.OSC.Port, configPath)
|
||||
|
||||
server := osc.NewServer(cfg.OSC.Host, cfg.OSC.Port)
|
||||
discordState := &discordMuteState{muted: true}
|
||||
SetDiscordMuted(true)
|
||||
server.Map("DiscordSend", func(_ string, args []osc.Value) error {
|
||||
return fmt.Errorf("discord mute not implemented yet")
|
||||
log.Printf("received DiscordSend args=%+v", args)
|
||||
if err := handleDiscordSend(discordState, args); err != nil {
|
||||
log.Printf("discord mute failed: %v", err)
|
||||
return err
|
||||
}
|
||||
SetDiscordMuted(discordState.muted)
|
||||
return nil
|
||||
})
|
||||
server.Map("ocrEnabled", func(_ string, args []osc.Value) error {
|
||||
log.Printf("received ocrEnabled args=%+v", args)
|
||||
if err := runPythonOcrFromScreen(); err != nil {
|
||||
log.Printf("ocr failed: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- server.Serve() }()
|
||||
go func() {
|
||||
log.Printf("osc server listening on %s:%d", cfg.OSC.Host, cfg.OSC.Port)
|
||||
errCh <- server.Serve()
|
||||
}()
|
||||
go watchVrchatLog()
|
||||
startSelfMonitor(cfg, discordState)
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
select {
|
||||
case err := <-errCh:
|
||||
log.Printf("osc server stopped: %v", err)
|
||||
return err
|
||||
case <-sigCh:
|
||||
log.Printf("shutdown signal received")
|
||||
server.Close()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
89
internal/app/discord_mute.go
Normal file
89
internal/app/discord_mute.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/osc"
|
||||
)
|
||||
|
||||
const discordDebounce = 300 * time.Millisecond
|
||||
|
||||
func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("OSC args is empty")
|
||||
}
|
||||
isPressed, err := toBool(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if !state.lastAction.IsZero() && now.Sub(state.lastAction) < discordDebounce {
|
||||
return nil
|
||||
}
|
||||
|
||||
if isPressed && !state.buttonPressed {
|
||||
state.buttonPressed = true
|
||||
if state.muted {
|
||||
log.Printf("ACTION VRChat button pressed -> Discord unmute hotkey")
|
||||
if err := pressDiscordMuteHotkey(); err != nil {
|
||||
return err
|
||||
}
|
||||
state.muted = false
|
||||
SetDiscordMuted(state.muted)
|
||||
state.lastAction = now
|
||||
} else {
|
||||
log.Printf("INFO already unmuted")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !isPressed && state.buttonPressed {
|
||||
state.buttonPressed = false
|
||||
if !state.muted {
|
||||
log.Printf("ACTION VRChat button released -> Discord mute hotkey")
|
||||
if err := pressDiscordMuteHotkey(); err != nil {
|
||||
return err
|
||||
}
|
||||
state.muted = true
|
||||
SetDiscordMuted(state.muted)
|
||||
state.lastAction = now
|
||||
} else {
|
||||
log.Printf("INFO already muted")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func toBool(v osc.Value) (bool, error) {
|
||||
switch v.Type {
|
||||
case 'T':
|
||||
return true, nil
|
||||
case 'F':
|
||||
return false, nil
|
||||
case 'i':
|
||||
return v.Int != 0, nil
|
||||
case 'f':
|
||||
return v.Float != 0, nil
|
||||
case 's':
|
||||
switch strings.ToLower(strings.TrimSpace(v.Str)) {
|
||||
case "true", "1", "on", "yes":
|
||||
return true, nil
|
||||
case "false", "0", "off", "no":
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("unsupported OSC value: %q", v.Str)
|
||||
}
|
||||
case 0:
|
||||
return false, fmt.Errorf("unsupported OSC value")
|
||||
default:
|
||||
return false, fmt.Errorf("unsupported OSC type: %q", v.Type)
|
||||
}
|
||||
}
|
||||
136
internal/app/guest_status.go
Normal file
136
internal/app/guest_status.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/common"
|
||||
)
|
||||
|
||||
type GuestStatus struct {
|
||||
Name string `json:"name"`
|
||||
Present bool `json:"present"`
|
||||
LastJoin time.Time `json:"last_join"`
|
||||
LastLeave time.Time `json:"last_leave"`
|
||||
AbsentFor string `json:"absent_for"`
|
||||
}
|
||||
|
||||
type GuestTracker struct {
|
||||
mu sync.RWMutex
|
||||
current string
|
||||
items map[string]*GuestStatus
|
||||
}
|
||||
|
||||
func NewGuestTracker(names []string) *GuestTracker {
|
||||
items := make(map[string]*GuestStatus, len(names))
|
||||
for _, name := range names {
|
||||
items[name] = &GuestStatus{Name: name}
|
||||
}
|
||||
return &GuestTracker{items: items}
|
||||
}
|
||||
|
||||
func (t *GuestTracker) SetCurrentInstance(label string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.current = label
|
||||
}
|
||||
|
||||
func (t *GuestTracker) CurrentInstance() string {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return t.current
|
||||
}
|
||||
|
||||
func (t *GuestTracker) MarkJoin(name string, at time.Time) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
s := t.ensure(name)
|
||||
s.Present = true
|
||||
s.LastJoin = at
|
||||
s.AbsentFor = ""
|
||||
_ = t.persistLocked()
|
||||
}
|
||||
|
||||
func (t *GuestTracker) MarkLeave(name string, at time.Time) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
s := t.ensure(name)
|
||||
s.Present = false
|
||||
s.LastLeave = at
|
||||
if !s.LastJoin.IsZero() {
|
||||
s.AbsentFor = humanSince(at)
|
||||
}
|
||||
_ = t.persistLocked()
|
||||
}
|
||||
|
||||
func (t *GuestTracker) Snapshot(now time.Time) []GuestStatus {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
out := make([]GuestStatus, 0, len(t.items))
|
||||
for _, s := range t.items {
|
||||
cp := *s
|
||||
if !cp.Present && !cp.LastLeave.IsZero() {
|
||||
cp.AbsentFor = humanSince(cp.LastLeave, now)
|
||||
}
|
||||
out = append(out, cp)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *GuestTracker) persistLocked() error {
|
||||
dir := filepath.Join(common.RootDir(), "runtime")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
snapshot := make([]GuestStatus, 0, len(t.items))
|
||||
for _, s := range t.items {
|
||||
cp := *s
|
||||
snapshot = append(snapshot, cp)
|
||||
}
|
||||
f, err := os.Create(filepath.Join(dir, "guest_snapshot.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
enc := json.NewEncoder(f)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(snapshot)
|
||||
}
|
||||
|
||||
func (t *GuestTracker) ensure(name string) *GuestStatus {
|
||||
if s, ok := t.items[name]; ok {
|
||||
return s
|
||||
}
|
||||
s := &GuestStatus{Name: name}
|
||||
t.items[name] = s
|
||||
return s
|
||||
}
|
||||
|
||||
func humanSince(at time.Time, now ...time.Time) string {
|
||||
ref := time.Now()
|
||||
if len(now) > 0 {
|
||||
ref = now[0]
|
||||
}
|
||||
if at.IsZero() {
|
||||
return ""
|
||||
}
|
||||
d := ref.Sub(at)
|
||||
if d < time.Minute {
|
||||
return "1分未満"
|
||||
}
|
||||
m := int(d.Minutes())
|
||||
if m < 60 {
|
||||
return fmt.Sprintf("%d分前", m)
|
||||
}
|
||||
h := m / 60
|
||||
if h < 24 {
|
||||
return fmt.Sprintf("%d時間前", h)
|
||||
}
|
||||
return fmt.Sprintf("%d日前", h/24)
|
||||
}
|
||||
30
internal/app/ocr.go
Normal file
30
internal/app/ocr.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func runPythonOcrFromScreen() error {
|
||||
script := `
|
||||
import sys
|
||||
from pathlib import Path
|
||||
root = Path(r"C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC")
|
||||
sys.path.insert(0, str(root / "src"))
|
||||
from ocr.ocr_actions import runOcrFromScreen
|
||||
runOcrFromScreen()
|
||||
`
|
||||
cmd := exec.Command("python", "-c", script)
|
||||
cmd.Dir = `C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC`
|
||||
out, err := cmd.CombinedOutput()
|
||||
if len(out) > 0 {
|
||||
log.Printf("ocr output: %s", string(out))
|
||||
SetOCRText(string(out))
|
||||
SetTranslateText("翻訳: 未実装")
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("ocr failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
52
internal/app/runtime.go
Normal file
52
internal/app/runtime.go
Normal file
@@ -0,0 +1,52 @@
|
||||
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 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)"
|
||||
}
|
||||
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
|
||||
}
|
||||
69
internal/app/self_monitor.go
Normal file
69
internal/app/self_monitor.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/config"
|
||||
"vrc_osc_go/internal/osc"
|
||||
)
|
||||
|
||||
var (
|
||||
selfJoinPattern = regexp.MustCompile(`OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
|
||||
selfLeftPattern = regexp.MustCompile(`OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
|
||||
)
|
||||
|
||||
func startSelfMonitor(cfg *config.Config, state *discordMuteState) {
|
||||
if cfg.VrcLog.SelfName == "" {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
lastState := ""
|
||||
for {
|
||||
path, err := findLatestVrchatLog()
|
||||
if err != nil {
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
text, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
nextState := extractLatestSelfLogState(string(text), cfg.VrcLog.SelfName)
|
||||
if nextState != "" && nextState != lastState {
|
||||
if nextState == "joined" {
|
||||
if err := handleDiscordSend(state, []osc.Value{{Type: 'T', Bool: true}}); err != nil {
|
||||
log.Printf("self monitor discord mute failed: %v", err)
|
||||
}
|
||||
} else if nextState == "left" {
|
||||
if err := handleDiscordSend(state, []osc.Value{{Type: 'F', Bool: false}}); err != nil {
|
||||
log.Printf("self monitor discord mute failed: %v", err)
|
||||
}
|
||||
}
|
||||
lastState = nextState
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func extractLatestSelfLogState(text, selfName string) string {
|
||||
if selfName == "" {
|
||||
return ""
|
||||
}
|
||||
state := ""
|
||||
for _, rawLine := range strings.Split(text, "\n") {
|
||||
if m := selfJoinPattern.FindStringSubmatch(rawLine); len(m) == 2 && strings.TrimSpace(m[1]) == selfName {
|
||||
state = "joined"
|
||||
continue
|
||||
}
|
||||
if m := selfLeftPattern.FindStringSubmatch(rawLine); len(m) == 2 && strings.TrimSpace(m[1]) == selfName {
|
||||
state = "left"
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
89
internal/app/state.go
Normal file
89
internal/app/state.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/common"
|
||||
)
|
||||
|
||||
type RuntimeState struct {
|
||||
mu sync.RWMutex
|
||||
DiscordMuted bool
|
||||
LastOCRText string
|
||||
LastTranslate string
|
||||
CurrentWorld string
|
||||
}
|
||||
|
||||
var runtimeState = &RuntimeState{}
|
||||
var guestTracker *GuestTracker
|
||||
|
||||
func SetGuestTracker(t *GuestTracker) { guestTracker = t }
|
||||
func InitGuestTracker(t *GuestTracker) {
|
||||
guestTracker = t
|
||||
if guestTracker != nil {
|
||||
guestTracker.mu.Lock()
|
||||
_ = guestTracker.persistLocked()
|
||||
guestTracker.mu.Unlock()
|
||||
}
|
||||
}
|
||||
func GetGuestTracker() *GuestTracker { return guestTracker }
|
||||
|
||||
func SetDiscordMuted(v bool) {
|
||||
runtimeState.mu.Lock()
|
||||
runtimeState.DiscordMuted = v
|
||||
runtimeState.mu.Unlock()
|
||||
_ = persistRuntimeState()
|
||||
}
|
||||
|
||||
func SetOCRText(v string) {
|
||||
runtimeState.mu.Lock()
|
||||
runtimeState.LastOCRText = v
|
||||
runtimeState.mu.Unlock()
|
||||
_ = persistRuntimeState()
|
||||
}
|
||||
|
||||
func SetTranslateText(v string) {
|
||||
runtimeState.mu.Lock()
|
||||
runtimeState.LastTranslate = v
|
||||
runtimeState.mu.Unlock()
|
||||
_ = persistRuntimeState()
|
||||
}
|
||||
|
||||
func SetCurrentWorld(v string) {
|
||||
runtimeState.mu.Lock()
|
||||
runtimeState.CurrentWorld = v
|
||||
runtimeState.mu.Unlock()
|
||||
_ = persistRuntimeState()
|
||||
}
|
||||
|
||||
func SnapshotRuntime() RuntimeState {
|
||||
runtimeState.mu.RLock()
|
||||
defer runtimeState.mu.RUnlock()
|
||||
return *runtimeState
|
||||
}
|
||||
|
||||
func persistRuntimeState() error {
|
||||
s := SnapshotRuntime()
|
||||
dir := filepath.Join(common.RootDir(), "runtime")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Create(filepath.Join(dir, "state.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
enc := json.NewEncoder(f)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(map[string]any{
|
||||
"discord_muted": s.DiscordMuted,
|
||||
"ocr": s.LastOCRText,
|
||||
"translate": s.LastTranslate,
|
||||
"world": s.CurrentWorld,
|
||||
"updated_at": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
345
internal/app/vrc_log.go
Normal file
345
internal/app/vrc_log.go
Normal file
@@ -0,0 +1,345 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
joinPattern = regexp.MustCompile(`OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
|
||||
leftPattern = regexp.MustCompile(`OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
|
||||
worldIdPattern = regexp.MustCompile(`worldId=(wrld_[0-9a-fA-F-]+)`)
|
||||
instanceIdPattern = regexp.MustCompile(`instanceId=([^,}\s]+)`)
|
||||
worldNamePattern = regexp.MustCompile(`worldName=([^,}]+)`)
|
||||
worldLocationPattern = regexp.MustCompile(`worldId=(wrld_[0-9a-fA-F-]+):([^\s,\]\)\"']+)`)
|
||||
worldPattern = regexp.MustCompile(`(wrld_[0-9a-fA-F-]+(?::[^\s\]\)\"']+)?)`)
|
||||
enteringRoomPattern = regexp.MustCompile(`\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$`)
|
||||
)
|
||||
|
||||
type vrcLogState struct {
|
||||
location string
|
||||
worldID string
|
||||
instanceID string
|
||||
worldName string
|
||||
pendingWorldName string
|
||||
presentSet map[string]struct{}
|
||||
initialized bool
|
||||
}
|
||||
|
||||
func getVrchatLogDir() (string, error) {
|
||||
userProfile := os.Getenv("USERPROFILE")
|
||||
if userProfile == "" {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
return filepath.Join(userProfile, "AppData", "LocalLow", "VRChat", "VRChat"), nil
|
||||
}
|
||||
|
||||
func findLatestVrchatLog() (string, error) {
|
||||
dir, err := getVrchatLogDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var latest string
|
||||
var latestMod time.Time
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".log") && !strings.HasSuffix(strings.ToLower(name), ".txt") {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.ModTime().After(latestMod) {
|
||||
latestMod = info.ModTime()
|
||||
latest = filepath.Join(dir, name)
|
||||
}
|
||||
}
|
||||
if latest == "" {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
return latest, nil
|
||||
}
|
||||
|
||||
func watchVrchatLog() {
|
||||
lastPath := ""
|
||||
lastSize := int64(0)
|
||||
state := &vrcLogState{
|
||||
presentSet: map[string]struct{}{},
|
||||
}
|
||||
for {
|
||||
path, err := findLatestVrchatLog()
|
||||
if err != nil {
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
if path != lastPath {
|
||||
log.Printf("vrchat log watching %s", path)
|
||||
lastPath = path
|
||||
lastSize = 0
|
||||
if err := scanExistingVrchatLog(path, state); err != nil {
|
||||
log.Printf("vrchat log initial scan failed: %v", err)
|
||||
}
|
||||
if info2, err := os.Stat(path); err == nil {
|
||||
lastSize = info2.Size()
|
||||
} else {
|
||||
lastSize = info.Size()
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
if info.Size() < lastSize {
|
||||
lastSize = 0
|
||||
}
|
||||
if info.Size() > lastSize {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
_, _ = f.Seek(lastSize, 0)
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if err := appendRuntimeLog("VRC LOG", line); err != nil {
|
||||
log.Printf("append runtime log failed: %v", err)
|
||||
}
|
||||
if changed, worldLabel := updateWorldState(state, line); changed {
|
||||
log.Printf("world changed: %s", worldLabel)
|
||||
state.presentSet = map[string]struct{}{}
|
||||
SetCurrentWorld(worldLabel)
|
||||
if tracker := GetGuestTracker(); tracker != nil {
|
||||
tracker.SetCurrentInstance(worldLabel)
|
||||
}
|
||||
_ = appendRuntimeLog("VRC WORLD", worldLabel)
|
||||
}
|
||||
at := extractLineTime(line)
|
||||
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||
name := strings.TrimSpace(m[1])
|
||||
state.presentSet[name] = struct{}{}
|
||||
if tracker := GetGuestTracker(); tracker != nil {
|
||||
tracker.MarkJoin(name, at)
|
||||
}
|
||||
out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet))
|
||||
log.Print(out)
|
||||
_ = appendJoinLeaveLog(out)
|
||||
continue
|
||||
}
|
||||
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||
name := strings.TrimSpace(m[1])
|
||||
delete(state.presentSet, name)
|
||||
if tracker := GetGuestTracker(); tracker != nil {
|
||||
tracker.MarkLeave(name, at)
|
||||
}
|
||||
out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet))
|
||||
log.Print(out)
|
||||
_ = appendJoinLeaveLog(out)
|
||||
continue
|
||||
}
|
||||
}
|
||||
lastSize = info.Size()
|
||||
_ = f.Close()
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
state.presentSet = map[string]struct{}{}
|
||||
state.location = ""
|
||||
state.worldID = ""
|
||||
state.instanceID = ""
|
||||
state.worldName = ""
|
||||
state.pendingWorldName = ""
|
||||
state.initialized = false
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if changed, _ := updateWorldState(state, line); changed {
|
||||
state.presentSet = map[string]struct{}{}
|
||||
}
|
||||
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||
name := strings.TrimSpace(m[1])
|
||||
state.presentSet[name] = struct{}{}
|
||||
if tracker := GetGuestTracker(); tracker != nil {
|
||||
tracker.MarkJoin(name, extractLineTime(line))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||
name := strings.TrimSpace(m[1])
|
||||
delete(state.presentSet, name)
|
||||
if tracker := GetGuestTracker(); tracker != nil {
|
||||
tracker.MarkLeave(name, extractLineTime(line))
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
state.initialized = true
|
||||
if state.worldName != "" {
|
||||
log.Printf("world changed: %s", state.worldName)
|
||||
SetCurrentWorld(state.worldName)
|
||||
if tracker := GetGuestTracker(); tracker != nil {
|
||||
tracker.SetCurrentInstance(state.worldName)
|
||||
}
|
||||
_ = appendRuntimeLog("VRC WORLD", state.worldName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateWorldState(state *vrcLogState, line string) (bool, string) {
|
||||
worldID := ""
|
||||
instanceID := ""
|
||||
worldName := ""
|
||||
roomTitle := ""
|
||||
|
||||
if m := enteringRoomPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||
roomTitle = strings.TrimSpace(m[1])
|
||||
state.pendingWorldName = roomTitle
|
||||
}
|
||||
if m := worldLocationPattern.FindStringSubmatch(line); len(m) == 3 {
|
||||
worldID = strings.TrimSpace(m[1])
|
||||
instanceID = strings.TrimSpace(m[2])
|
||||
}
|
||||
|
||||
if m := worldIdPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||
worldID = strings.TrimSpace(m[1])
|
||||
}
|
||||
if m := instanceIdPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||
instanceID = strings.TrimSpace(m[1])
|
||||
}
|
||||
if m := worldNamePattern.FindStringSubmatch(line); len(m) == 2 {
|
||||
worldName = strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
if m := enteringRoomPattern.FindStringSubmatch(line); len(m) == 2 && worldID == "" {
|
||||
if worldMatch := worldPattern.FindStringSubmatch(m[1]); len(worldMatch) == 2 {
|
||||
worldID = worldMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
nextLocation := ""
|
||||
if worldID != "" {
|
||||
nextLocation = worldID
|
||||
if instanceID != "" {
|
||||
nextLocation = worldID + ":" + instanceID
|
||||
}
|
||||
}
|
||||
|
||||
if nextLocation != "" && nextLocation != state.location {
|
||||
state.location = nextLocation
|
||||
state.worldID = worldID
|
||||
state.instanceID = instanceID
|
||||
if worldName != "" {
|
||||
state.worldName = worldName
|
||||
} else if shouldUseRoomTitlePreferLatest(roomTitle, state.worldName) {
|
||||
state.worldName = roomTitle
|
||||
}
|
||||
label := state.worldName
|
||||
if label == "" {
|
||||
label = state.worldID
|
||||
}
|
||||
if label == "" {
|
||||
label = nextLocation
|
||||
}
|
||||
if state.initialized {
|
||||
log.Printf("world changed: %s", label)
|
||||
_ = appendRuntimeLog("VRC WORLD", label)
|
||||
}
|
||||
return true, label
|
||||
}
|
||||
|
||||
if worldName != "" {
|
||||
state.worldName = worldName
|
||||
state.pendingWorldName = ""
|
||||
return false, ""
|
||||
}
|
||||
if shouldUseRoomTitlePreferLatest(roomTitle, state.worldName) {
|
||||
state.worldName = roomTitle
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func shouldUseRoomTitle(roomTitle, currentWorldName string) bool {
|
||||
if roomTitle == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(roomTitle, "Home Location") {
|
||||
return false
|
||||
}
|
||||
if roomTitle == "Holiday-Cottage" && currentWorldName != "" && currentWorldName != roomTitle {
|
||||
return false
|
||||
}
|
||||
if currentWorldName == "" {
|
||||
return true
|
||||
}
|
||||
if roomTitle == currentWorldName {
|
||||
return false
|
||||
}
|
||||
if len(roomTitle) > len(currentWorldName) {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(roomTitle, "「") || strings.Contains(roomTitle, "」") || strings.Contains(roomTitle, "[") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shouldUseRoomTitlePreferLatest(roomTitle, currentWorldName string) bool {
|
||||
if roomTitle == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(roomTitle, "Home Location") {
|
||||
return false
|
||||
}
|
||||
if currentWorldName == "" {
|
||||
return true
|
||||
}
|
||||
if roomTitle == currentWorldName {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func extractLineTime(line string) time.Time {
|
||||
if len(line) < 19 {
|
||||
return time.Now()
|
||||
}
|
||||
t, err := time.ParseInLocation("2006.01.02 15:04:05", line[:19], time.Local)
|
||||
if err == nil {
|
||||
return t
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
6
internal/buildinfo/buildinfo.go
Normal file
6
internal/buildinfo/buildinfo.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package buildinfo
|
||||
|
||||
var (
|
||||
Version = "dev"
|
||||
BuildTime = "unknown"
|
||||
)
|
||||
16
internal/common/project_paths.go
Normal file
16
internal/common/project_paths.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func RuntimeBaseDir() string {
|
||||
exe, err := os.Executable()
|
||||
if err == nil {
|
||||
return filepath.Dir(exe)
|
||||
}
|
||||
return "."
|
||||
}
|
||||
|
||||
func RootDir() string { return RuntimeBaseDir() }
|
||||
@@ -3,12 +3,16 @@ package config
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"vrc_osc_go/internal/common"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
OSC OSCConfig
|
||||
OSC OSCConfig
|
||||
VrcLog VrcLogConfig
|
||||
}
|
||||
|
||||
type OSCConfig struct {
|
||||
@@ -16,8 +20,24 @@ type OSCConfig struct {
|
||||
Port int
|
||||
}
|
||||
|
||||
type VrcLogConfig struct {
|
||||
SelfName string
|
||||
GuestNames []string
|
||||
GuestFile string
|
||||
StaffNames []string
|
||||
MissingCount int
|
||||
LogPatterns []string
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
cfg := &Config{OSC: OSCConfig{Host: "127.0.0.1", Port: 9001}}
|
||||
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
|
||||
@@ -28,6 +48,7 @@ func Load(path string) (*Config, error) {
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
line = strings.TrimPrefix(line, "\ufeff")
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
@@ -42,7 +63,8 @@ func Load(path string) (*Config, error) {
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.TrimSpace(parts[1])
|
||||
val = strings.Trim(val, "\"'")
|
||||
if section == "osc" {
|
||||
switch section {
|
||||
case "osc":
|
||||
switch key {
|
||||
case "host":
|
||||
cfg.OSC.Host = val
|
||||
@@ -51,8 +73,74 @@ func Load(path string) (*Config, error) {
|
||||
cfg.OSC.Port = n
|
||||
}
|
||||
}
|
||||
case "self":
|
||||
if key == "name" {
|
||||
cfg.VrcLog.SelfName = val
|
||||
}
|
||||
case "notice":
|
||||
if key == "missing_count" {
|
||||
if n, err := strconv.Atoi(val); err == nil {
|
||||
cfg.VrcLog.MissingCount = n
|
||||
}
|
||||
}
|
||||
case "vrc_log":
|
||||
if key == "patterns" {
|
||||
cfg.VrcLog.LogPatterns = parseList(val)
|
||||
}
|
||||
case "staff":
|
||||
if key == "names" {
|
||||
cfg.VrcLog.StaffNames = parseList(val)
|
||||
}
|
||||
case "guest":
|
||||
switch key {
|
||||
case "names":
|
||||
cfg.VrcLog.GuestNames = parseList(val)
|
||||
case "file":
|
||||
cfg.VrcLog.GuestFile = val
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.VrcLog.GuestFile != "" {
|
||||
if !filepath.IsAbs(cfg.VrcLog.GuestFile) {
|
||||
cfg.VrcLog.GuestFile = filepath.Join(common.RootDir(), cfg.VrcLog.GuestFile)
|
||||
}
|
||||
if names, err := loadLines(cfg.VrcLog.GuestFile); err == nil {
|
||||
cfg.VrcLog.GuestNames = names
|
||||
}
|
||||
}
|
||||
return cfg, scanner.Err()
|
||||
}
|
||||
|
||||
func parseList(value string) []string {
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.TrimPrefix(value, "[")
|
||||
value = strings.TrimSuffix(value, "]")
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(strings.Trim(part, "\"'"))
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loadLines(path string) ([]string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
out := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "\ufeff"))
|
||||
if line != "" && !strings.HasPrefix(line, "#") {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
144
internal/consenttool/config.go
Normal file
144
internal/consenttool/config.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package consenttool
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Consent ConsentConfig
|
||||
Log LogConfig
|
||||
}
|
||||
|
||||
type ConsentConfig struct {
|
||||
InputCSV string
|
||||
OutputJSON string
|
||||
DisplayFields []string
|
||||
ConsentFields []string
|
||||
ConsentValues []string
|
||||
NormalizeCase bool
|
||||
AllowDuplicates bool
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
InputPath string
|
||||
OutputPath string
|
||||
IncludeRegex string
|
||||
KeepNonMatch bool
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
cfg := &Config{
|
||||
Consent: ConsentConfig{
|
||||
InputCSV: "config/consent.csv",
|
||||
OutputJSON: "config/consent_list.json",
|
||||
DisplayFields: []string{"display_name", "vrchat_name", "name"},
|
||||
ConsentFields: []string{"consent", "agree", "agreed", "checked"},
|
||||
ConsentValues: []string{"1", "true", "yes", "y", "on", "checked"},
|
||||
NormalizeCase: false,
|
||||
AllowDuplicates: false,
|
||||
},
|
||||
Log: LogConfig{
|
||||
InputPath: "runtime/output_log.txt",
|
||||
OutputPath: "runtime/research_log.txt",
|
||||
IncludeRegex: `\[[A-Z0-9:_ -]+\]`,
|
||||
KeepNonMatch: false,
|
||||
},
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var section string
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
line = strings.TrimPrefix(line, "\ufeff")
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
section = strings.Trim(line, "[]")
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.TrimSpace(parts[1])
|
||||
val = strings.Trim(val, "\"'")
|
||||
|
||||
switch section {
|
||||
case "consent":
|
||||
switch key {
|
||||
case "input_csv":
|
||||
cfg.Consent.InputCSV = val
|
||||
case "output_json":
|
||||
cfg.Consent.OutputJSON = val
|
||||
case "display_fields":
|
||||
cfg.Consent.DisplayFields = parseList(val)
|
||||
case "consent_fields":
|
||||
cfg.Consent.ConsentFields = parseList(val)
|
||||
case "consent_values":
|
||||
cfg.Consent.ConsentValues = parseList(val)
|
||||
case "normalize_case":
|
||||
cfg.Consent.NormalizeCase = parseBool(val)
|
||||
case "allow_duplicates":
|
||||
cfg.Consent.AllowDuplicates = parseBool(val)
|
||||
}
|
||||
case "log":
|
||||
switch key {
|
||||
case "input_path":
|
||||
cfg.Log.InputPath = val
|
||||
case "output_path":
|
||||
cfg.Log.OutputPath = val
|
||||
case "include_regex":
|
||||
cfg.Log.IncludeRegex = val
|
||||
case "keep_non_match":
|
||||
cfg.Log.KeepNonMatch = parseBool(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cfg, scanner.Err()
|
||||
}
|
||||
|
||||
func parseList(value string) []string {
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.TrimPrefix(value, "[")
|
||||
value = strings.TrimSuffix(value, "]")
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
items := strings.Split(value, ",")
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
item = strings.TrimSpace(item)
|
||||
item = strings.Trim(item, "\"'")
|
||||
if item != "" {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseBool(value string) bool {
|
||||
n, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
return err == nil && n
|
||||
}
|
||||
|
||||
func resolvePath(baseDir, value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if filepath.IsAbs(value) {
|
||||
return value
|
||||
}
|
||||
return filepath.Clean(filepath.Join(baseDir, value))
|
||||
}
|
||||
183
internal/consenttool/tool.go
Normal file
183
internal/consenttool/tool.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package consenttool
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Run(configPath string, mode string) error {
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
baseDir := filepath.Dir(configPath)
|
||||
|
||||
switch mode {
|
||||
case "generate-json":
|
||||
return generateJSON(cfg, baseDir)
|
||||
case "extract-log":
|
||||
return extractLog(cfg, baseDir)
|
||||
default:
|
||||
return fmt.Errorf("unknown mode %q", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func generateJSON(cfg *Config, baseDir string) error {
|
||||
inputPath := resolvePath(baseDir, cfg.Consent.InputCSV)
|
||||
outputPath := resolvePath(baseDir, cfg.Consent.OutputJSON)
|
||||
|
||||
rows, err := readCSVRows(inputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
names := collectConsentedNames(rows, cfg.Consent)
|
||||
body, err := json.MarshalIndent(names, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(outputPath, append(body, '\n'), 0o644)
|
||||
}
|
||||
|
||||
func extractLog(cfg *Config, baseDir string) error {
|
||||
inputPath := resolvePath(baseDir, cfg.Log.InputPath)
|
||||
outputPath := resolvePath(baseDir, cfg.Log.OutputPath)
|
||||
|
||||
data, err := os.ReadFile(inputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
re, err := regexp.Compile(cfg.Log.IncludeRegex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var kept []string
|
||||
for _, line := range strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") {
|
||||
if re.MatchString(line) {
|
||||
kept = append(kept, line)
|
||||
continue
|
||||
}
|
||||
if cfg.Log.KeepNonMatch {
|
||||
kept = append(kept, line)
|
||||
}
|
||||
}
|
||||
|
||||
body := strings.Join(kept, "\n")
|
||||
if body != "" {
|
||||
body += "\n"
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(outputPath, []byte(body), 0o644)
|
||||
}
|
||||
|
||||
func readCSVRows(path string) ([]map[string]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
r := csv.NewReader(file)
|
||||
headers, err := r.Read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, header := range headers {
|
||||
headers[i] = strings.TrimPrefix(strings.TrimSpace(header), "\ufeff")
|
||||
}
|
||||
|
||||
var rows []map[string]string
|
||||
for {
|
||||
record, err := r.Read()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
row := make(map[string]string, len(headers))
|
||||
for i, header := range headers {
|
||||
if i < len(record) {
|
||||
row[strings.TrimSpace(header)] = strings.TrimPrefix(strings.TrimSpace(record[i]), "\ufeff")
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func collectConsentedNames(rows []map[string]string, cfg ConsentConfig) []string {
|
||||
displayFields := cfg.DisplayFields
|
||||
if len(displayFields) == 0 {
|
||||
displayFields = []string{"display_name", "vrchat_name", "name"}
|
||||
}
|
||||
consentFields := cfg.ConsentFields
|
||||
if len(consentFields) == 0 {
|
||||
consentFields = []string{"consent", "agree", "agreed", "checked"}
|
||||
}
|
||||
accepted := make(map[string]struct{})
|
||||
var names []string
|
||||
|
||||
for _, row := range rows {
|
||||
if !rowIsConsented(row, consentFields, cfg.ConsentValues) {
|
||||
continue
|
||||
}
|
||||
name := firstNonEmpty(row, displayFields)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
key := name
|
||||
if cfg.NormalizeCase {
|
||||
key = strings.ToLower(key)
|
||||
}
|
||||
if !cfg.AllowDuplicates {
|
||||
if _, ok := accepted[key]; ok {
|
||||
continue
|
||||
}
|
||||
accepted[key] = struct{}{}
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func rowIsConsented(row map[string]string, fields []string, values []string) bool {
|
||||
if len(values) == 0 {
|
||||
values = []string{"1", "true", "yes", "y", "on", "checked"}
|
||||
}
|
||||
allowed := make(map[string]struct{}, len(values))
|
||||
for _, v := range values {
|
||||
allowed[strings.ToLower(strings.TrimSpace(v))] = struct{}{}
|
||||
}
|
||||
for _, field := range fields {
|
||||
if v, ok := row[field]; ok {
|
||||
if _, ok := allowed[strings.ToLower(strings.TrimSpace(v))]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func firstNonEmpty(row map[string]string, fields []string) string {
|
||||
for _, field := range fields {
|
||||
if v := strings.TrimSpace(row[field]); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
44
internal/consenttool/tool_test.go
Normal file
44
internal/consenttool/tool_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package consenttool
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCollectConsentedNames(t *testing.T) {
|
||||
rows := []map[string]string{
|
||||
{"display_name": "Alice", "consent": "true"},
|
||||
{"display_name": "Bob", "consent": "false"},
|
||||
{"display_name": "Alice", "consent": "yes"},
|
||||
}
|
||||
|
||||
names := collectConsentedNames(rows, ConsentConfig{})
|
||||
if len(names) != 1 || names[0] != "Alice" {
|
||||
t.Fatalf("unexpected names: %#v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLog(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := &Config{
|
||||
Log: LogConfig{
|
||||
InputPath: "input.log",
|
||||
OutputPath: "out.log",
|
||||
IncludeRegex: `ID:[0-9]+`,
|
||||
},
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "input.log"), []byte("x\nID:1 hello\nnope\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
if err := extractLog(cfg, dir); err != nil {
|
||||
t.Fatalf("extractLog: %v", err)
|
||||
}
|
||||
out, err := os.ReadFile(filepath.Join(dir, "out.log"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if string(out) != "ID:1 hello\n" {
|
||||
t.Fatalf("unexpected output: %q", string(out))
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package osc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -33,7 +35,12 @@ func NewServer(host string, port int) *Server {
|
||||
}
|
||||
|
||||
func (s *Server) Map(param string, h Handler) { s.handlers["/avatar/parameters/"+param] = h }
|
||||
func (s *Server) Close() error { if s.conn != nil { return s.conn.Close() }; return nil }
|
||||
func (s *Server) Close() error {
|
||||
if s.conn != nil {
|
||||
return s.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Serve() error {
|
||||
addr, err := net.ResolveUDPAddr("udp", s.addr)
|
||||
@@ -65,13 +72,70 @@ func (s *Server) Serve() error {
|
||||
}
|
||||
|
||||
func parseMessage(b []byte) (string, []Value, error) {
|
||||
parts := bytes.SplitN(b, []byte{0}, 2)
|
||||
if len(parts) == 0 {
|
||||
return "", nil, fmt.Errorf("invalid osc")
|
||||
address, offset, err := parsePaddedString(b, 0)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
address := string(parts[0])
|
||||
if !strings.HasPrefix(address, "/") {
|
||||
return "", nil, fmt.Errorf("invalid address")
|
||||
}
|
||||
return address, nil, nil
|
||||
if offset >= len(b) {
|
||||
return address, nil, nil
|
||||
}
|
||||
typetags, offset, err := parsePaddedString(b, offset)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if !strings.HasPrefix(typetags, ",") {
|
||||
return address, nil, nil
|
||||
}
|
||||
var values []Value
|
||||
for _, tag := range typetags[1:] {
|
||||
switch tag {
|
||||
case 'i':
|
||||
if offset+4 > len(b) {
|
||||
return "", nil, fmt.Errorf("invalid int")
|
||||
}
|
||||
values = append(values, Value{Type: 'i', Int: int32(binary.BigEndian.Uint32(b[offset : offset+4]))})
|
||||
offset += 4
|
||||
case 'f':
|
||||
if offset+4 > len(b) {
|
||||
return "", nil, fmt.Errorf("invalid float")
|
||||
}
|
||||
bits := binary.BigEndian.Uint32(b[offset : offset+4])
|
||||
values = append(values, Value{Type: 'f', Float: math.Float32frombits(bits)})
|
||||
offset += 4
|
||||
case 's':
|
||||
s, next, err := parsePaddedString(b, offset)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
values = append(values, Value{Type: 's', Str: s})
|
||||
offset = next
|
||||
case 'T':
|
||||
values = append(values, Value{Type: 'T', Bool: true})
|
||||
case 'F':
|
||||
values = append(values, Value{Type: 'F', Bool: false})
|
||||
}
|
||||
}
|
||||
return address, values, nil
|
||||
}
|
||||
|
||||
func parsePaddedString(b []byte, offset int) (string, int, error) {
|
||||
if offset >= len(b) {
|
||||
return "", offset, fmt.Errorf("invalid osc string")
|
||||
}
|
||||
end := bytes.IndexByte(b[offset:], 0)
|
||||
if end < 0 {
|
||||
return "", offset, fmt.Errorf("invalid osc string")
|
||||
}
|
||||
s := string(b[offset : offset+end])
|
||||
next := offset + end + 1
|
||||
for next%4 != 0 {
|
||||
next++
|
||||
}
|
||||
if next > len(b) {
|
||||
next = len(b)
|
||||
}
|
||||
return s, next, nil
|
||||
}
|
||||
|
||||
106
internal/update/manager.go
Normal file
106
internal/update/manager.go
Normal 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
125
internal/update/release.go
Normal 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}
|
||||
}
|
||||
Reference in New Issue
Block a user