diff --git a/EXE.md b/EXE.md new file mode 100644 index 0000000..a125657 --- /dev/null +++ b/EXE.md @@ -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. diff --git a/README.md b/README.md index 08d3fff..7119ac8 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/build_exe.ps1 b/build_exe.ps1 new file mode 100644 index 0000000..5975e8b --- /dev/null +++ b/build_exe.ps1 @@ -0,0 +1,6 @@ +$ErrorActionPreference = "Stop" + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $ScriptDir + +python -m PyInstaller .\vrc_osc.spec --noconfirm --clean diff --git a/cmd/vrc_osc/main.go b/cmd/vrc_osc/main.go new file mode 100644 index 0000000..fed460f --- /dev/null +++ b/cmd/vrc_osc/main.go @@ -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) + } +} + diff --git a/config/config.example.toml b/config/config.example.toml index e154d9e..eda415b 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ea7c930 --- /dev/null +++ b/go.mod @@ -0,0 +1,4 @@ +module vrc_osc_go + +go 1.22 + diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..c6a6f96 --- /dev/null +++ b/internal/app/app.go @@ -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 + } +} + diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..ed1319b --- /dev/null +++ b/internal/config/config.go @@ -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() +} + diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..6e95697 --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} + diff --git a/internal/osc/server.go b/internal/osc/server.go new file mode 100644 index 0000000..c56871c --- /dev/null +++ b/internal/osc/server.go @@ -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 +} diff --git a/internal/osc/server_test.go b/internal/osc/server_test.go new file mode 100644 index 0000000..7aa3486 --- /dev/null +++ b/internal/osc/server_test.go @@ -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) + } +} + diff --git a/requirements.txt b/requirements.txt index ccef526..d5564bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,6 @@ pykakasi pyperclip python-osc rapidfuzz +pyinstaller +chardet==5.2.0 vrchatapi diff --git a/src/app.py b/src/app.py index 3b87df0..7d8e37d 100644 --- a/src/app.py +++ b/src/app.py @@ -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) diff --git a/src/common/project_paths.py b/src/common/project_paths.py index fa38746..132e36b 100644 --- a/src/common/project_paths.py +++ b/src/common/project_paths.py @@ -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" diff --git a/src/common/runtime_log.py b/src/common/runtime_log.py index 862cec0..026175e 100644 --- a/src/common/runtime_log.py +++ b/src/common/runtime_log.py @@ -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 diff --git a/src/osc/gateway.py b/src/osc/gateway.py index 419d88b..d781fca 100644 --- a/src/osc/gateway.py +++ b/src/osc/gateway.py @@ -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") diff --git a/src/vrc_log/log_actions.py b/src/vrc_log/log_actions.py index 1031e5f..18889da 100644 --- a/src/vrc_log/log_actions.py +++ b/src/vrc_log/log_actions.py @@ -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, diff --git a/vrc_osc.spec b/vrc_osc.spec new file mode 100644 index 0000000..74f2843 --- /dev/null +++ b/vrc_osc.spec @@ -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, +)