Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23d5fcfcda | ||
|
|
329bcd0c50 | ||
|
|
93a0251082 | ||
|
|
6add92ef40 | ||
|
|
63700d9d29 | ||
|
|
ab9551c241 | ||
|
|
b6421958af | ||
|
|
cbd8911c25 |
@@ -35,8 +35,8 @@ jobs:
|
|||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
VERSION="${GITHUB_REF_NAME}"
|
VERSION="${GITHUB_REF_NAME}"
|
||||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc.exe ./cmd/vrc_osc
|
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-H windowsgui -X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc.exe ./cmd/vrc_osc
|
||||||
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc_launcher.exe ./cmd/vrc_osc_launcher
|
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-H windowsgui -X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc_launcher.exe ./cmd/vrc_osc_launcher
|
||||||
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrwt_tool.exe ./cmd/vrwt_tool
|
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrwt_tool.exe ./cmd/vrwt_tool
|
||||||
|
|
||||||
- name: Package executables
|
- name: Package executables
|
||||||
|
|||||||
33
EXE.md
Normal file
33
EXE.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Windows Release Packaging
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Release の zip は Gitea Actions で作ります。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc.exe ./cmd\vrc_osc
|
||||||
|
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_launcher.exe ./cmd\vrc_osc_launcher
|
||||||
|
go build -o .\dist\vrwt_tool.exe ./cmd\vrwt_tool
|
||||||
|
```
|
||||||
|
|
||||||
|
## 起動
|
||||||
|
|
||||||
|
通常は `vrc_osc_launcher.exe` を起動してください。
|
||||||
|
|
||||||
|
- 起動時に最新 Release をチェック
|
||||||
|
- 新しい版があれば自動更新
|
||||||
|
- 更新後に本体を起動
|
||||||
|
|
||||||
|
`vrc_osc.exe` を直接起動した場合は更新チェックしません。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
Release zip を展開したフォルダに以下を置きます。
|
||||||
|
|
||||||
|
- `vrc_osc.exe`
|
||||||
|
- `vrc_osc_launcher.exe`
|
||||||
|
- `vrwt_tool.exe`
|
||||||
|
- `config\`
|
||||||
|
- `runtime\`
|
||||||
|
|
||||||
|
`runtime\` は起動時に作られます。
|
||||||
132
README.md
132
README.md
@@ -1,123 +1,73 @@
|
|||||||
# VRC_OSC
|
# VRC_OSC
|
||||||
|
|
||||||
VRC_OSC は、VRChat の状態を見て Discord やログ連携を行うためのツールです。
|
VRChat のログ監視、Discord ミュート連携、OCR、同意リスト生成をまとめたツールです。
|
||||||
|
|
||||||
一番の使い方は、VRChat の `ExMenu` から操作することです。
|
## 主な機能
|
||||||
`ExMenu` は VRChat のメニュー画面に出る、追加の操作メニューです。
|
|
||||||
|
|
||||||
## できること
|
- VRChat の入退出ログを監視して `join / leave` を出力
|
||||||
|
- Discord のミュート切り替え
|
||||||
- VRChat のログを見て Discord に通知する
|
- OCR で画面文字を取得
|
||||||
- Discord のミュートを VRChat 側の操作に合わせて切り替える
|
- 同意フォーム用の JSON 生成とログ抽出
|
||||||
- VRChat 画面を読み取って OCR する
|
- Gitea Release からの自動更新
|
||||||
- OCR した文字を翻訳してコピーする
|
|
||||||
|
|
||||||
## 画面イメージ
|
|
||||||
|
|
||||||
### ExMenu
|
|
||||||
|
|
||||||
VRChat のメニュー画面から、このツールの機能を操作します。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||

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

|
|
||||||
|
|
||||||
## 使い方
|
|
||||||
|
|
||||||
1. 必要な設定ファイルを用意します。
|
|
||||||
2. ツールを起動します。
|
|
||||||
3. VRChat を開きます。
|
|
||||||
4. VRChat の `ExMenu` から必要な機能を選びます。
|
|
||||||
|
|
||||||
### Discord のミュート連動
|
|
||||||
|
|
||||||
VRChat 側で特定の操作が行われたときに、Discord のミュート状態を切り替えます。
|
|
||||||
通話中に VRChat を使うとき、Discord の音声操作を手で切り替える手間を減らすための機能です。
|
|
||||||
|
|
||||||
### OCR と翻訳
|
|
||||||
|
|
||||||
VRChat 画面の文字を読み取って、必要に応じて翻訳します。
|
|
||||||
見た文字や翻訳結果はコピーできるので、内容をそのまま使いやすくなります。
|
|
||||||
|
|
||||||
## 起動方法
|
## 起動方法
|
||||||
|
|
||||||
PowerShell から起動する場合:
|
配布 zip を展開したら、まず `vrc_osc_launcher.exe` を起動してください。
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
bin\vrc_osc.ps1
|
vrc_osc_launcher.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
Python から直接起動する場合:
|
launcher は起動時に最新 Release を確認します。新しい版があれば自動で更新してから本体を起動します。
|
||||||
|
|
||||||
|
本体を直接起動することもできます。
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
python src\app.py
|
vrc_osc.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
## 事前準備
|
ただし、この場合は自動更新は走りません。
|
||||||
|
|
||||||
依存関係を入れます。
|
## 配布物
|
||||||
|
|
||||||
```powershell
|
Release の zip には次のファイルが入ります。
|
||||||
python -m pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
## 設定ファイル
|
- `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/config.toml`
|
||||||
- `config/secrets.toml`
|
- `config/secrets.toml`
|
||||||
- `config/guests.txt`
|
- `config/guests.txt`
|
||||||
|
|
||||||
サンプルはそれぞれ `*.example.*` を使って作成できます。
|
最初は各 `*.example.*` をコピーして使ってください。
|
||||||
|
|
||||||
## 初期セットアップ
|
## Release 更新
|
||||||
|
|
||||||
最初は、`config/` のサンプルファイルを実ファイルにしてから中身を埋めます。
|
自動更新は Gitea の Release を参照します。
|
||||||
|
|
||||||
1. `config/config.example.toml` を `config/config.toml` にコピーします。
|
- 更新元: `https://git.vrcworldtour.com/every_holiday/VRCWT-OSC/releases`
|
||||||
2. `config/secrets.example.toml` を `config/secrets.toml` にコピーします。
|
- 対象 asset: `vrc_osc-windows.zip`
|
||||||
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 行追加します。
|
|
||||||
|
|
||||||
`guests.txt` は、例として次のように書きます。
|
## 開発時のビルド
|
||||||
|
|
||||||
```text
|
```powershell
|
||||||
GuestUserA
|
go test ./...
|
||||||
GuestUserB
|
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc.exe ./cmd/vrc_osc
|
||||||
YourNameHere
|
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`
|
- `runtime/runtime.log`
|
||||||
- Discord に通知を送るための Webhook URL
|
- `runtime/join_leave.log`
|
||||||
- `vrchat.username`
|
- `runtime/latest_ocr.png`
|
||||||
- VRChat のアカウント名
|
- `runtime/latest_screenshot.png`
|
||||||
- `vrchat.password`
|
|
||||||
- VRChat のパスワード
|
|
||||||
- `staff.names`
|
|
||||||
- スタッフとして扱う名前
|
|
||||||
- `guest.file`
|
|
||||||
- guest 名簿のファイル
|
|
||||||
|
|
||||||
## 補足
|
|
||||||
|
|
||||||
この README は、まず使い方が分かることを優先して簡単にまとめています。
|
|
||||||
細かい内部仕様が必要な場合は、ソースコードと `config/*.example.*` を見てください。
|
|
||||||
|
|
||||||
## 詳しい資料
|
|
||||||
|
|
||||||
- [設計書](doc/design.md)
|
|
||||||
- [シーケンス図](doc/sequence.md)
|
|
||||||
- [ワークフロー](doc/workflow.md)
|
|
||||||
|
|||||||
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 |
@@ -4,6 +4,8 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"vrc_osc_go/internal/app"
|
"vrc_osc_go/internal/app"
|
||||||
)
|
)
|
||||||
@@ -11,12 +13,30 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
var configPath string
|
var configPath string
|
||||||
var mode string
|
var mode string
|
||||||
|
var noGUI bool
|
||||||
flag.StringVar(&configPath, "config", "config/config.toml", "path to config.toml")
|
flag.StringVar(&configPath, "config", "config/config.toml", "path to config.toml")
|
||||||
flag.StringVar(&mode, "mode", "", "generate-json or extract-log")
|
flag.StringVar(&mode, "mode", "", "generate-json or extract-log")
|
||||||
|
flag.BoolVar(&noGUI, "no-gui", false, "do not launch the GUI companion")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
if mode == "" && !noGUI {
|
||||||
|
launchGUI()
|
||||||
|
}
|
||||||
|
|
||||||
if err := app.Run(configPath, mode); err != nil {
|
if err := app.Run(configPath, mode); err != nil {
|
||||||
log.SetOutput(os.Stderr)
|
log.SetOutput(os.Stderr)
|
||||||
log.Fatalf("vrc_osc_go: %v", err)
|
log.Fatalf("vrc_osc_go: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,16 +29,19 @@ func Run(configPath string, mode string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
log.Printf("vrc_osc_go starting osc=%s:%d config=%s", cfg.OSC.Host, cfg.OSC.Port, configPath)
|
||||||
|
|
||||||
server := osc.NewServer(cfg.OSC.Host, cfg.OSC.Port)
|
server := osc.NewServer(cfg.OSC.Host, cfg.OSC.Port)
|
||||||
discordState := &discordMuteState{muted: true}
|
discordState := &discordMuteState{muted: true}
|
||||||
|
SetDiscordMuted(true)
|
||||||
server.Map("DiscordSend", func(_ string, args []osc.Value) error {
|
server.Map("DiscordSend", func(_ string, args []osc.Value) error {
|
||||||
log.Printf("received DiscordSend args=%+v", args)
|
log.Printf("received DiscordSend args=%+v", args)
|
||||||
if err := handleDiscordSend(discordState, args); err != nil {
|
if err := handleDiscordSend(discordState, args); err != nil {
|
||||||
log.Printf("discord mute failed: %v", err)
|
log.Printf("discord mute failed: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
SetDiscordMuted(discordState.muted)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
server.Map("ocrEnabled", func(_ string, args []osc.Value) error {
|
server.Map("ocrEnabled", func(_ string, args []osc.Value) error {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -9,13 +8,68 @@ import (
|
|||||||
|
|
||||||
func pressDiscordMuteHotkey() error {
|
func pressDiscordMuteHotkey() error {
|
||||||
script := `
|
script := `
|
||||||
$p = Get-Process | Where-Object { $_.MainWindowTitle -match 'Discord' } | Select-Object -First 1
|
$ErrorActionPreference = "Stop"
|
||||||
if ($null -eq $p) { exit 2 }
|
|
||||||
[void][System.Runtime.InteropServices.Marshal]::GetLastWin32Error()
|
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
|
$wshell = New-Object -ComObject WScript.Shell
|
||||||
$null = $wshell.AppActivate($p.Id)
|
Start-Sleep -Milliseconds 200
|
||||||
Start-Sleep -Milliseconds 150
|
|
||||||
$wshell.SendKeys('^+m')
|
$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)
|
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script)
|
||||||
@@ -28,9 +82,3 @@ $wshell.SendKeys('^+m')
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func pressDiscordMuteHotkeyLegacy() error {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
_ = buf
|
|
||||||
return pressDiscordMuteHotkey()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
state.muted = false
|
state.muted = false
|
||||||
|
SetDiscordMuted(state.muted)
|
||||||
state.lastAction = now
|
state.lastAction = now
|
||||||
} else {
|
} else {
|
||||||
log.Printf("INFO already unmuted")
|
log.Printf("INFO already unmuted")
|
||||||
@@ -51,11 +52,11 @@ func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
state.muted = true
|
state.muted = true
|
||||||
|
SetDiscordMuted(state.muted)
|
||||||
state.lastAction = now
|
state.lastAction = now
|
||||||
} else {
|
} else {
|
||||||
log.Printf("INFO already muted")
|
log.Printf("INFO already muted")
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ runOcrFromScreen()
|
|||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if len(out) > 0 {
|
if len(out) > 0 {
|
||||||
log.Printf("ocr output: %s", string(out))
|
log.Printf("ocr output: %s", string(out))
|
||||||
|
SetOCRText(string(out))
|
||||||
|
SetTranslateText("翻訳: 未実装")
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ocr failed: %w", err)
|
return fmt.Errorf("ocr failed: %w", err)
|
||||||
|
|||||||
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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -128,11 +128,19 @@ func watchVrchatLog() {
|
|||||||
if changed, worldLabel := updateWorldState(state, line); changed {
|
if changed, worldLabel := updateWorldState(state, line); changed {
|
||||||
log.Printf("world changed: %s", worldLabel)
|
log.Printf("world changed: %s", worldLabel)
|
||||||
state.presentSet = map[string]struct{}{}
|
state.presentSet = map[string]struct{}{}
|
||||||
|
SetCurrentWorld(worldLabel)
|
||||||
|
if tracker := GetGuestTracker(); tracker != nil {
|
||||||
|
tracker.SetCurrentInstance(worldLabel)
|
||||||
|
}
|
||||||
_ = appendRuntimeLog("VRC WORLD", worldLabel)
|
_ = appendRuntimeLog("VRC WORLD", worldLabel)
|
||||||
}
|
}
|
||||||
|
at := extractLineTime(line)
|
||||||
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
|
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||||
name := strings.TrimSpace(m[1])
|
name := strings.TrimSpace(m[1])
|
||||||
state.presentSet[name] = struct{}{}
|
state.presentSet[name] = struct{}{}
|
||||||
|
if tracker := GetGuestTracker(); tracker != nil {
|
||||||
|
tracker.MarkJoin(name, at)
|
||||||
|
}
|
||||||
out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet))
|
out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet))
|
||||||
log.Print(out)
|
log.Print(out)
|
||||||
_ = appendJoinLeaveLog(out)
|
_ = appendJoinLeaveLog(out)
|
||||||
@@ -141,6 +149,9 @@ func watchVrchatLog() {
|
|||||||
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
|
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||||
name := strings.TrimSpace(m[1])
|
name := strings.TrimSpace(m[1])
|
||||||
delete(state.presentSet, name)
|
delete(state.presentSet, name)
|
||||||
|
if tracker := GetGuestTracker(); tracker != nil {
|
||||||
|
tracker.MarkLeave(name, at)
|
||||||
|
}
|
||||||
out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet))
|
out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet))
|
||||||
log.Print(out)
|
log.Print(out)
|
||||||
_ = appendJoinLeaveLog(out)
|
_ = appendJoinLeaveLog(out)
|
||||||
@@ -178,11 +189,17 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
|||||||
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
|
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||||
name := strings.TrimSpace(m[1])
|
name := strings.TrimSpace(m[1])
|
||||||
state.presentSet[name] = struct{}{}
|
state.presentSet[name] = struct{}{}
|
||||||
|
if tracker := GetGuestTracker(); tracker != nil {
|
||||||
|
tracker.MarkJoin(name, extractLineTime(line))
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
|
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||||
name := strings.TrimSpace(m[1])
|
name := strings.TrimSpace(m[1])
|
||||||
delete(state.presentSet, name)
|
delete(state.presentSet, name)
|
||||||
|
if tracker := GetGuestTracker(); tracker != nil {
|
||||||
|
tracker.MarkLeave(name, extractLineTime(line))
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,6 +209,10 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
|||||||
state.initialized = true
|
state.initialized = true
|
||||||
if state.worldName != "" {
|
if state.worldName != "" {
|
||||||
log.Printf("world changed: %s", state.worldName)
|
log.Printf("world changed: %s", state.worldName)
|
||||||
|
SetCurrentWorld(state.worldName)
|
||||||
|
if tracker := GetGuestTracker(); tracker != nil {
|
||||||
|
tracker.SetCurrentInstance(state.worldName)
|
||||||
|
}
|
||||||
_ = appendRuntimeLog("VRC WORLD", state.worldName)
|
_ = appendRuntimeLog("VRC WORLD", state.worldName)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -311,3 +332,14 @@ func shouldUseRoomTitlePreferLatest(roomTitle, currentWorldName string) bool {
|
|||||||
}
|
}
|
||||||
return true
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type OSCConfig struct {
|
|||||||
type VrcLogConfig struct {
|
type VrcLogConfig struct {
|
||||||
SelfName string
|
SelfName string
|
||||||
GuestNames []string
|
GuestNames []string
|
||||||
|
GuestFile string
|
||||||
StaffNames []string
|
StaffNames []string
|
||||||
MissingCount int
|
MissingCount int
|
||||||
LogPatterns []string
|
LogPatterns []string
|
||||||
@@ -91,11 +92,22 @@ func Load(path string) (*Config, error) {
|
|||||||
cfg.VrcLog.StaffNames = parseList(val)
|
cfg.VrcLog.StaffNames = parseList(val)
|
||||||
}
|
}
|
||||||
case "guest":
|
case "guest":
|
||||||
if key == "names" {
|
switch key {
|
||||||
|
case "names":
|
||||||
cfg.VrcLog.GuestNames = parseList(val)
|
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()
|
return cfg, scanner.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,3 +128,19 @@ func parseList(value string) []string {
|
|||||||
}
|
}
|
||||||
return out
|
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
|
||||||
|
}
|
||||||
|
|||||||
6
build_exe.ps1
Normal file
6
build_exe.ps1
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
Set-Location $ScriptDir
|
||||||
|
|
||||||
|
python -m PyInstaller .\vrc_osc.spec --noconfirm --clean
|
||||||
21
cmd/vrc_osc/main.go
Normal file
21
cmd/vrc_osc/main.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"vrc_osc_go/internal/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var configPath string
|
||||||
|
flag.StringVar(&configPath, "config", "config/config.toml", "path to config.toml")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if err := app.Run(configPath); err != nil {
|
||||||
|
log.SetOutput(os.Stderr)
|
||||||
|
log.Fatalf("vrc_osc_go: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
56
cmd/vrc_osc_launcher/main.go
Normal file
56
cmd/vrc_osc_launcher/main.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"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 {
|
||||||
|
showError("vrc_osc_launcher", "missing exe: "+err.Error())
|
||||||
|
log.Fatalf("missing exe: %v", err)
|
||||||
|
}
|
||||||
|
if err := update.Launch(exe, os.Args[1:]); 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(syscall.StringToUTF16Ptr(message)),
|
||||||
|
uintptr(syscall.StringToUTF16Ptr(title)),
|
||||||
|
uintptr(mbOK|mbIconError),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,16 @@ file = "config/guests.txt"
|
|||||||
[notice]
|
[notice]
|
||||||
missing_count = 0
|
missing_count = 0
|
||||||
|
|
||||||
|
[vrc_log]
|
||||||
|
patterns = [
|
||||||
|
"OnPlayerJoined",
|
||||||
|
"OnPlayerLeft",
|
||||||
|
"Entering Room",
|
||||||
|
"Joining or Creating Room",
|
||||||
|
"worldId=",
|
||||||
|
"wrld_",
|
||||||
|
]
|
||||||
|
|
||||||
[ocr.crop]
|
[ocr.crop]
|
||||||
left = 0.05
|
left = 0.05
|
||||||
top = 0.35
|
top = 0.35
|
||||||
|
|||||||
37
internal/app/app.go
Normal file
37
internal/app/app.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"vrc_osc_go/internal/config"
|
||||||
|
"vrc_osc_go/internal/osc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run(configPath string) error {
|
||||||
|
cfg, err := config.Load(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
server := osc.NewServer(cfg.OSC.Host, cfg.OSC.Port)
|
||||||
|
server.Map("DiscordSend", func(_ string, args []osc.Value) error {
|
||||||
|
return fmt.Errorf("discord mute not implemented yet")
|
||||||
|
})
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() { errCh <- server.Serve() }()
|
||||||
|
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
return err
|
||||||
|
case <-sigCh:
|
||||||
|
server.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
122
internal/app/discord.go
Normal file
122
internal/app/discord.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pressDiscordMuteHotkey() error {
|
||||||
|
script := `
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
Add-Type @"
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public static class Win32 {
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
public struct RECT {
|
||||||
|
public int Left;
|
||||||
|
public int Top;
|
||||||
|
public int Right;
|
||||||
|
public int Bottom;
|
||||||
|
}
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern bool SetCursorPos(int X, int Y);
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo);
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
|
||||||
|
function Click-WindowCenter($p) {
|
||||||
|
if ($null -eq $p) { return }
|
||||||
|
$hWnd = [IntPtr]$p.MainWindowHandle
|
||||||
|
if ($hWnd -eq [IntPtr]::Zero) { return }
|
||||||
|
|
||||||
|
$rect = New-Object 'Win32+RECT'
|
||||||
|
if (-not [Win32]::GetWindowRect($hWnd, [ref]$rect)) { return }
|
||||||
|
|
||||||
|
$x = [int](($rect.Left + $rect.Right) / 2)
|
||||||
|
$y = [int](($rect.Top + $rect.Bottom) / 2)
|
||||||
|
[Win32]::SetCursorPos($x, $y) | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 50
|
||||||
|
[Win32]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero)
|
||||||
|
[Win32]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero)
|
||||||
|
Start-Sleep -Milliseconds 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-DiscordWindows {
|
||||||
|
$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
|
||||||
|
Click-WindowCenter $p
|
||||||
|
}
|
||||||
|
|
||||||
|
$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
|
||||||
|
}
|
||||||
58
internal/config/config.go
Normal file
58
internal/config/config.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
OSC OSCConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type OSCConfig struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
cfg := &Config{OSC: OSCConfig{Host: "127.0.0.1", Port: 9001}}
|
||||||
|
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())
|
||||||
|
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, "\"'")
|
||||||
|
if section == "osc" {
|
||||||
|
switch key {
|
||||||
|
case "host":
|
||||||
|
cfg.OSC.Host = val
|
||||||
|
case "port":
|
||||||
|
if n, err := strconv.Atoi(val); err == nil {
|
||||||
|
cfg.OSC.Port = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg, scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
33
internal/config/config_test.go
Normal file
33
internal/config/config_test.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadDefaultsWhenMissing(t *testing.T) {
|
||||||
|
cfg, err := Load("does-not-exist.toml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.OSC.Host != "127.0.0.1" || cfg.OSC.Port != 9001 {
|
||||||
|
t.Fatalf("unexpected defaults: %+v", cfg.OSC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadOSCValues(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.toml")
|
||||||
|
if err := os.WriteFile(path, []byte("[osc]\nhost = \"0.0.0.0\"\nport = 9002\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.OSC.Host != "0.0.0.0" || cfg.OSC.Port != 9002 {
|
||||||
|
t.Fatalf("unexpected values: %+v", cfg.OSC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
77
internal/osc/server.go
Normal file
77
internal/osc/server.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package osc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"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) {
|
||||||
|
parts := bytes.SplitN(b, []byte{0}, 2)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return "", nil, fmt.Errorf("invalid osc")
|
||||||
|
}
|
||||||
|
address := string(parts[0])
|
||||||
|
if !strings.HasPrefix(address, "/") {
|
||||||
|
return "", nil, fmt.Errorf("invalid address")
|
||||||
|
}
|
||||||
|
return address, nil, nil
|
||||||
|
}
|
||||||
14
internal/osc/server_test.go
Normal file
14
internal/osc/server_test.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package osc
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseMessageAddress(t *testing.T) {
|
||||||
|
addr, _, err := parseMessage([]byte("/avatar/parameters/DiscordSend\x00"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseMessage returned error: %v", err)
|
||||||
|
}
|
||||||
|
if addr != "/avatar/parameters/DiscordSend" {
|
||||||
|
t.Fatalf("unexpected address: %q", addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -7,4 +7,6 @@ pykakasi
|
|||||||
pyperclip
|
pyperclip
|
||||||
python-osc
|
python-osc
|
||||||
rapidfuzz
|
rapidfuzz
|
||||||
|
pyinstaller
|
||||||
|
chardet==5.2.0
|
||||||
vrchatapi
|
vrchatapi
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ if str(SRC_DIR) not in sys.path:
|
|||||||
|
|
||||||
from osc.gateway import VrcOscGateway
|
from osc.gateway import VrcOscGateway
|
||||||
from discord_control.actions import setDiscordMute
|
from discord_control.actions import setDiscordMute
|
||||||
from ocr.ocr_actions import runOcrFromScreen
|
|
||||||
from translate.translate_actions import saveTranslationText
|
|
||||||
from translate.translate_actions import translateTextToJapanese
|
|
||||||
from vision.vision_actions import runVisionFromScreen
|
|
||||||
from vrc_log.log_actions import collectVrchatLog
|
from vrc_log.log_actions import collectVrchatLog
|
||||||
from vrc_log.log_actions import startSelfMonitor
|
from vrc_log.log_actions import startSelfMonitor
|
||||||
from vrc_log.log_actions import startVrcLogMonitor
|
from vrc_log.log_actions import startVrcLogMonitor
|
||||||
@@ -68,6 +64,7 @@ def create_gateway():
|
|||||||
|
|
||||||
if gateway.is_rising_edge(address, args[0]):
|
if gateway.is_rising_edge(address, args[0]):
|
||||||
gateway.log("INFO", f"received {address} args={args}")
|
gateway.log("INFO", f"received {address} args={args}")
|
||||||
|
from ocr.ocr_actions import runOcrFromScreen
|
||||||
runOcrFromScreen()
|
runOcrFromScreen()
|
||||||
|
|
||||||
def on_translate_ocr(address, *args):
|
def on_translate_ocr(address, *args):
|
||||||
@@ -78,6 +75,9 @@ def create_gateway():
|
|||||||
if gateway.is_rising_edge(address, args[0]):
|
if gateway.is_rising_edge(address, args[0]):
|
||||||
gateway.log("INFO", f"received {address} args={args}")
|
gateway.log("INFO", f"received {address} args={args}")
|
||||||
|
|
||||||
|
from ocr.ocr_actions import runOcrFromScreen
|
||||||
|
from translate.translate_actions import saveTranslationText
|
||||||
|
from translate.translate_actions import translateTextToJapanese
|
||||||
result = runOcrFromScreen()
|
result = runOcrFromScreen()
|
||||||
if not result:
|
if not result:
|
||||||
gateway.log("ERROR", "OCR result is None")
|
gateway.log("ERROR", "OCR result is None")
|
||||||
@@ -105,6 +105,7 @@ def create_gateway():
|
|||||||
|
|
||||||
if gateway.is_rising_edge(address, args[0]):
|
if gateway.is_rising_edge(address, args[0]):
|
||||||
gateway.log("INFO", f"received {address} args={args}")
|
gateway.log("INFO", f"received {address} args={args}")
|
||||||
|
from vision.vision_actions import runVisionFromScreen
|
||||||
runVisionFromScreen()
|
runVisionFromScreen()
|
||||||
|
|
||||||
gateway.map(PARAM_DISCORD_MUTE, on_discord_mute)
|
gateway.map(PARAM_DISCORD_MUTE, on_discord_mute)
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
SRC_DIR = Path(__file__).resolve().parents[1]
|
def _get_runtime_base_dir():
|
||||||
ROOT_DIR = SRC_DIR.parent
|
if getattr(sys, "frozen", False):
|
||||||
|
return Path(sys.executable).resolve().parent
|
||||||
|
|
||||||
|
return Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
ROOT_DIR = _get_runtime_base_dir()
|
||||||
|
SRC_DIR = ROOT_DIR / "src"
|
||||||
RUNTIME_DIR = ROOT_DIR / "runtime"
|
RUNTIME_DIR = ROOT_DIR / "runtime"
|
||||||
RUNTIME_LOG_FILE = RUNTIME_DIR / "runtime.log"
|
RUNTIME_LOG_FILE = RUNTIME_DIR / "runtime.log"
|
||||||
|
RUNTIME_JOIN_LEAVE_LOG_FILE = RUNTIME_DIR / "join_leave.log"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from common.project_paths import RUNTIME_LOG_FILE
|
from common.project_paths import RUNTIME_LOG_FILE
|
||||||
|
from common.project_paths import RUNTIME_JOIN_LEAVE_LOG_FILE
|
||||||
|
|
||||||
|
|
||||||
def appendRuntimeLog(title, text):
|
def appendRuntimeLog(title, text):
|
||||||
@@ -17,3 +18,19 @@ def appendRuntimeLog(title, text):
|
|||||||
file.write("\n")
|
file.write("\n")
|
||||||
|
|
||||||
return RUNTIME_LOG_FILE
|
return RUNTIME_LOG_FILE
|
||||||
|
|
||||||
|
|
||||||
|
def appendJoinLeaveLog(text):
|
||||||
|
RUNTIME_JOIN_LEAVE_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
body = str(text).strip()
|
||||||
|
if not body:
|
||||||
|
body = "(empty)"
|
||||||
|
|
||||||
|
with RUNTIME_JOIN_LEAVE_LOG_FILE.open("a", encoding="utf-8") as file:
|
||||||
|
file.write(f"\n[{now}] VRC JOIN/LEAVE\n")
|
||||||
|
file.write(body)
|
||||||
|
file.write("\n")
|
||||||
|
|
||||||
|
return RUNTIME_JOIN_LEAVE_LOG_FILE
|
||||||
|
|||||||
@@ -61,18 +61,12 @@ class VrcOscGateway:
|
|||||||
|
|
||||||
self.dispatcher.set_default_handler(on_any)
|
self.dispatcher.set_default_handler(on_any)
|
||||||
|
|
||||||
def serve_forever(self):
|
def serve_forever(self):
|
||||||
self.log("CHECK", "STEP=1 状態確認")
|
self.log("CHECK", "STEP=1 状態確認")
|
||||||
self.log("INFO", f"listen={self.host}:{self.port}")
|
self.log("INFO", f"listen={self.host}:{self.port}")
|
||||||
|
|
||||||
self.log("CHECK", "STEP=2 想定状態判定")
|
try:
|
||||||
self.log("INFO", "VRChat OSC Enabled")
|
server = BlockingOSCUDPServer((self.host, self.port), self.dispatcher)
|
||||||
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()
|
server.serve_forever()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
self.log("INFO", "Ctrl+C detected")
|
self.log("INFO", "Ctrl+C detected")
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ from discord_control.actions import setDiscordMuted
|
|||||||
_monitor_thread = None
|
_monitor_thread = None
|
||||||
_monitor_stop_event = threading.Event()
|
_monitor_stop_event = threading.Event()
|
||||||
_last_sent_output = None
|
_last_sent_output = None
|
||||||
|
_last_join_leave_output = ""
|
||||||
|
_monitor_vrc_state = None
|
||||||
_self_monitor_thread = None
|
_self_monitor_thread = None
|
||||||
_self_monitor_stop_event = threading.Event()
|
_self_monitor_stop_event = threading.Event()
|
||||||
_last_self_state = None
|
_last_self_state = None
|
||||||
@@ -39,7 +41,10 @@ ENTERING_ROOM_PATTERN = re.compile(
|
|||||||
r"\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$"
|
r"\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$"
|
||||||
)
|
)
|
||||||
WORLD_NAME_PATTERN = re.compile(
|
WORLD_NAME_PATTERN = re.compile(
|
||||||
r"worldId=(wrld_[0-9a-fA-F-]+)(?::[^,}]*)?,\s*worldName=([^,}]+)"
|
r"worldId=(wrld_[0-9a-fA-F-]+)(?::[^,}]*)?,\s*(?:instanceId=([^,}]+),\s*)?worldName=([^,}]+)"
|
||||||
|
)
|
||||||
|
WORLD_LOCATION_PATTERN = re.compile(
|
||||||
|
r"worldId=(wrld_[0-9a-fA-F-]+)(?::[^,}]*)?,\s*instanceId=([^,}]+)"
|
||||||
)
|
)
|
||||||
TIME_PATTERN = re.compile(
|
TIME_PATTERN = re.compile(
|
||||||
r"(\d{4}\.\d{2}\.\d{2}\s+\d{2}:\d{2}:\d{2})"
|
r"(\d{4}\.\d{2}\.\d{2}\s+\d{2}:\d{2}:\d{2})"
|
||||||
@@ -77,7 +82,9 @@ param(
|
|||||||
$Payload = @{ content = $Message } | ConvertTo-Json -Compress
|
$Payload = @{ content = $Message } | ConvertTo-Json -Compress
|
||||||
$Body = [System.Text.Encoding]::UTF8.GetBytes($Payload)
|
$Body = [System.Text.Encoding]::UTF8.GetBytes($Payload)
|
||||||
$Response = Invoke-WebRequest -Uri $WebhookUrl -Method Post -ContentType 'application/json; charset=utf-8' -Body $Body -UseBasicParsing
|
$Response = Invoke-WebRequest -Uri $WebhookUrl -Method Post -ContentType 'application/json; charset=utf-8' -Body $Body -UseBasicParsing
|
||||||
Write-Output ("STATUS=" + $Response.StatusCode)
|
if ($Response.StatusCode -ne 200) {
|
||||||
|
Write-Output ("STATUS=" + $Response.StatusCode)
|
||||||
|
}
|
||||||
'''
|
'''
|
||||||
|
|
||||||
ps_path = None
|
ps_path = None
|
||||||
@@ -127,7 +134,7 @@ param(
|
|||||||
|
|
||||||
stdout = (result.stdout or "").strip()
|
stdout = (result.stdout or "").strip()
|
||||||
if stdout:
|
if stdout:
|
||||||
log("INFO", f"Discord webhook PowerShell stdout={stdout}")
|
log("DEBUG", f"Discord webhook PowerShell stdout={stdout}")
|
||||||
log("INFO", "Discord webhook sent via PowerShell")
|
log("INFO", "Discord webhook sent via PowerShell")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -205,7 +212,7 @@ def extractWorldNameMap(text):
|
|||||||
match = WORLD_NAME_PATTERN.search(raw_line)
|
match = WORLD_NAME_PATTERN.search(raw_line)
|
||||||
if match:
|
if match:
|
||||||
world_id = match.group(1).strip()
|
world_id = match.group(1).strip()
|
||||||
world_name = match.group(2).strip()
|
world_name = match.group(3).strip()
|
||||||
if world_id and world_name:
|
if world_id and world_name:
|
||||||
world_name_map[world_id] = world_name
|
world_name_map[world_id] = world_name
|
||||||
continue
|
continue
|
||||||
@@ -322,10 +329,9 @@ def matchesAnyPattern(text, compiled_patterns):
|
|||||||
|
|
||||||
|
|
||||||
def formatJoinLeaveLine(line_time, action, name, state, guest_names):
|
def formatJoinLeaveLine(line_time, action, name, state, guest_names):
|
||||||
staff_count = len(state["staff_present_set"])
|
total_count = len(state["present_set"])
|
||||||
member_count = len(state["member_present_set"])
|
|
||||||
guest_count = len(state["guest_names"])
|
guest_count = len(state["guest_names"])
|
||||||
return f'[{formatClock(line_time)}][{action}] {name} {member_count - staff_count}/{guest_count}'
|
return f'[{formatClock(line_time)}][{action}] {name} {total_count}/{guest_count}'
|
||||||
|
|
||||||
|
|
||||||
def parseVrchatEvents(text, config):
|
def parseVrchatEvents(text, config):
|
||||||
@@ -340,12 +346,11 @@ def parseVrchatEvents(text, config):
|
|||||||
"location": "",
|
"location": "",
|
||||||
"world_id": "",
|
"world_id": "",
|
||||||
"instance_id": "",
|
"instance_id": "",
|
||||||
|
"present_set": set(),
|
||||||
"guest_names": guest_names,
|
"guest_names": guest_names,
|
||||||
"guest_name_set": set(guest_names),
|
"guest_name_set": set(guest_names),
|
||||||
"staff_names": staff_names,
|
"staff_names": staff_names,
|
||||||
"guest_present_set": set(),
|
"guest_present_set": set(),
|
||||||
"staff_present_set": set(),
|
|
||||||
"member_present_set": set(),
|
|
||||||
"missing_count": config["missing_count"],
|
"missing_count": config["missing_count"],
|
||||||
"last_notice_key": None,
|
"last_notice_key": None,
|
||||||
}
|
}
|
||||||
@@ -383,9 +388,8 @@ def parseVrchatEvents(text, config):
|
|||||||
state["location"] = location or world_id
|
state["location"] = location or world_id
|
||||||
state["world_id"] = world_id
|
state["world_id"] = world_id
|
||||||
state["instance_id"] = instance_id
|
state["instance_id"] = instance_id
|
||||||
|
state["present_set"].clear()
|
||||||
state["guest_present_set"].clear()
|
state["guest_present_set"].clear()
|
||||||
state["staff_present_set"].clear()
|
|
||||||
state["member_present_set"].clear()
|
|
||||||
state["last_notice_key"] = None
|
state["last_notice_key"] = None
|
||||||
output_lines = []
|
output_lines = []
|
||||||
world_label = formatWorldLabel(world_id, world_name_map)
|
world_label = formatWorldLabel(world_id, world_name_map)
|
||||||
@@ -398,7 +402,7 @@ def parseVrchatEvents(text, config):
|
|||||||
join_leave_line = None
|
join_leave_line = None
|
||||||
if isSelf(name, self_name):
|
if isSelf(name, self_name):
|
||||||
self_state = "joined"
|
self_state = "joined"
|
||||||
state["member_present_set"].add(name)
|
state["present_set"].add(name)
|
||||||
join_leave_line = formatJoinLeaveLine(line_time, "join", name, state, guest_names)
|
join_leave_line = formatJoinLeaveLine(line_time, "join", name, state, guest_names)
|
||||||
output_lines.append(join_leave_line)
|
output_lines.append(join_leave_line)
|
||||||
continue
|
continue
|
||||||
@@ -407,16 +411,16 @@ def parseVrchatEvents(text, config):
|
|||||||
|
|
||||||
if role == "guest":
|
if role == "guest":
|
||||||
state["guest_present_set"].add(name)
|
state["guest_present_set"].add(name)
|
||||||
state["member_present_set"].add(name)
|
state["present_set"].add(name)
|
||||||
join_leave_line = formatJoinLeaveLine(line_time, "join", name, state, guest_names)
|
join_leave_line = formatJoinLeaveLine(line_time, "join", name, state, guest_names)
|
||||||
output_lines.append(join_leave_line)
|
output_lines.append(join_leave_line)
|
||||||
appendNoticeIfNeeded(output_lines, state)
|
appendNoticeIfNeeded(output_lines, state)
|
||||||
elif role == "staff":
|
elif role == "staff":
|
||||||
state["staff_present_set"].add(name)
|
state["present_set"].add(name)
|
||||||
join_leave_line = formatJoinLeaveLine(line_time, "join", name, state, guest_names)
|
join_leave_line = formatJoinLeaveLine(line_time, "join", name, state, guest_names)
|
||||||
output_lines.append(join_leave_line)
|
output_lines.append(join_leave_line)
|
||||||
else:
|
else:
|
||||||
state["member_present_set"].add(name)
|
state["present_set"].add(name)
|
||||||
join_leave_line = formatJoinLeaveLine(line_time, "join", name, state, guest_names)
|
join_leave_line = formatJoinLeaveLine(line_time, "join", name, state, guest_names)
|
||||||
output_lines.append(join_leave_line)
|
output_lines.append(join_leave_line)
|
||||||
continue
|
continue
|
||||||
@@ -427,7 +431,7 @@ def parseVrchatEvents(text, config):
|
|||||||
join_leave_line = None
|
join_leave_line = None
|
||||||
if isSelf(name, self_name):
|
if isSelf(name, self_name):
|
||||||
self_state = "left"
|
self_state = "left"
|
||||||
state["member_present_set"].discard(name)
|
state["present_set"].discard(name)
|
||||||
join_leave_line = formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
|
join_leave_line = formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
|
||||||
output_lines.append(join_leave_line)
|
output_lines.append(join_leave_line)
|
||||||
continue
|
continue
|
||||||
@@ -436,16 +440,16 @@ def parseVrchatEvents(text, config):
|
|||||||
|
|
||||||
if role == "guest":
|
if role == "guest":
|
||||||
state["guest_present_set"].discard(name)
|
state["guest_present_set"].discard(name)
|
||||||
state["member_present_set"].discard(name)
|
state["present_set"].discard(name)
|
||||||
join_leave_line = formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
|
join_leave_line = formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
|
||||||
output_lines.append(join_leave_line)
|
output_lines.append(join_leave_line)
|
||||||
appendNoticeIfNeeded(output_lines, state)
|
appendNoticeIfNeeded(output_lines, state)
|
||||||
elif role == "staff":
|
elif role == "staff":
|
||||||
state["staff_present_set"].discard(name)
|
state["present_set"].discard(name)
|
||||||
join_leave_line = formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
|
join_leave_line = formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
|
||||||
output_lines.append(join_leave_line)
|
output_lines.append(join_leave_line)
|
||||||
else:
|
else:
|
||||||
state["member_present_set"].discard(name)
|
state["present_set"].discard(name)
|
||||||
join_leave_line = formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
|
join_leave_line = formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
|
||||||
output_lines.append(join_leave_line)
|
output_lines.append(join_leave_line)
|
||||||
continue
|
continue
|
||||||
@@ -453,6 +457,142 @@ def parseVrchatEvents(text, config):
|
|||||||
return output_lines, self_state
|
return output_lines, self_state
|
||||||
|
|
||||||
|
|
||||||
|
def createVrcState(config, world_name_map=None):
|
||||||
|
guest_names = config["guest_names"]
|
||||||
|
return {
|
||||||
|
"location": "",
|
||||||
|
"world_id": "",
|
||||||
|
"instance_id": "",
|
||||||
|
"present_set": set(),
|
||||||
|
"guest_names": guest_names,
|
||||||
|
"guest_name_set": set(guest_names),
|
||||||
|
"staff_names": set(config["staff_names"]),
|
||||||
|
"guest_present_set": set(),
|
||||||
|
"missing_count": config["missing_count"],
|
||||||
|
"last_notice_key": None,
|
||||||
|
"world_name_map": world_name_map or {},
|
||||||
|
"last_join_leave_output": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def processVrcText(text, config, state):
|
||||||
|
return processVrcLines(text.splitlines(), config, state)
|
||||||
|
|
||||||
|
|
||||||
|
def processVrcLines(raw_lines, config, state):
|
||||||
|
output_lines = []
|
||||||
|
world_name_map = state.get("world_name_map") or {}
|
||||||
|
guest_names = state["guest_names"]
|
||||||
|
staff_names = state["staff_names"]
|
||||||
|
self_name = config.get("self_name", "")
|
||||||
|
compiled_patterns = compileLogPatterns(config.get("log_patterns", []))
|
||||||
|
|
||||||
|
for raw_line in raw_lines:
|
||||||
|
line_time = parseLineTime(raw_line)
|
||||||
|
if not line_time:
|
||||||
|
continue
|
||||||
|
|
||||||
|
line_matches = matchesAnyPattern(raw_line, compiled_patterns)
|
||||||
|
|
||||||
|
room_match = ENTERING_ROOM_PATTERN.search(raw_line)
|
||||||
|
world_name_match = WORLD_NAME_PATTERN.search(raw_line)
|
||||||
|
world_location_match = WORLD_LOCATION_PATTERN.search(raw_line)
|
||||||
|
world_match = WORLD_PATTERN.search(raw_line)
|
||||||
|
location = ""
|
||||||
|
world_id = ""
|
||||||
|
instance_id = ""
|
||||||
|
|
||||||
|
if world_name_match:
|
||||||
|
world_id = world_name_match.group(1).strip()
|
||||||
|
instance_id = (world_name_match.group(2) or "").strip()
|
||||||
|
location = world_id
|
||||||
|
elif world_location_match:
|
||||||
|
world_id = world_location_match.group(1).strip()
|
||||||
|
instance_id = world_location_match.group(2).strip()
|
||||||
|
location = f"{world_id}:{instance_id}" if instance_id else world_id
|
||||||
|
elif room_match and world_match:
|
||||||
|
location = world_match.group(1).strip()
|
||||||
|
world_id, instance_id = splitWorldLocation(location)
|
||||||
|
elif world_match:
|
||||||
|
location = world_match.group(1).strip()
|
||||||
|
world_id, instance_id = splitWorldLocation(location)
|
||||||
|
|
||||||
|
next_location = f"{world_id}:{instance_id}" if world_id else ""
|
||||||
|
if next_location and next_location != state["location"]:
|
||||||
|
state["location"] = next_location
|
||||||
|
state["world_id"] = world_id
|
||||||
|
state["instance_id"] = instance_id
|
||||||
|
state["present_set"].clear()
|
||||||
|
state["guest_present_set"].clear()
|
||||||
|
state["last_notice_key"] = None
|
||||||
|
state["last_join_leave_output"] = ""
|
||||||
|
world_label = formatWorldLabel(world_id, world_name_map)
|
||||||
|
if world_label != "不明":
|
||||||
|
output_lines.append(f"ワールド入室: {world_label}")
|
||||||
|
|
||||||
|
join_match = JOIN_PATTERN.search(raw_line)
|
||||||
|
if join_match and line_matches:
|
||||||
|
name = join_match.group(1).strip()
|
||||||
|
if isSelf(name, self_name):
|
||||||
|
state["present_set"].add(name)
|
||||||
|
output_lines.append(
|
||||||
|
formatJoinLeaveLine(line_time, "join", name, state, guest_names)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
role = classifyUser(name, state["guest_name_set"], staff_names)
|
||||||
|
if role == "guest":
|
||||||
|
state["guest_present_set"].add(name)
|
||||||
|
state["present_set"].add(name)
|
||||||
|
output_lines.append(
|
||||||
|
formatJoinLeaveLine(line_time, "join", name, state, guest_names)
|
||||||
|
)
|
||||||
|
appendNoticeIfNeeded(output_lines, state)
|
||||||
|
elif role == "staff":
|
||||||
|
state["present_set"].add(name)
|
||||||
|
output_lines.append(
|
||||||
|
formatJoinLeaveLine(line_time, "join", name, state, guest_names)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
state["present_set"].add(name)
|
||||||
|
output_lines.append(
|
||||||
|
formatJoinLeaveLine(line_time, "join", name, state, guest_names)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
left_match = LEFT_PATTERN.search(raw_line)
|
||||||
|
if left_match and line_matches:
|
||||||
|
name = left_match.group(1).strip()
|
||||||
|
if isSelf(name, self_name):
|
||||||
|
state["present_set"].discard(name)
|
||||||
|
output_lines.append(
|
||||||
|
formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
role = classifyUser(name, state["guest_name_set"], staff_names)
|
||||||
|
if role == "guest":
|
||||||
|
state["guest_present_set"].discard(name)
|
||||||
|
state["present_set"].discard(name)
|
||||||
|
output_lines.append(
|
||||||
|
formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
|
||||||
|
)
|
||||||
|
appendNoticeIfNeeded(output_lines, state)
|
||||||
|
elif role == "staff":
|
||||||
|
state["present_set"].discard(name)
|
||||||
|
output_lines.append(
|
||||||
|
formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
state["present_set"].discard(name)
|
||||||
|
output_lines.append(
|
||||||
|
formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
return output_lines, state
|
||||||
|
|
||||||
|
|
||||||
def extractLatestSelfLogState(text, self_name):
|
def extractLatestSelfLogState(text, self_name):
|
||||||
if not self_name:
|
if not self_name:
|
||||||
return None
|
return None
|
||||||
@@ -481,7 +621,9 @@ def collectVrchatLog(pattern=None, notify_changed=True):
|
|||||||
latest_log = findLatestVrchatLog()
|
latest_log = findLatestVrchatLog()
|
||||||
|
|
||||||
text = readTextSafe(latest_log)
|
text = readTextSafe(latest_log)
|
||||||
lines, self_state = parseVrchatEvents(text, config)
|
world_name_map = extractWorldNameMap(text)
|
||||||
|
state = createVrcState(config, world_name_map=world_name_map)
|
||||||
|
lines, state = processVrcText(text, config, state)
|
||||||
|
|
||||||
output_text = "\n".join(lines) if lines else ""
|
output_text = "\n".join(lines) if lines else ""
|
||||||
output_path = None
|
output_path = None
|
||||||
@@ -489,13 +631,22 @@ def collectVrchatLog(pattern=None, notify_changed=True):
|
|||||||
output_path = appendRuntimeLog("VRC LOG", output_text)
|
output_path = appendRuntimeLog("VRC LOG", output_text)
|
||||||
join_leave_lines = [line for line in lines if "[join]" in line or "[leave]" in line]
|
join_leave_lines = [line for line in lines if "[join]" in line or "[leave]" in line]
|
||||||
if join_leave_lines:
|
if join_leave_lines:
|
||||||
appendJoinLeaveLog("\n".join(join_leave_lines))
|
global _last_join_leave_output
|
||||||
|
current_join_leave_output = "\n".join(join_leave_lines)
|
||||||
|
join_leave_diff = buildDiffMessage(
|
||||||
|
_last_join_leave_output,
|
||||||
|
current_join_leave_output,
|
||||||
|
)
|
||||||
|
if join_leave_diff:
|
||||||
|
appendJoinLeaveLog(join_leave_diff)
|
||||||
|
_last_join_leave_output = current_join_leave_output
|
||||||
|
|
||||||
if notify_changed:
|
if notify_changed:
|
||||||
global _last_sent_output
|
global _last_sent_output
|
||||||
diff_text = buildDiffMessage(_last_sent_output, output_text)
|
diff_text = buildDiffMessage(_last_sent_output, output_text)
|
||||||
if diff_text:
|
if diff_text:
|
||||||
try:
|
try:
|
||||||
|
appendRuntimeLog("VRC LOG DIFF", diff_text)
|
||||||
ok = sendWebhook(diff_text)
|
ok = sendWebhook(diff_text)
|
||||||
if ok:
|
if ok:
|
||||||
log("INFO", "Discord webhook diff sent")
|
log("INFO", "Discord webhook diff sent")
|
||||||
@@ -512,6 +663,16 @@ def collectVrchatLog(pattern=None, notify_changed=True):
|
|||||||
return output_path
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def initializeVrcMonitorState():
|
||||||
|
global _monitor_vrc_state
|
||||||
|
config = loadVrcLogConfig()
|
||||||
|
latest_log = findLatestVrchatLog()
|
||||||
|
text = readTextSafe(latest_log)
|
||||||
|
world_name_map = extractWorldNameMap(text)
|
||||||
|
_monitor_vrc_state = createVrcState(config, world_name_map=world_name_map)
|
||||||
|
return _monitor_vrc_state
|
||||||
|
|
||||||
|
|
||||||
def _readNewText(path, offset):
|
def _readNewText(path, offset):
|
||||||
for encoding in ("utf-8", "utf-8-sig", "cp932", "shift_jis"):
|
for encoding in ("utf-8", "utf-8-sig", "cp932", "shift_jis"):
|
||||||
try:
|
try:
|
||||||
@@ -561,11 +722,54 @@ def _watchVrchatLogChanges(stop_event, label, on_relevant_change):
|
|||||||
|
|
||||||
|
|
||||||
def _monitor_loop():
|
def _monitor_loop():
|
||||||
|
global _monitor_vrc_state
|
||||||
|
|
||||||
|
if _monitor_vrc_state is None:
|
||||||
|
try:
|
||||||
|
initializeVrcMonitorState()
|
||||||
|
except Exception as e:
|
||||||
|
log("ERROR", f"VRC log monitor init failed detail={e}")
|
||||||
|
|
||||||
def on_relevant_change(text):
|
def on_relevant_change(text):
|
||||||
|
global _monitor_vrc_state
|
||||||
|
global _last_sent_output
|
||||||
|
global _last_join_leave_output
|
||||||
|
|
||||||
if not any(token in text for token in ("OnPlayerJoined", "OnPlayerLeft", "Entering Room", "Joining or Creating Room", "worldId=", "wrld_")):
|
if not any(token in text for token in ("OnPlayerJoined", "OnPlayerLeft", "Entering Room", "Joining or Creating Room", "worldId=", "wrld_")):
|
||||||
return
|
return
|
||||||
|
|
||||||
collectVrchatLog(notify_changed=True)
|
try:
|
||||||
|
config = loadVrcLogConfig()
|
||||||
|
if _monitor_vrc_state is None:
|
||||||
|
initializeVrcMonitorState()
|
||||||
|
lines, state = processVrcText(text, config, _monitor_vrc_state)
|
||||||
|
_monitor_vrc_state = state
|
||||||
|
output_text = "\n".join(lines) if lines else ""
|
||||||
|
if not output_text:
|
||||||
|
return
|
||||||
|
appendRuntimeLog("VRC LOG", output_text)
|
||||||
|
join_leave_lines = [line for line in lines if "[join]" in line or "[leave]" in line]
|
||||||
|
if join_leave_lines:
|
||||||
|
current_join_leave_output = "\n".join(join_leave_lines)
|
||||||
|
if current_join_leave_output != _last_join_leave_output:
|
||||||
|
join_leave_diff = buildDiffMessage(_last_join_leave_output, current_join_leave_output)
|
||||||
|
if join_leave_diff:
|
||||||
|
appendJoinLeaveLog(join_leave_diff)
|
||||||
|
_last_join_leave_output = current_join_leave_output
|
||||||
|
global _last_sent_output
|
||||||
|
diff_text = buildDiffMessage(_last_sent_output, output_text)
|
||||||
|
if diff_text:
|
||||||
|
appendRuntimeLog("VRC LOG DIFF", diff_text)
|
||||||
|
ok = sendWebhook(diff_text)
|
||||||
|
if ok:
|
||||||
|
log("INFO", "Discord webhook diff sent")
|
||||||
|
_last_sent_output = output_text
|
||||||
|
else:
|
||||||
|
log("ERROR", "Discord webhook diff send failed")
|
||||||
|
for line in lines:
|
||||||
|
print(line, flush=True)
|
||||||
|
except Exception as e:
|
||||||
|
log("ERROR", f"VRC log monitor chunk failed detail={e}")
|
||||||
|
|
||||||
_watchVrchatLogChanges(
|
_watchVrchatLogChanges(
|
||||||
_monitor_stop_event,
|
_monitor_stop_event,
|
||||||
|
|||||||
59
vrc_osc.spec
Normal file
59
vrc_osc.spec
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
block_cipher = None
|
||||||
|
project_dir = Path(SPECPATH).resolve()
|
||||||
|
src_dir = project_dir / "src"
|
||||||
|
|
||||||
|
hiddenimports = [
|
||||||
|
"paddleocr",
|
||||||
|
"pygetwindow",
|
||||||
|
"pyautogui",
|
||||||
|
"pythonosc",
|
||||||
|
"rapidfuzz",
|
||||||
|
"deep_translator",
|
||||||
|
"chardet.pipeline.orchestrator__mypyc",
|
||||||
|
]
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
[str(src_dir / "app.py")],
|
||||||
|
pathex=[str(src_dir)],
|
||||||
|
binaries=[],
|
||||||
|
datas=[
|
||||||
|
(str(project_dir / "config"), "config"),
|
||||||
|
(str(project_dir / "doc"), "doc"),
|
||||||
|
],
|
||||||
|
hiddenimports=hiddenimports,
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
win_no_prefer_redirects=False,
|
||||||
|
win_private_assemblies=False,
|
||||||
|
cipher=block_cipher,
|
||||||
|
noarchive=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name="vrc_osc",
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
console=True,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user