8 Commits

Author SHA1 Message Date
every_holiday
329bcd0c50 Use windowsgui in release workflow
All checks were successful
build-windows-exe / build (push) Successful in 2m7s
2026-06-25 00:02:41 +09:00
every_holiday
93a0251082 Merge remote-tracking branch 'origin/main' into go-rewrite 2026-06-25 00:00:33 +09:00
every_holiday
6add92ef40 Improve Discord mute focus handling 2026-06-24 23:43:53 +09:00
every_holiday
63700d9d29 Restore VRTW_Tool title; note discord mute regression 2026-06-24 03:08:30 +09:00
every_holiday
ab9551c241 Improve GUI guest sorting and presence display 2026-06-24 02:38:06 +09:00
every_holiday
b6421958af Add Windows app icon assets 2026-06-24 02:17:04 +09:00
every_holiday
8bff7af6bc Add release auto-update launcher
All checks were successful
build-windows-exe / build (push) Successful in 1m47s
2026-06-24 02:08:19 +09:00
every_holiday
cbd8911c25 Port 2026-06-22 22:29:10 +09:00
36 changed files with 1597 additions and 57 deletions

View File

@@ -33,8 +33,11 @@ jobs:
run: |
cd VRWT_Tool/VRC_OSC
mkdir -p dist
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o dist/vrc_osc.exe ./cmd/vrc_osc
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o dist/vrwt_tool.exe ./cmd/vrwt_tool
VERSION="${GITHUB_REF_NAME}"
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-H windowsgui -X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc.exe ./cmd/vrc_osc
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-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
- name: Package executables
shell: bash
@@ -43,7 +46,7 @@ jobs:
cp config/config.example.toml dist/config.example.toml
cp config/secrets.example.toml dist/secrets.example.toml
cp config/guests.example.txt dist/guests.example.txt
(cd dist && zip -r vrc_osc-windows.zip vrc_osc.exe vrwt_tool.exe config.example.toml secrets.example.toml guests.example.txt)
(cd dist && zip -r vrc_osc-windows.zip vrc_osc.exe vrc_osc_launcher.exe vrwt_tool.exe config.example.toml secrets.example.toml guests.example.txt)
- name: Create Release
env:

25
EXE.md Normal file
View File

@@ -0,0 +1,25 @@
# exe Packaging
## Build
```powershell
.\build_exe.ps1
```
This uses `PyInstaller` and writes the result to `dist\vrc_osc\`.
## Distribution Layout
Place these next to `vrc_osc.exe`:
- `config\config.toml`
- `config\secrets.toml`
- `config\guests.txt`
- `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.

View File

@@ -8,6 +8,7 @@ VRC_OSC は、VRChat の状態を見て Discord やログ連携を行うため
## できること
- VRChat のログを見て Discord に通知する
- 変更があった部分だけを差分で送る
- Discord のミュートを VRChat 側の操作に合わせて切り替える
- VRChat 画面を読み取って OCR する
- OCR した文字を翻訳してコピーする
@@ -116,6 +117,15 @@ YourNameHere
この README は、まず使い方が分かることを優先して簡単にまとめています。
細かい内部仕様が必要な場合は、ソースコードと `config/*.example.*` を見てください。
## 生成されるログ
- `runtime/runtime.log`
- 通常の実行ログ
- `runtime/join_leave.log`
- join / leave だけを抜き出したログ
- `runtime/latest_ocr.png`
- `runtime/latest_screenshot.png`
## 詳しい資料
- [設計書](doc/design.md)

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -4,6 +4,8 @@ import (
"flag"
"log"
"os"
"os/exec"
"path/filepath"
"vrc_osc_go/internal/app"
)
@@ -11,12 +13,30 @@ import (
func main() {
var configPath string
var mode string
var noGUI bool
flag.StringVar(&configPath, "config", "config/config.toml", "path to config.toml")
flag.StringVar(&mode, "mode", "", "generate-json or extract-log")
flag.BoolVar(&noGUI, "no-gui", false, "do not launch the GUI companion")
flag.Parse()
if mode == "" && !noGUI {
launchGUI()
}
if err := app.Run(configPath, mode); err != nil {
log.SetOutput(os.Stderr)
log.Fatalf("vrc_osc_go: %v", err)
}
}
func launchGUI() {
exe, err := os.Executable()
if err != nil {
return
}
gui := filepath.Join(filepath.Dir(exe), "vrc_osc_gui.exe")
if _, err := os.Stat(gui); err != nil {
return
}
_ = exec.Command(gui).Start()
}

View File

@@ -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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m]; }); }
setInterval(load, 3000);
load();
</script>
</body>
</html>`
_, _ = fmt.Fprint(w, html)
}
func handleState(w http.ResponseWriter, r *http.Request) {
statePath := filepath.Join(runtimeDir(), "state.json")
body, err := os.ReadFile(statePath)
if err != nil {
_ = json.NewEncoder(w).Encode(map[string]any{
"world": "",
"discord_muted": false,
"ocr": "",
"translate": "",
"guests": []any{},
"guest_count": 0,
"error": err.Error(),
})
return
}
var state map[string]any
if err := json.Unmarshal(body, &state); err != nil {
_ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error()})
return
}
guests := loadGuestSnapshot()
state["guests"] = guests
state["guest_count"] = len(guests)
_ = json.NewEncoder(w).Encode(state)
}
func loadGuestSnapshot() []map[string]any {
statePath := filepath.Join(runtimeDir(), "guest_snapshot.json")
body, err := os.ReadFile(statePath)
if err != nil {
return []map[string]any{}
}
var guests []map[string]any
if err := json.Unmarshal(body, &guests); err != nil {
return []map[string]any{}
}
return guests
}
func openBrowser(url string) {
switch runtime.GOOS {
case "windows":
_ = exec.Command("cmd", "/c", "start", "", url).Start()
default:
_ = exec.Command("xdg-open", url).Start()
}
}

View File

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

View File

@@ -29,16 +29,19 @@ func Run(configPath string, mode string) error {
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 {

View File

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

View File

@@ -36,6 +36,7 @@ func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
return err
}
state.muted = false
SetDiscordMuted(state.muted)
state.lastAction = now
} else {
log.Printf("INFO already unmuted")
@@ -51,11 +52,11 @@ func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
return err
}
state.muted = true
SetDiscordMuted(state.muted)
state.lastAction = now
} else {
log.Printf("INFO already muted")
}
return nil
}
return nil

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

View File

@@ -20,6 +20,8 @@ runOcrFromScreen()
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)

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

View File

@@ -128,11 +128,19 @@ func watchVrchatLog() {
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)
@@ -141,6 +149,9 @@ func watchVrchatLog() {
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)
@@ -178,11 +189,17 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
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
}
}
@@ -192,6 +209,10 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
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
@@ -311,3 +332,14 @@ func shouldUseRoomTitlePreferLatest(roomTitle, currentWorldName string) bool {
}
return true
}
func extractLineTime(line string) time.Time {
if len(line) < 19 {
return time.Now()
}
t, err := time.ParseInLocation("2006.01.02 15:04:05", line[:19], time.Local)
if err == nil {
return t
}
return time.Now()
}

View File

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

View File

@@ -23,6 +23,7 @@ type OSCConfig struct {
type VrcLogConfig struct {
SelfName string
GuestNames []string
GuestFile string
StaffNames []string
MissingCount int
LogPatterns []string
@@ -91,11 +92,22 @@ func Load(path string) (*Config, error) {
cfg.VrcLog.StaffNames = parseList(val)
}
case "guest":
if key == "names" {
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()
}
@@ -116,3 +128,19 @@ func parseList(value string) []string {
}
return out
}
func loadLines(path string) ([]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
out := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(strings.TrimPrefix(line, "\ufeff"))
if line != "" && !strings.HasPrefix(line, "#") {
out = append(out, line)
}
}
return out, nil
}

View File

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

View File

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

6
build_exe.ps1 Normal file
View File

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

21
cmd/vrc_osc/main.go Normal file
View 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)
}
}

View File

@@ -10,6 +10,16 @@ file = "config/guests.txt"
[notice]
missing_count = 0
[vrc_log]
patterns = [
"OnPlayerJoined",
"OnPlayerLeft",
"Entering Room",
"Joining or Creating Room",
"worldId=",
"wrld_",
]
[ocr.crop]
left = 0.05
top = 0.35

4
go.mod Normal file
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -61,18 +61,12 @@ class VrcOscGateway:
self.dispatcher.set_default_handler(on_any)
def serve_forever(self):
self.log("CHECK", "STEP=1 状態確認")
self.log("INFO", f"listen={self.host}:{self.port}")
self.log("CHECK", "STEP=2 想定状態判定")
self.log("INFO", "VRChat OSC Enabled")
self.log("INFO", "Python は Windows PowerShell 側で実行")
self.log("INFO", "Unity側 MA Parameters と OSC名が一致していること")
try:
server = BlockingOSCUDPServer((self.host, self.port), self.dispatcher)
def serve_forever(self):
self.log("CHECK", "STEP=1 状態確認")
self.log("INFO", f"listen={self.host}:{self.port}")
try:
server = BlockingOSCUDPServer((self.host, self.port), self.dispatcher)
server.serve_forever()
except KeyboardInterrupt:
self.log("INFO", "Ctrl+C detected")

View File

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

59
vrc_osc.spec Normal file
View File

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