diff --git a/VRWT_Tool/VRC_OSC/.env.example b/VRWT_Tool/VRC_OSC/.env.example new file mode 100644 index 0000000..9576eb2 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/.env.example @@ -0,0 +1,3 @@ +DISCORD_WEBHOOK_URL= +VRCHAT_USERNAME= +VRCHAT_PASSWORD= diff --git a/VRWT_Tool/VRC_OSC/.gitignore b/VRWT_Tool/VRC_OSC/.gitignore new file mode 100644 index 0000000..11a147a --- /dev/null +++ b/VRWT_Tool/VRC_OSC/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.py[cod] + +.env +config/config.toml +runtime/ +.vrchat_cookie.txt +src/.vrchat_cookie.txt + +*.bak +*.bak_* +backup_*/ diff --git a/VRWT_Tool/VRC_OSC/bin/vrc_osc.ps1 b/VRWT_Tool/VRC_OSC/bin/vrc_osc.ps1 new file mode 100644 index 0000000..647fa3e --- /dev/null +++ b/VRWT_Tool/VRC_OSC/bin/vrc_osc.ps1 @@ -0,0 +1,7 @@ +$ErrorActionPreference = "Stop" + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Resolve-Path (Join-Path $ScriptDir "..") +$EntryPoint = Join-Path $RootDir "bin\vrc_osc.py" + +python $EntryPoint diff --git a/VRWT_Tool/VRC_OSC/config/config.example.toml b/VRWT_Tool/VRC_OSC/config/config.example.toml new file mode 100644 index 0000000..75fc8b7 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/config/config.example.toml @@ -0,0 +1,11 @@ +[self] +name = "" + +[staff] +names = [] + +[guest] +names = [] + +[notice] +missing_count = 0 diff --git a/VRWT_Tool/VRC_OSC/readme.txt b/VRWT_Tool/VRC_OSC/readme.txt new file mode 100644 index 0000000..9ca7c3b --- /dev/null +++ b/VRWT_Tool/VRC_OSC/readme.txt @@ -0,0 +1,241 @@ +修正版。仕様はこう整理すると実装しやすい。 + +# VRC_LOG event.toml 監視仕様 + +## 1. 目的 + +VRChatログから現在インスタンスの入退室を拾い、必要最低限だけ表示する。 + +表示対象は以下のみ。 + +- ワールド入室 +- guest の現在人数 +- guest の join / leave +- staff の join / leave + +staff は表示するが guest 人数にはカウントしない。 + +--- + +## 2. event.toml + +```toml +[staff] +names = [ + "StaffUserA", + "StaffUserB" +] + +[guest] +names = [ + "GuestUserA", + "GuestUserB", + "GuestUserC" +] + +[notice] +missing_count = 3 + +注意。 + +nofice ではなく notice にする。 +意味は「通知設定」。 + +3. カウント仕様 +3.1 guest人数 + +guest人数は event.toml の [guest].names にいるユーザーだけを数える。 + +staff は数えない。 + +現在Guest: 1/22 + +意味。 + +現在いるguest数 / guest登録総数 +3.2 自分の入室順は関係ない + +自分がインスタンスに何番目に入ったかは使わない。 + +現在インスタンス内で発生した join / leave を時系列で処理し、最終的な在室状態から人数を出す。 + +4. ログ範囲 + +解析対象は昼12:00以降。 + +実行時刻が 01:00 の場合。 + +前日 12:00 以降 + +実行時刻が 13:00 の場合。 + +当日 12:00 以降 + +11:59以前のインスタンスは切り捨てる。 + +5. インスタンス仕様 + +昼12:00以降に検出した最新のワールド / インスタンスだけを対象にする。 + +インスタンスが変わったら、参加状態はリセットする。 + +6. 表示仕様 +6.1 ワールド入室 + +ワールドに入った時だけ表示。 + +ワールド入室: Midnight Rooftop +現在Guest: 1/22 + +ワールド名が取れない場合。 + +ワールド入室: wrld_xxx +現在Guest: 1/22 +7. join / leave 表示仕様 +7.1 guest join +[01:20] GuestUserA join 2/22 +7.2 guest leave +[01:25] GuestUserA leave 1/22 +7.3 staff join + +staff は人数カウントしない。 + +[staff] StaffUserA join 1/22 +7.4 staff leave + +staff が抜けても guest人数は変わらない。 + +[staff] StaffUserA leave 1/22 +8. notice 判定 + +notice.missing_count = 3 の場合、guest登録総数から3人以内の不足になったら、今いないguest名を出す。 + +例。 + +guest登録総数が23人。 +missing_count が3。 + +通知対象人数は以下。 + +20人 +21人 +22人 + +つまり、 + +23 - 3 = 20 + +なので、guest現在人数が 20〜22 の間なら不足者を表示する。 + +9. notice 表示例 + +guest総数23人、notice=3。 + +9.1 20人になった場合 +現在Guest: 20/23 +未入室Guest: +- GuestUserA +- GuestUserB +- GuestUserC +9.2 21人になった場合 +現在Guest: 21/23 +未入室Guest: +- GuestUserA +- GuestUserB +9.3 22人になった場合 +現在Guest: 22/23 +未入室Guest: +- GuestUserA +9.4 23人になった場合 + +表示しない。 + +9.5 23人から22人に減った場合 + +再度表示する。 + +現在Guest: 22/23 +未入室Guest: +- GuestUserA +9.6 19人以下になった場合 + +表示しない。 + +理由。 + +23 - 3 = 20 + +なので、20人未満は通知範囲外。 + +10. notice 条件 + +通知する条件。 + +total_guest - notice <= current_guest < total_guest + +例。 + +23 - 3 <= current_guest < 23 + +つまり。 + +20 <= current_guest < 23 + +通知しない条件。 + +current_guest == total_guest +current_guest < total_guest - notice +11. 最終出力例 +ワールド入室: Midnight Rooftop +現在Guest: 1/22 +[staff] StaffUserA join 1/22 +[01:20] GuestUserA join 2/22 +[01:21] GuestUserB join 3/22 +[01:22] GuestUserC join 4/22 +[01:25] GuestUserB leave 3/22 +[staff] StaffUserA leave 3/22 + +notice条件に入った場合。 + +現在Guest: 20/23 +未入室Guest: +- GuestUserA +- GuestUserB +- GuestUserC +12. 実装時の内部状態 +world_name +world_id +instance_id +guest_total +guest_present_set +staff_present_set +current_guest_count +notice_missing_count + +ユーザー分類。 + +if name in staff: + role = "staff" +elif name in guest: + role = "guest" +else: + role = "other" + +other は表示しない。 +guest人数にも含めない。 + +13. Discord通知も同じ最小表示 + +Discordには以下だけ送る。 + +ワールド入室: Midnight Rooftop +現在Guest: 1/22 +[staff] StaffUserA join 1/22 +[01:20] GuestUserA join 2/22 +[01:25] GuestUserA leave 1/22 + +notice時だけ追加。 + +未入室Guest: +- GuestUserA +- GuestUserB \ No newline at end of file diff --git a/VRWT_Tool/VRC_OSC/script/test.py b/VRWT_Tool/VRC_OSC/script/test.py new file mode 100644 index 0000000..6621205 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/script/test.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +目的: +VRChat表示名とTOML guest名を fuzzy matching する + +特徴: +- 記号除去 +- 全角半角統一 +- 小文字化 +- 日本語 -> ローマ字変換 +- rapidfuzz による類似度 +- ambiguous 判定 +- テストケース付き + +前提: +pip install rapidfuzz pykakasi + +期待結果: +- 類似候補を score 順に表示 +- 危険な曖昧ケースを検知 +""" + +import re +import sys +import unicodedata +from typing import List, Tuple + +try: + from rapidfuzz import fuzz + from rapidfuzz import process +except Exception as e: + print(f"[FATAL] rapidfuzz import failed: {e}") + input("Press Enter to exit...") + sys.exit(1) + +try: + from pykakasi import kakasi +except Exception as e: + print(f"[FATAL] pykakasi import failed: {e}") + input("Press Enter to exit...") + sys.exit(1) + + +# ========================= +# 設定 +# ========================= + +AUTO_LINK_SCORE = 95 +AMBIGUOUS_DIFF = 10 + +TEST_GUESTS = [ + "okada", + "tanaka", + "yamada", + "messykenty", +] + +TEST_CURRENT_USERS = [ + "岡田", + "ikada", + "okada_", + "OKADA", + "tanaka!!!", + "田中", + "やまだ", + "messy_kenty", + "messy.kenty", + "MESSYKENTY", + "ma", + "めっしーけんてぃ", + "山田", +] + + +# ========================= +# ローマ字変換 +# ========================= + +kks = kakasi() + + +def jp_to_romaji(text: str) -> str: + try: + converted = kks.convert(text) + + result = [] + for item in converted: + result.append(item["hepburn"]) + + return "".join(result) + + except Exception as e: + print(f"[ERROR] romaji convert failed: {text} : {e}") + return text + + +# ========================= +# 正規化 +# ========================= + +def normalize_name(name: str) -> str: + """ + 正規化内容: + - 全角半角統一 + - 小文字化 + - 日本語→ローマ字 + - 記号除去 + """ + + # Unicode normalize + name = unicodedata.normalize("NFKC", name) + + # 小文字化 + name = name.lower() + + # 日本語→ローマ字 + name = jp_to_romaji(name) + + # 記号除去 + name = re.sub(r"[^a-z0-9]", "", name) + + return name.strip() + + +# ========================= +# 類似候補検索 +# ========================= + +def find_candidates( + target_guest: str, + current_users: List[str], +) -> List[Tuple[str, float]]: + + normalized_target = normalize_name(target_guest) + + normalized_map = {} + + for user in current_users: + normalized_map[user] = normalize_name(user) + + results = [] + + for original_name, normalized_name in normalized_map.items(): + + score = fuzz.ratio( + normalized_target, + normalized_name, + ) + + results.append((original_name, score)) + + results.sort(key=lambda x: x[1], reverse=True) + + return results + + +# ========================= +# 判定 +# ========================= + +def evaluate_guest_match( + guest_name: str, + current_users: List[str], +): + + print("") + print("=" * 60) + print(f"[CHECK] guest={guest_name}") + + normalized_guest = normalize_name(guest_name) + + print(f"[INFO] normalized_guest={normalized_guest}") + + candidates = find_candidates( + guest_name, + current_users, + ) + + if not candidates: + print("[ERROR] no candidates") + return + + print("") + print("[CHECK] candidate list") + + for idx, (name, score) in enumerate(candidates[:5], start=1): + print(f" {idx}. {name:<20} score={score:.2f}") + + top_name, top_score = candidates[0] + + second_score = 0 + + if len(candidates) >= 2: + second_score = candidates[1][1] + + diff = top_score - second_score + + print("") + print(f"[INFO] top_score={top_score:.2f}") + print(f"[INFO] second_score={second_score:.2f}") + print(f"[INFO] diff={diff:.2f}") + + # 自動確定 + if ( + top_score >= AUTO_LINK_SCORE + and diff >= AMBIGUOUS_DIFF + ): + print("") + print("[INFO] AUTO LINK SAFE") + print(f"[INFO] linked => {top_name}") + return + + # 曖昧 + print("") + print("[WARN] ambiguous match") + print("[WARN] auto link disabled") + + print("") + print("[ACTION] manual review required") + + +# ========================= +# main +# ========================= + +def main(): + + print("[CHECK] normalize preview") + + for user in TEST_CURRENT_USERS: + print( + f"{user:<20}" + f" => " + f"{normalize_name(user)}" + ) + + print("") + print("[CHECK] guest matching test") + + for guest in TEST_GUESTS: + evaluate_guest_match( + guest, + TEST_CURRENT_USERS, + ) + + print("") + print("[INFO] completed") + + +if __name__ == "__main__": + + print("[CHECK] start") + + answer = input( + "[ACTION] run fuzzy test? (yes/no): " + ).strip().lower() + + if answer != "yes": + print("[FATAL] cancelled") + input("Press Enter to exit...") + sys.exit(1) + + main() diff --git a/VRWT_Tool/VRC_OSC/src/bin/__init__.py b/VRWT_Tool/VRC_OSC/src/bin/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/bin/__init__.py @@ -0,0 +1 @@ + diff --git a/VRWT_Tool/VRC_OSC/src/bin/app.py b/VRWT_Tool/VRC_OSC/src/bin/app.py new file mode 100644 index 0000000..f17899f --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/bin/app.py @@ -0,0 +1,114 @@ +import os +from pathlib import Path +import sys + +os.environ["FLAGS_enable_pir_api"] = "0" +os.environ["FLAGS_use_onednn"] = "0" +os.environ["FLAGS_use_mkldnn"] = "0" +os.environ["FLAGS_use_onednn_bfloat16"] = "0" +os.environ["ONEDNN_VERBOSE"] = "0" + +SRC_DIR = Path(__file__).resolve().parents[1] +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +from osc.gateway import VrcOscGateway +from discord.discord_actions import setDiscordMute +from ocr.ocr_actions import runOcrFromScreen +from translate.translate_actions import saveTranslationText +from translate.translate_actions import translateTextToJapanese +from vrc_log.log_actions import collectVrchatLog +from vrc_log.log_actions import startSelfMonitor +from vrc_log.log_actions import startVrcLogMonitor +from vrc_log.log_actions import stopSelfMonitor +from vrc_log.log_actions import stopVrcLogMonitor + +PARAM_DISCORD_MUTE = "DiscordSend" +PARAM_LOG_GREP = "vrc_log" +PARAM_OCR = "ocrEnabled" +PARAM_TRANSLATE_OCR = "translation" + + +def create_gateway(): + gateway = VrcOscGateway(host="127.0.0.1", port=9001) + + def on_discord_mute(address, *args): + if not args: + gateway.log("ERROR", "DiscordSend args empty") + return + + if gateway.is_rising_edge(address, args[0]): + gateway.log("INFO", f"received {address} args={args}") + setDiscordMute() + + vrc_log_enabled = False + def on_log_grep(address, *args): + nonlocal vrc_log_enabled + + if not args: + gateway.log("ERROR", "vrc_log args empty") + return + + if gateway.is_rising_edge(address, args[0]): + vrc_log_enabled = not vrc_log_enabled + gateway.log("INFO", f"received {address} args={args} enabled={vrc_log_enabled}") + + if vrc_log_enabled: + collectVrchatLog(notify_changed=True) + startVrcLogMonitor() + else: + stopVrcLogMonitor() + + def on_ocr(address, *args): + if not args: + gateway.log("ERROR", "ocrEnabled args empty") + return + + if gateway.is_rising_edge(address, args[0]): + gateway.log("INFO", f"received {address} args={args}") + runOcrFromScreen() + + def on_translate_ocr(address, *args): + if not args: + gateway.log("ERROR", "translateOcr args empty") + return + + if gateway.is_rising_edge(address, args[0]): + gateway.log("INFO", f"received {address} args={args}") + + result = runOcrFromScreen() + if not result: + gateway.log("ERROR", "OCR result is None") + return + + text = result.get("text", "") + if not text.strip(): + gateway.log("INFO", "OCR text is empty. translation skipped") + return + + translated = translateTextToJapanese(text) + if not translated.strip(): + gateway.log("INFO", "translation result is empty") + return + + image_path = result.get("image_path") + stem = image_path.stem if image_path else "translation" + saved = saveTranslationText(stem, translated) + gateway.log("INFO", f"translation saved={saved}") + + gateway.map(PARAM_DISCORD_MUTE, on_discord_mute) + gateway.map(PARAM_LOG_GREP, on_log_grep) + gateway.map(PARAM_OCR, on_ocr) + gateway.map(PARAM_TRANSLATE_OCR, on_translate_ocr) + gateway.set_default_debug(enabled=False) + return gateway + + +def main(): + gateway = create_gateway() + startSelfMonitor() + gateway.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/VRWT_Tool/VRC_OSC/src/bin/vrc_osc.py b/VRWT_Tool/VRC_OSC/src/bin/vrc_osc.py new file mode 100644 index 0000000..430ce79 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/bin/vrc_osc.py @@ -0,0 +1,13 @@ +from pathlib import Path +import sys + +SRC_DIR = Path(__file__).resolve().parents[1] + +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +from bin.app import main + + +if __name__ == "__main__": + main() diff --git a/VRWT_Tool/VRC_OSC/src/common/config_loader.py b/VRWT_Tool/VRC_OSC/src/common/config_loader.py new file mode 100644 index 0000000..205d1dd --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/common/config_loader.py @@ -0,0 +1,75 @@ +from pathlib import Path +import tomllib + +from common.project_paths import ROOT_DIR + +CONFIG_DIR = ROOT_DIR / "config" +CONFIG_FILE = CONFIG_DIR / "config.toml" +DEFAULT_EVENT_FILE = CONFIG_DIR / "event.toml" + + +def loadTomlFile(path): + if not path.exists(): + return {} + + return tomllib.loads(path.read_text(encoding="utf-8")) + + +def resolveConfigPath(value, default_path): + if not value: + return default_path + + candidate = Path(value) + if candidate.is_absolute(): + return candidate + + return (ROOT_DIR / candidate).resolve() + + +def loadVrcLogConfig(): + config = loadTomlFile(CONFIG_FILE) + vrc_log = config.get("vrc_log", {}) + self_section = config.get("self", {}) + event_file_value = ( + vrc_log.get("event_toml") + or vrc_log.get("event_file") + or config.get("event_toml") + or config.get("event_file") + ) + + event_file = resolveConfigPath(event_file_value, DEFAULT_EVENT_FILE) + event_config = loadTomlFile(event_file) + + if not event_config: + event_config = vrc_log or config + + guest_names = event_config.get("guest", {}).get("names", []) + staff_names = event_config.get("staff", {}).get("names", []) + + if not guest_names and isinstance(event_config.get("guest_names"), list): + guest_names = event_config.get("guest_names", []) + if not staff_names and isinstance(event_config.get("staff_names"), list): + staff_names = event_config.get("staff_names", []) + + notice_section = event_config.get("notice", {}) + missing_count = notice_section.get("missing_count", 0) + + if not missing_count: + missing_count = event_config.get("missing_count", 0) + + self_name = ( + self_section.get("name") + or config.get("self_name") + or vrc_log.get("self_name") + or event_config.get("self", {}).get("name") + or "" + ) + + return { + "config_file": CONFIG_FILE, + "event_file": event_file, + "guest_names": [str(name).strip() for name in guest_names if str(name).strip()], + "staff_names": [str(name).strip() for name in staff_names if str(name).strip()], + "missing_count": int(missing_count), + "self_name": str(self_name).strip(), + } diff --git a/VRWT_Tool/VRC_OSC/src/common/project_paths.py b/VRWT_Tool/VRC_OSC/src/common/project_paths.py new file mode 100644 index 0000000..3d89804 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/common/project_paths.py @@ -0,0 +1,7 @@ +from pathlib import Path + +SRC_DIR = Path(__file__).resolve().parents[1] +ROOT_DIR = SRC_DIR.parent +RUNTIME_DIR = ROOT_DIR / "runtime" +RUNTIME_LOG_FILE = RUNTIME_DIR / "runtime.log" +ENV_FILE = ROOT_DIR / ".env" diff --git a/VRWT_Tool/VRC_OSC/src/common/runtime_log.py b/VRWT_Tool/VRC_OSC/src/common/runtime_log.py new file mode 100644 index 0000000..862cec0 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/common/runtime_log.py @@ -0,0 +1,19 @@ +from datetime import datetime + +from common.project_paths import RUNTIME_LOG_FILE + + +def appendRuntimeLog(title, text): + RUNTIME_LOG_FILE.parent.mkdir(parents=True, exist_ok=True) + + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + body = str(text).strip() + if not body: + body = "(empty)" + + with RUNTIME_LOG_FILE.open("a", encoding="utf-8") as file: + file.write(f"\n[{now}] {title}\n") + file.write(body) + file.write("\n") + + return RUNTIME_LOG_FILE diff --git a/VRWT_Tool/VRC_OSC/src/discord/discord_actions.py b/VRWT_Tool/VRC_OSC/src/discord/discord_actions.py new file mode 100644 index 0000000..9d1b68f --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/discord/discord_actions.py @@ -0,0 +1,203 @@ +from datetime import datetime +import ctypes +import threading +import time + +import pyautogui +import pygetwindow as gw + +BROWSER_KEYWORDS = [ + "google chrome", + "microsoft edge", + "mozilla firefox", +] + +_discord_muted = True +_discord_mute_lock = threading.Lock() + + +def log(level, message): + now = datetime.now().strftime("%H:%M:%S") + print(f"[{level}] {now} {message}", flush=True) + + +def findDiscordWindow(): + candidates = [] + + for window in gw.getAllWindows(): + title = (window.title or "").strip() + if not title: + continue + if "discord" not in title.lower(): + continue + candidates.append(window) + + if not candidates: + raise RuntimeError("Discordウィンドウが見つかりません") + + candidates.sort(key=lambda window: -(window.width * window.height)) + return candidates[0] + + +def findBrowserWindow(): + candidates = [] + + for window in gw.getAllWindows(): + title = (window.title or "").strip() + if not title: + continue + lowered = title.lower() + if not any(keyword in lowered for keyword in BROWSER_KEYWORDS): + continue + candidates.append(window) + + if not candidates: + raise RuntimeError("ブラウザウィンドウが見つかりません") + + candidates.sort(key=lambda window: -(window.width * window.height)) + return candidates[0] + + +def findDiscordBrowserWindow(): + candidates = [] + + for window in gw.getAllWindows(): + title = (window.title or "").strip() + if not title: + continue + lowered = title.lower() + if "discord" not in lowered: + continue + if not any(keyword in lowered for keyword in BROWSER_KEYWORDS): + continue + candidates.append(window) + + if not candidates: + return None + + candidates.sort(key=lambda window: -(window.width * window.height)) + return candidates[0] + + +def activateDiscordWindow(): + window = findDiscordWindow() + hwnd = getattr(window, "_hWnd", None) + + try: + if window.isMinimized: + window.restore() + except Exception: + pass + + if hwnd: + try: + ctypes.windll.user32.ShowWindow(hwnd, 9) + except Exception: + pass + + try: + window.activate() + except Exception as e: + log("ERROR", f"Discord activate() failed detail={e}") + + if hwnd: + try: + ctypes.windll.user32.BringWindowToTop(hwnd) + ctypes.windll.user32.SetForegroundWindow(hwnd) + except Exception as e: + log("ERROR", f"Discord SetForegroundWindow failed detail={e}") + + time.sleep(0.15) + + log("INFO", f"Discord window title={window.title}") + return window + + +def findVrchatWindow(): + candidates = [] + + for window in gw.getAllWindows(): + title = (window.title or "").strip() + if not title: + continue + lowered = title.lower() + if lowered == "vrchat": + candidates.append((0, window)) + continue + if lowered.startswith("vrchat "): + candidates.append((1, window)) + continue + if lowered.startswith("vrchat"): + candidates.append((2, window)) + + if not candidates: + raise RuntimeError("VRChatウィンドウが見つかりません") + + candidates.sort(key=lambda item: (item[0], -(item[1].width * item[1].height))) + return candidates[0][1] + + +def activateWindow(window, label): + if not window: + return + + hwnd = getattr(window, "_hWnd", None) + + try: + if window.isMinimized: + window.restore() + except Exception: + pass + + if hwnd: + try: + ctypes.windll.user32.ShowWindow(hwnd, 9) + except Exception: + pass + + try: + window.activate() + except Exception as e: + log("ERROR", f"{label} activate() failed detail={e}") + + if hwnd: + try: + ctypes.windll.user32.BringWindowToTop(hwnd) + ctypes.windll.user32.SetForegroundWindow(hwnd) + except Exception as e: + log("ERROR", f"{label} SetForegroundWindow failed detail={e}") + + time.sleep(0.15) + try: + pyautogui.click(window.left + window.width // 2, window.top + window.height // 2) + except Exception: + pass + time.sleep(0.1) + log("INFO", f"{label} window title={window.title}") + + +def setDiscordMute(): + log("ACTION", "setDiscordMute called") + try: + activateDiscordWindow() + except Exception as e: + log("ERROR", f"Discord window unavailable detail={e}") + browser = findDiscordBrowserWindow() or findBrowserWindow() + activateWindow(browser, "Browser") + log("INFO", "Discord hotkey Ctrl+Shift+M") + pyautogui.hotkey("ctrl", "shift", "m") + activateWindow(findVrchatWindow(), "VRChat") + + +def setDiscordMuted(muted): + global _discord_muted + + muted = bool(muted) + + with _discord_mute_lock: + if _discord_muted == muted: + log("INFO", f"Discord mute already {'on' if muted else 'off'}") + return + + setDiscordMute() + _discord_muted = muted diff --git a/VRWT_Tool/VRC_OSC/src/getUser.py b/VRWT_Tool/VRC_OSC/src/getUser.py new file mode 100644 index 0000000..7254bbb --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/getUser.py @@ -0,0 +1,152 @@ +import os +import re +from http.cookiejar import MozillaCookieJar + +import vrchatapi +from vrchatapi.api import authentication_api +from vrchatapi.api import users_api +from vrchatapi.exceptions import UnauthorizedException +from vrchatapi.models.two_factor_auth_code import TwoFactorAuthCode +from vrchatapi.models.two_factor_email_code import TwoFactorEmailCode + +COOKIE_FILE = ".vrchat_cookie.txt" + +USER_AGENT = ( + "VRC_OSC/1.0 " + "(contact: test@gmail.com)" +) + + +def read_dotenv_value(key): + env_path = ".env" + if not os.path.exists(env_path): + return "" + + with open(env_path, "r", encoding="utf-8-sig", errors="replace") as file: + for line in file: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if not stripped.startswith(key + "="): + continue + + value = stripped.split("=", 1)[1].strip() + return value.strip('"').strip("'") + + return "" + + +def get_required_secret(key): + value = os.environ.get(key, "").strip() or read_dotenv_value(key).strip() + if not value: + raise RuntimeError(f"{key} is not set") + return value + + +def setup_cookie_jar(api_client): + cookie_jar = MozillaCookieJar(COOKIE_FILE) + + if os.path.exists(COOKIE_FILE): + try: + cookie_jar.load( + ignore_discard=True, + ignore_expires=True, + ) + except Exception: + pass + + api_client.rest_client.cookie_jar = cookie_jar + return cookie_jar + + +def save_cookie_jar(cookie_jar): + try: + cookie_jar.save( + ignore_discard=True, + ignore_expires=True, + ) + except Exception: + pass + + +def login(api_client, cookie_jar): + auth_api = authentication_api.AuthenticationApi(api_client) + + try: + current_user = auth_api.get_current_user() + save_cookie_jar(cookie_jar) + return current_user + + except UnauthorizedException as e: + reason = str(e.reason) + + if "Email 2 Factor Authentication" in reason: + code = input("Email 2FA Code: ").strip() + + auth_api.verify2_fa_email_code( + TwoFactorEmailCode(code) + ) + + elif "2 Factor Authentication" in reason: + code = input("TOTP Code: ").strip() + + auth_api.verify2_fa( + TwoFactorAuthCode(code) + ) + + else: + raise + + current_user = auth_api.get_current_user() + + save_cookie_jar(cookie_jar) + + return current_user + + +def extract_user_id(text): + match = re.search( + r"(usr_[0-9a-fA-F\-]+)", + text, + ) + + if not match: + return None + + return match.group(1) + + +def main(): + text = input("参加者名: ").strip() + + user_id = extract_user_id(text) + + if not user_id: + print(text) + return + + configuration = vrchatapi.Configuration( + username=get_required_secret("VRCHAT_USERNAME"), + password=get_required_secret("VRCHAT_PASSWORD"), + ) + + with vrchatapi.ApiClient(configuration) as api_client: + api_client.user_agent = USER_AGENT + + api_client.default_headers["User-Agent"] = USER_AGENT + + cookie_jar = setup_cookie_jar(api_client) + + login(api_client, cookie_jar) + + users = users_api.UsersApi(api_client) + + user = users.get_user(user_id) + + print(user.display_name) + + save_cookie_jar(cookie_jar) + + +if __name__ == "__main__": + main() diff --git a/VRWT_Tool/VRC_OSC/src/main.py b/VRWT_Tool/VRC_OSC/src/main.py new file mode 100644 index 0000000..e51969d --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/main.py @@ -0,0 +1,5 @@ +from bin.app import main + + +if __name__ == "__main__": + main() diff --git a/VRWT_Tool/VRC_OSC/src/ocr/ocr_actions.py b/VRWT_Tool/VRC_OSC/src/ocr/ocr_actions.py new file mode 100644 index 0000000..a9d106d --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/ocr/ocr_actions.py @@ -0,0 +1,141 @@ +import os +from datetime import datetime + +import pyperclip +from paddleocr import PaddleOCR + +from common.runtime_log import appendRuntimeLog +from screen.screen_actions import captureOcrRegion + +os.environ["FLAGS_enable_pir_api"] = "0" +os.environ["FLAGS_use_onednn"] = "0" +os.environ["FLAGS_use_mkldnn"] = "0" +os.environ["FLAGS_use_onednn_bfloat16"] = "0" +os.environ["MKLDNN_DISABLE_WORKSPACE"] = "1" +os.environ["ONEDNN_VERBOSE"] = "0" + +_ocr_engine = None + + +def log(level, message): + now = datetime.now().strftime("%H:%M:%S") + print(f"[{level}] {now} {message}", flush=True) + + +def getOcrEngine(): + global _ocr_engine + + if _ocr_engine is None: + log("INFO", "PaddleOCR initializing") + _ocr_engine = PaddleOCR( + lang="japan", + use_angle_cls=True, + ) + + return _ocr_engine + + +def saveOcrText(image_path, text): + return appendRuntimeLog( + "OCR TEXT", + f"image={image_path}\n{text}", + ) + + +def extractTextFromAny(obj): + lines = [] + + def walk(x): + if x is None: + return + + if isinstance(x, dict): + for key in ("rec_texts", "texts"): + value = x.get(key) + if isinstance(value, list): + for item in value: + text = str(item).strip() + if text: + lines.append(text) + + for key in ("text", "rec_text"): + value = x.get(key) + if isinstance(value, str) and value.strip(): + lines.append(value.strip()) + + for value in x.values(): + walk(value) + return + + if isinstance(x, (list, tuple)): + if len(x) >= 2 and isinstance(x[1], (list, tuple)) and len(x[1]) >= 1: + candidate = x[1][0] + if isinstance(candidate, str) and candidate.strip(): + lines.append(candidate.strip()) + + for item in x: + walk(item) + + walk(obj) + + unique_lines = [] + seen = set() + for line in lines: + if line not in seen: + unique_lines.append(line) + seen.add(line) + + return "\n".join(unique_lines) + + +def printOcrResult(text): + cleaned = text.strip() + if not cleaned: + log("INFO", "OCR結果は空です") + return + + log("CHECK", "OCR結果 full") + for line in cleaned.splitlines(): + print(line, flush=True) + + +def runPaddleOcr(engine, image_path): + if hasattr(engine, "predict"): + return engine.predict(str(image_path)) + return engine.ocr(str(image_path)) + + +def runOcrFromImage(image_path, show_result=True): + log("ACTION", f"PaddleOCR start image={image_path}") + + try: + engine = getOcrEngine() + result = runPaddleOcr(engine, image_path) + except Exception as e: + log("ERROR", f"PaddleOCR failed detail={e}") + return None + + text = extractTextFromAny(result) + text_path = saveOcrText(image_path, text) + + try: + pyperclip.copy(text) + log("INFO", "OCR結果をクリップボードへコピーしました") + except Exception as e: + log("ERROR", f"clipboard copy failed detail={e}") + + log("INFO", f"OCR text appended={text_path}") + if show_result: + printOcrResult(text) + + return { + "image_path": image_path, + "text_path": text_path, + "text": text, + } + + +def runOcrFromScreen(): + log("ACTION", "runOcrFromScreen called") + image_path = captureOcrRegion() + return runOcrFromImage(image_path, show_result=True) diff --git a/VRWT_Tool/VRC_OSC/src/osc/gateway.py b/VRWT_Tool/VRC_OSC/src/osc/gateway.py new file mode 100644 index 0000000..419d88b --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/osc/gateway.py @@ -0,0 +1,85 @@ +from pythonosc.dispatcher import Dispatcher +from pythonosc.osc_server import BlockingOSCUDPServer +from datetime import datetime +import sys + +class VrcOscGateway: + def __init__(self, host="127.0.0.1", port=9001): + self.host = host + self.port = port + self.dispatcher = Dispatcher() + self.last_values = {} + + def log(self, level, message): + now = datetime.now().strftime("%H:%M:%S") + print(f"[{level}] {now} {message}", flush=True) + + def to_bool(self, value): + if isinstance(value, bool): + return value + + if isinstance(value, int) or isinstance(value, float): + return value != 0 + + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in ("true", "1", "on"): + return True + if lowered in ("false", "0", "off"): + return False + + raise ValueError(f"unsupported OSC value: {value}") + + def is_rising_edge(self, address, value): + current = self.to_bool(value) + previous = self.last_values.get(address, False) + self.last_values[address] = current + return current and not previous + + def is_changed(self, address, value): + current = self.to_bool(value) + previous = self.last_values.get(address, None) + + if previous == current: + return False + + self.last_values[address] = current + return True + + def map(self, parameter_name, handler): + address = f"/avatar/parameters/{parameter_name}" + self.dispatcher.map(address, handler) + self.log("INFO", f"mapped {address}") + + def set_default_debug(self, enabled=False): + if not enabled: + return + + def on_any(address, *args): + if address.startswith("/avatar/parameters/"): + self.log("INFO", f"other address={address} args={args}") + + self.dispatcher.set_default_handler(on_any) + + def serve_forever(self): + self.log("CHECK", "STEP=1 状態確認") + self.log("INFO", f"listen={self.host}:{self.port}") + + self.log("CHECK", "STEP=2 想定状態判定") + self.log("INFO", "VRChat OSC Enabled") + self.log("INFO", "Python は Windows PowerShell 側で実行") + self.log("INFO", "Unity側 MA Parameters と OSC名が一致していること") + + + try: + server = BlockingOSCUDPServer((self.host, self.port), self.dispatcher) + server.serve_forever() + except KeyboardInterrupt: + self.log("INFO", "Ctrl+C detected") + except Exception as e: + self.log("FATAL", f"OSC server error STEP=3 detail={e}") + input("Press Enter to exit...") + sys.exit(1) + + self.log("CHECK", "STEP=4 結果確認") + self.log("INFO", "終了しました") diff --git a/VRWT_Tool/VRC_OSC/src/screen/screen_actions.py b/VRWT_Tool/VRC_OSC/src/screen/screen_actions.py new file mode 100644 index 0000000..65a9d2d --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/screen/screen_actions.py @@ -0,0 +1,121 @@ +import pyautogui +import pygetwindow as gw + +from common.project_paths import RUNTIME_DIR + +SCREENSHOT_PATH = RUNTIME_DIR / "latest_screenshot.png" +OCR_SCREENSHOT_PATH = RUNTIME_DIR / "latest_ocr.png" + +EXCLUDED_WINDOW_KEYWORDS = [ + "Google Chrome", + "Microsoft Edge", + "Mozilla Firefox", + "Visual Studio Code", + "PowerShell", + "Windows Terminal", + "ChatGPT", +] + + +def log(level, message): + from datetime import datetime + + now = datetime.now().strftime("%H:%M:%S") + print(f"[{level}] {now} {message}", flush=True) + + +def findVrchatWindow(): + windows = gw.getAllWindows() + candidates = [] + + for window in windows: + title = window.title or "" + normalized = title.strip().lower() + if not normalized: + continue + if any(keyword.lower() in normalized for keyword in EXCLUDED_WINDOW_KEYWORDS): + continue + + if normalized == "vrchat": + candidates.append((0, window)) + continue + if normalized.startswith("vrchat "): + candidates.append((1, window)) + continue + if normalized.startswith("vrchat"): + candidates.append((2, window)) + + if not candidates: + visible_titles = [] + for window in windows: + title = (window.title or "").strip() + if title: + visible_titles.append(title) + + log("ERROR", "VRChatウィンドウが見つかりません") + log("CHECK", "現在検出できるウィンドウ") + for title in visible_titles[:30]: + print(title, flush=True) + + raise RuntimeError("VRChatウィンドウが見つかりません") + + candidates = sorted( + candidates, + key=lambda item: (item[0], -(item[1].width * item[1].height)), + ) + window = candidates[0][1] + + if window.width <= 0 or window.height <= 0: + raise RuntimeError("VRChatウィンドウサイズが不正です") + + return window + + +def getVrchatRegion(): + window = findVrchatWindow() + + try: + if window.isMinimized: + window.restore() + except Exception: + pass + + left = int(window.left) + top = int(window.top) + width = int(window.width) + height = int(window.height) + + if width <= 0 or height <= 0: + raise RuntimeError("VRChatウィンドウ範囲が不正です") + + log("INFO", f"VRChat window title={window.title}") + log("INFO", f"VRChat region left={left} top={top} width={width} height={height}") + return left, top, width, height + + +def captureVrchatWindowTo(image_path): + image_path.parent.mkdir(parents=True, exist_ok=True) + + left, top, width, height = getVrchatRegion() + + image = pyautogui.screenshot(region=(left, top, width, height)) + image.save(image_path) + + log("ACTION", f"captureVrchatWindow saved={image_path}") + return image_path + + +def captureVrchatWindow(): + return captureVrchatWindowTo(SCREENSHOT_PATH) + + +def captureOcrRegion(): + return captureVrchatWindowTo(OCR_SCREENSHOT_PATH) + + +def captureScreen(): + return captureVrchatWindow() + + +def takeScreenshot(): + return captureVrchatWindow() diff --git a/VRWT_Tool/VRC_OSC/src/tools/discord_hold_mute_listener.py b/VRWT_Tool/VRC_OSC/src/tools/discord_hold_mute_listener.py new file mode 100644 index 0000000..03514d2 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/tools/discord_hold_mute_listener.py @@ -0,0 +1,124 @@ +from pythonosc.dispatcher import Dispatcher +from pythonosc.osc_server import BlockingOSCUDPServer +from datetime import datetime +import pyautogui +import time +import sys + +HOST = "127.0.0.1" +PORT = 9001 +PARAMETER = "/avatar/parameters/DiscordSend" + +# Discordの初期状態を「ミュート中」と仮定 +discord_muted = True +button_pressed = False +last_action_time = 0.0 +DEBOUNCE_SECONDS = 0.3 + +def log(level, message): + now = datetime.now().strftime("%H:%M:%S") + print(f"[{level}] {now} {message}", flush=True) + +def wait_exit(): + input("Press Enter to exit...") + +def press_discord_mute_hotkey(): + pyautogui.hotkey("ctrl", "shift", "m") + +def handle_discord_send(address, *args): + global discord_muted + global button_pressed + global last_action_time + + if not args: + log("ERROR", "OSC args is empty") + return + + value = args[0] + now_ts = time.time() + + if isinstance(value, bool): + is_pressed = value + elif isinstance(value, int) or isinstance(value, float): + is_pressed = value != 0 + else: + log("ERROR", f"unsupported value type value={value}") + return + + if now_ts - last_action_time < DEBOUNCE_SECONDS: + return + + # 押した瞬間: ミュート解除 + if is_pressed and not button_pressed: + button_pressed = True + + if discord_muted: + log("ACTION", "VRChat button pressed -> Discord unmute hotkey") + press_discord_mute_hotkey() + discord_muted = False + last_action_time = now_ts + else: + log("INFO", "already unmuted") + + return + + # 離した瞬間: ミュート + if not is_pressed and button_pressed: + button_pressed = False + + if not discord_muted: + log("ACTION", "VRChat button released -> Discord mute hotkey") + press_discord_mute_hotkey() + discord_muted = True + last_action_time = now_ts + else: + log("INFO", "already muted") + + return + +def main(): + log("CHECK", "STEP=1 OSC受信状態確認") + log("INFO", f"host={HOST}") + log("INFO", f"port={PORT}") + log("INFO", f"parameter={PARAMETER}") + + log("CHECK", "STEP=2 想定状態判定") + log("INFO", "Windows PowerShellで実行していること") + log("INFO", "VRChat OSC Enabledであること") + log("INFO", "Discordのミュート切替キーが Ctrl+Shift+M であること") + log("INFO", "Discordの現在状態はミュート中として扱います") + + answer = input("[ACTION] 続行しますか? yes/no: ").strip().lower() + + if answer != "yes": + if answer == "no": + log("FATAL", "ユーザー操作により停止 STEP=2") + wait_exit() + sys.exit(1) + + log("ERROR", "不正入力です STEP=2") + wait_exit() + sys.exit(1) + + log("ACTION", "STEP=3 OSC受信開始") + log("INFO", "VRChatのメニューを押している間だけDiscordミュート解除します") + log("INFO", "停止する場合は Ctrl+C") + + dispatcher = Dispatcher() + dispatcher.map(PARAMETER, handle_discord_send) + + try: + server = BlockingOSCUDPServer((HOST, PORT), dispatcher) + server.serve_forever() + except KeyboardInterrupt: + log("INFO", "Ctrl+C detected") + except Exception as e: + log("FATAL", f"OSC server error STEP=3 detail={e}") + wait_exit() + sys.exit(1) + + log("CHECK", "STEP=4 結果確認") + log("INFO", "終了しました") + +if __name__ == "__main__": + main() diff --git a/VRWT_Tool/VRC_OSC/src/tools/osc_debug_listener.py b/VRWT_Tool/VRC_OSC/src/tools/osc_debug_listener.py new file mode 100644 index 0000000..c21b324 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/tools/osc_debug_listener.py @@ -0,0 +1,21 @@ +from pythonosc.dispatcher import Dispatcher +from pythonosc.osc_server import BlockingOSCUDPServer +from datetime import datetime + +HOST = "127.0.0.1" +PORT = 9001 + +def on_any(address, *args): + now = datetime.now().strftime("%H:%M:%S") + print(f"[INFO] {now} address={address} args={args}", flush=True) + +dispatcher = Dispatcher() +dispatcher.set_default_handler(on_any) + +print("[CHECK] OSC受信開始") +print(f"[INFO] host={HOST}") +print(f"[INFO] port={PORT}") +print("[ACTION] VRChatでメニューを押してください") + +server = BlockingOSCUDPServer((HOST, PORT), dispatcher) +server.serve_forever() diff --git a/VRWT_Tool/VRC_OSC/src/tools/osc_test_print_listener.py b/VRWT_Tool/VRC_OSC/src/tools/osc_test_print_listener.py new file mode 100644 index 0000000..5b67fb4 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/tools/osc_test_print_listener.py @@ -0,0 +1,54 @@ +from pythonosc.dispatcher import Dispatcher +from pythonosc.osc_server import BlockingOSCUDPServer +from datetime import datetime + +HOST = "127.0.0.1" +PORT = 9001 + +def log(msg): + print(msg, flush=True) + +def on_test(address, *args): + now = datetime.now().strftime("%H:%M:%S") + + log("") + log(f"[INFO] time={now}") + log(f"[ACTION] OSC parameter received") + log(f"[INFO] address={address}") + log(f"[INFO] args={args}") + +dispatcher = Dispatcher() + +dispatcher.map( + "/avatar/parameters/TestPrint", + on_test +) + +log("[CHECK] STEP=1 OSC待受開始") +log(f"[INFO] host={HOST}") +log(f"[INFO] port={PORT}") + +try: + server = BlockingOSCUDPServer( + (HOST, PORT), + dispatcher + ) + + log("[INFO] OSC server started") + log("[ACTION] VRChatでTestPrintを押してください") + + server.serve_forever() + +except KeyboardInterrupt: + log("") + log("[INFO] Ctrl+C detected") + log("[INFO] shutdown") + +except Exception as e: + log("") + log("[FATAL] OSC server error") + log(f"[ERROR] detail={e}") + + print("") + input("Press Enter to exit...") + raise SystemExit(1) diff --git a/VRWT_Tool/VRC_OSC/src/translate/translate_actions.py b/VRWT_Tool/VRC_OSC/src/translate/translate_actions.py new file mode 100644 index 0000000..6df4117 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/translate/translate_actions.py @@ -0,0 +1,102 @@ +from datetime import datetime +import re + +import pyperclip +from deep_translator import GoogleTranslator + +from common.runtime_log import appendRuntimeLog + +IGNORE_EXACT = { + "戻る", + "翻訳", + "OCR", + "VRCLOG", + "Discord_Mute", + "Screenshot", + "OCR_Toggle", + "DCR_Toggle", + "HI", +} + + +def log(level, message): + now = datetime.now().strftime("%H:%M:%S") + print(f"[{level}] {now} {message}", flush=True) + + +def saveTranslationText(stem, text): + return appendRuntimeLog( + "TRANSLATION TEXT", + f"source={stem}\n{text}", + ) + + +def looksLikeNoise(line): + value = line.strip() + if not value: + return True + if value in IGNORE_EXACT: + return True + if len(value) <= 2: + return True + if "_" in value: + return True + if re.fullmatch(r"[A-Za-z0-9\-_/]+", value): + return True + return False + + +def normalizeSourceText(text): + lines = [] + + for raw_line in text.splitlines(): + line = raw_line.strip() + if looksLikeNoise(line): + continue + lines.append(line) + + unique_lines = [] + seen = set() + for line in lines: + if line not in seen: + unique_lines.append(line) + seen.add(line) + + return "\n".join(unique_lines).strip() + + +def translateTextToJapanese(text): + source = normalizeSourceText(text) + if not source: + log("INFO", "翻訳対象が空です") + return "" + + log("CHECK", "Translation source") + for line in source.splitlines(): + print(line, flush=True) + + try: + translated = GoogleTranslator( + source="auto", + target="ja", + ).translate(source) + except Exception as e: + log("ERROR", f"translation failed detail={e}") + return "" + + translated = translated.strip() + if not translated: + log("INFO", "翻訳結果は空です") + return "" + + try: + pyperclip.copy(translated) + log("INFO", "翻訳結果をクリップボードへコピーしました") + except Exception as e: + log("ERROR", f"clipboard copy failed detail={e}") + + log("CHECK", "Translation result") + for line in translated.splitlines(): + print(line, flush=True) + + return translated diff --git a/VRWT_Tool/VRC_OSC/src/vrc_log/log_actions.py b/VRWT_Tool/VRC_OSC/src/vrc_log/log_actions.py new file mode 100644 index 0000000..8c5c25d --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/vrc_log/log_actions.py @@ -0,0 +1,583 @@ +import os +import re +import subprocess +import tempfile +import threading +import time +from datetime import datetime +from datetime import timedelta +from pathlib import Path + +from common.config_loader import loadVrcLogConfig +from common.project_paths import ENV_FILE +from common.runtime_log import appendRuntimeLog +from discord.discord_actions import setDiscordMuted + +_monitor_thread = None +_monitor_stop_event = threading.Event() +_last_sent_output = None +_self_monitor_thread = None +_self_monitor_stop_event = threading.Event() +_last_self_state = None +REALTIME_IDLE_SECONDS = 0.2 + +JOIN_PATTERN = re.compile( + r"OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)" +) +LEFT_PATTERN = re.compile( + r"OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)" +) +WORLD_PATTERN = re.compile( + r"(wrld_[0-9a-fA-F-]+(?::[^\s\]\)\"']+)?)" +) +ENTERING_ROOM_PATTERN = re.compile( + r"\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$" +) +WORLD_NAME_PATTERN = re.compile( + r"worldId=(wrld_[0-9a-fA-F-]+)(?::[^,}]*)?,\s*worldName=([^,}]+)" +) +TIME_PATTERN = re.compile( + r"(\d{4}\.\d{2}\.\d{2}\s+\d{2}:\d{2}:\d{2})" +) +RELEVANT_LOG_PATTERN = re.compile( + r"OnPlayerJoined|OnPlayerLeft|Entering Room|Joining or Creating Room|worldId=|wrld_" +) + + +def log(level, message): + now = datetime.now().strftime("%H:%M:%S") + print(f"[{level}] {now} {message}", flush=True) + + +def readDotEnvValue(key): + if not ENV_FILE.exists(): + return "" + + for line in ENV_FILE.read_text(encoding="utf-8-sig", errors="replace").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if not stripped.startswith(key + "="): + continue + + value = stripped.split("=", 1)[1].strip() + return value.strip('"').strip("'") + + return "" + + +def getWebhookUrl(): + webhook_url = os.environ.get("DISCORD_WEBHOOK_URL", "").strip() + if webhook_url: + return webhook_url + return readDotEnvValue("DISCORD_WEBHOOK_URL") + + +def sendWebhook(text): + webhook_url = getWebhookUrl().strip() + if not webhook_url: + log("ERROR", f"DISCORD_WEBHOOK_URL が未設定です env_file={ENV_FILE}") + return False + + message = str(text).strip() + if not message: + log("INFO", "Webhook送信内容が空です") + return False + + if len(message) > 1800: + message = message[-1800:] + + ps_script = r''' +param( + [string]$WebhookUrl, + [string]$Message +) + +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$Payload = @{ content = $Message } | ConvertTo-Json -Compress +$Body = [System.Text.Encoding]::UTF8.GetBytes($Payload) +Invoke-WebRequest -Uri $WebhookUrl -Method Post -ContentType 'application/json; charset=utf-8' -Body $Body -UseBasicParsing | Out-Null +''' + + ps_path = None + + try: + with tempfile.NamedTemporaryFile( + mode="w", + suffix=".ps1", + delete=False, + encoding="utf-8-sig", + ) as tmp: + tmp.write(ps_script) + ps_path = tmp.name + + result = subprocess.run( + [ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + ps_path, + webhook_url, + message, + ], + capture_output=True, + text=True, + timeout=20, + ) + except Exception as e: + log("ERROR", f"Discord webhook PowerShell failed detail={e}") + return False + finally: + if ps_path: + try: + Path(ps_path).unlink(missing_ok=True) + except Exception: + pass + + if result.returncode != 0: + stderr = (result.stderr or "").strip() + stdout = (result.stdout or "").strip() + log("ERROR", f"Discord webhook PowerShell failed code={result.returncode} stderr={stderr} stdout={stdout}") + return False + + log("INFO", "Discord webhook sent via PowerShell") + return True + + +def getVrchatLogDir(): + userprofile = os.environ.get("USERPROFILE") + if not userprofile: + raise RuntimeError("USERPROFILE が取得できません") + return Path(userprofile) / "AppData" / "LocalLow" / "VRChat" / "VRChat" + + +def findLatestVrchatLog(): + log_dir = getVrchatLogDir() + if not log_dir.exists(): + raise RuntimeError(f"VRChatログディレクトリが存在しません: {log_dir}") + + candidates = [] + for pattern in ("output_log_*.txt", "Player.log", "*.log", "*.txt"): + candidates.extend(log_dir.glob(pattern)) + + candidates = [path for path in candidates if path.is_file()] + if not candidates: + raise RuntimeError(f"VRChatログファイルが見つかりません: {log_dir}") + + candidates.sort(key=lambda path: path.stat().st_mtime, reverse=True) + return candidates[0] + + +def readTextSafe(path): + for encoding in ("utf-8", "utf-8-sig", "cp932", "shift_jis"): + try: + return path.read_text(encoding=encoding, errors="replace") + except Exception: + continue + + raise RuntimeError(f"ログファイルを読めません: {path}") + + +def parseLineTime(line): + matched = TIME_PATTERN.search(line) + if not matched: + return None + + return datetime.strptime(matched.group(1), "%Y.%m.%d %H:%M:%S") + + +def formatClock(dt): + return dt.strftime("%H:%M") + + +def getLogStartTime(now): + noon_today = now.replace(hour=12, minute=0, second=0, microsecond=0) + if now >= noon_today: + return noon_today + return noon_today - timedelta(days=1) + + +def splitWorldLocation(location): + if ":" not in location: + return location, "" + + world_id, instance_id = location.split(":", 1) + return world_id, instance_id + + +def extractWorldNameMap(text): + world_name_map = {} + pending_world_name = "" + + for raw_line in text.splitlines(): + room_match = ENTERING_ROOM_PATTERN.search(raw_line) + if room_match: + pending_world_name = room_match.group(1).strip() + + match = WORLD_NAME_PATTERN.search(raw_line) + if match: + world_id = match.group(1).strip() + world_name = match.group(2).strip() + if world_id and world_name: + world_name_map[world_id] = world_name + continue + + world_match = WORLD_PATTERN.search(raw_line) + if world_match and pending_world_name: + location = world_match.group(1).strip() + world_id, _ = splitWorldLocation(location) + if world_id: + world_name_map[world_id] = pending_world_name + pending_world_name = "" + + return world_name_map + + +def formatWorldLabel(world_id, world_name_map): + world_name = world_name_map.get(world_id, "").strip() + if not world_name: + return "不明" + return world_name + + +def classifyUser(name, guest_names, staff_names): + if name in staff_names: + return "staff" + if name in guest_names: + return "guest" + return "other" + + +def isSelf(name, self_name): + return bool(self_name) and name.strip() == self_name.strip() + + +def buildNoticeLines(guest_names, guest_present_set, missing_count): + total_guest = len(guest_names) + current_guest = len(guest_present_set) + threshold = total_guest - missing_count + + if missing_count <= 0: + return [] + if not (threshold <= current_guest < total_guest): + return [] + + missing_guests = [name for name in guest_names if name not in guest_present_set] + if not missing_guests: + return [] + + lines = [f"現在Guest: {current_guest}/{total_guest}", "未入室Guest:"] + lines.extend(f"- {name}" for name in missing_guests) + return lines + + +def appendNoticeIfNeeded(output_lines, state): + notice_lines = buildNoticeLines( + state["guest_names"], + state["guest_present_set"], + state["missing_count"], + ) + notice_key = tuple(notice_lines) + + if not notice_lines: + state["last_notice_key"] = None + return + + if state["last_notice_key"] == notice_key: + return + + output_lines.extend(notice_lines) + state["last_notice_key"] = notice_key + + +def parseVrchatEvents(text, config): + start_time = getLogStartTime(datetime.now()) + guest_names = config["guest_names"] + staff_names = set(config["staff_names"]) + self_name = config.get("self_name", "") + world_name_map = extractWorldNameMap(text) + + state = { + "location": "", + "world_id": "", + "instance_id": "", + "guest_names": guest_names, + "guest_name_set": set(guest_names), + "staff_names": staff_names, + "guest_present_set": set(), + "staff_present_set": set(), + "member_present_set": set(), + "missing_count": config["missing_count"], + "last_notice_key": None, + } + output_lines = [] + self_state = None + + for raw_line in text.splitlines(): + line_time = parseLineTime(raw_line) + if not line_time or line_time < start_time: + continue + + world_match = WORLD_PATTERN.search(raw_line) + if world_match: + location = world_match.group(1).strip() + world_id, instance_id = splitWorldLocation(location) + + if location != state["location"]: + state["location"] = location + state["world_id"] = world_id + state["instance_id"] = instance_id + state["guest_present_set"].clear() + state["staff_present_set"].clear() + state["member_present_set"].clear() + state["last_notice_key"] = None + output_lines = [] + output_lines.append(f"ワールド入室: {formatWorldLabel(world_id, world_name_map)}") + output_lines.append(f"現在Guest: 0/{len(guest_names)}") + + join_match = JOIN_PATTERN.search(raw_line) + if join_match and state["world_id"]: + name = join_match.group(1).strip() + if isSelf(name, self_name): + self_state = "joined" + state["member_present_set"].add(name) + output_lines.append( + f'[{formatClock(line_time)}] [join] [self] {name} {len(state["member_present_set"])}/{len(guest_names)}' + ) + continue + + role = classifyUser(name, state["guest_name_set"], state["staff_names"]) + + if role == "guest": + state["guest_present_set"].add(name) + state["member_present_set"].add(name) + output_lines.append( + f'[{formatClock(line_time)}] [join] {name} {len(state["member_present_set"])}/{len(guest_names)}' + ) + appendNoticeIfNeeded(output_lines, state) + elif role == "staff": + state["staff_present_set"].add(name) + output_lines.append( + f'[{formatClock(line_time)}] [join] [staff] {name} {len(state["member_present_set"])}/{len(guest_names)}' + ) + else: + state["member_present_set"].add(name) + output_lines.append( + f'[{formatClock(line_time)}] [join] {name} {len(state["member_present_set"])}/{len(guest_names)}' + ) + continue + + left_match = LEFT_PATTERN.search(raw_line) + if left_match and state["world_id"]: + name = left_match.group(1).strip() + if isSelf(name, self_name): + self_state = "left" + state["member_present_set"].discard(name) + output_lines.append( + f'[{formatClock(line_time)}] [leave] [self] {name} {len(state["member_present_set"])}/{len(guest_names)}' + ) + continue + + role = classifyUser(name, state["guest_name_set"], state["staff_names"]) + + if role == "guest": + state["guest_present_set"].discard(name) + state["member_present_set"].discard(name) + output_lines.append( + f'[{formatClock(line_time)}] [leave] {name} {len(state["member_present_set"])}/{len(guest_names)}' + ) + appendNoticeIfNeeded(output_lines, state) + elif role == "staff": + state["staff_present_set"].discard(name) + output_lines.append( + f'[{formatClock(line_time)}] [leave] [staff] {name} {len(state["member_present_set"])}/{len(guest_names)}' + ) + else: + state["member_present_set"].discard(name) + output_lines.append( + f'[{formatClock(line_time)}] [leave] {name} {len(state["member_present_set"])}/{len(guest_names)}' + ) + + return output_lines, self_state + + +def extractLatestSelfLogState(text, self_name): + if not self_name: + return None + + latest_state = None + + for raw_line in text.splitlines(): + join_match = JOIN_PATTERN.search(raw_line) + if join_match and isSelf(join_match.group(1).strip(), self_name): + latest_state = "joined" + continue + + left_match = LEFT_PATTERN.search(raw_line) + if left_match and isSelf(left_match.group(1).strip(), self_name): + latest_state = "left" + + return latest_state + + +def grepVrchatLog(pattern=None): + return collectVrchatLog(pattern=pattern, notify_changed=False) + + +def collectVrchatLog(pattern=None, notify_changed=True): + config = loadVrcLogConfig() + latest_log = findLatestVrchatLog() + log("ACTION", f"parseVrchatLog file={latest_log}") + log("INFO", f'event_file={config["event_file"]}') + + text = readTextSafe(latest_log) + lines, self_state = parseVrchatEvents(text, config) + + output_text = "\n".join(lines) if lines else "[INFO] 対象イベントなし" + output_path = appendRuntimeLog("VRC LOG", output_text) + + if notify_changed: + global _last_sent_output + if output_text != _last_sent_output: + try: + ok = sendWebhook(output_text) + if ok: + log("INFO", "Discord webhook sent") + _last_sent_output = output_text + else: + log("ERROR", "Discord webhook send failed") + except Exception as e: + log("ERROR", f"Discord webhook failed detail={e}") + + log("INFO", f"result appended={output_path}") + for line in lines or ["[INFO] 対象イベントなし"]: + print(line, flush=True) + + return output_path + + +def _readNewText(path, offset): + for encoding in ("utf-8", "utf-8-sig", "cp932", "shift_jis"): + try: + with path.open("r", encoding=encoding, errors="replace") as file: + file.seek(offset) + return file.read(), file.tell() + except Exception: + continue + + raise RuntimeError(f"ログファイルを読めません: {path}") + + +def _watchVrchatLogChanges(stop_event, label, on_relevant_change): + log("ACTION", f"{label} realtime watcher started") + + current_log = None + offset = 0 + + while not stop_event.is_set(): + try: + latest_log = findLatestVrchatLog() + latest_size = latest_log.stat().st_size + + if current_log is None: + current_log = latest_log + offset = latest_size + log("INFO", f"{label} watching file={current_log}") + continue + + if latest_log != current_log: + current_log = latest_log + offset = 0 + log("INFO", f"{label} watching new file={current_log}") + + if latest_size < offset: + offset = 0 + + if latest_size > offset: + text, offset = _readNewText(latest_log, offset) + + if RELEVANT_LOG_PATTERN.search(text): + on_relevant_change(text) + except Exception as e: + log("ERROR", f"{label} realtime watcher failed detail={e}") + + stop_event.wait(REALTIME_IDLE_SECONDS) + + log("INFO", f"{label} realtime watcher stopped") + + +def _monitor_loop(): + def on_relevant_change(_text): + collectVrchatLog(notify_changed=True) + + _watchVrchatLogChanges( + _monitor_stop_event, + "VRC log monitor", + on_relevant_change, + ) + + +def _self_monitor_loop(): + def on_relevant_change(text): + try: + config = loadVrcLogConfig() + self_state = extractLatestSelfLogState( + text, + config.get("self_name", ""), + ) + + global _last_self_state + if self_state and self_state != _last_self_state: + if self_state == "joined": + setDiscordMuted(True) + elif self_state == "left": + setDiscordMuted(False) + _last_self_state = self_state + except Exception as e: + log("ERROR", f"VRC self monitor failed detail={e}") + + _watchVrchatLogChanges( + _self_monitor_stop_event, + "VRC self monitor", + on_relevant_change, + ) + + +def startVrcLogMonitor(_interval_seconds=None): + global _monitor_thread + + if _monitor_thread and _monitor_thread.is_alive(): + log("INFO", "VRC log monitor already running") + return + + _monitor_stop_event.clear() + _monitor_thread = threading.Thread( + target=_monitor_loop, + daemon=True, + ) + _monitor_thread.start() + + +def startSelfMonitor(_interval_seconds=None): + global _self_monitor_thread + + if _self_monitor_thread and _self_monitor_thread.is_alive(): + log("INFO", "VRC self monitor already running") + return + + _self_monitor_stop_event.clear() + _self_monitor_thread = threading.Thread( + target=_self_monitor_loop, + daemon=True, + ) + _self_monitor_thread.start() + + +def stopVrcLogMonitor(): + _monitor_stop_event.set() + + +def stopSelfMonitor(): + _self_monitor_stop_event.set() diff --git a/VRWT_Tool/VRC_OSC/src/vrc_log/member_name_matcher.py b/VRWT_Tool/VRC_OSC/src/vrc_log/member_name_matcher.py new file mode 100644 index 0000000..efe2a34 --- /dev/null +++ b/VRWT_Tool/VRC_OSC/src/vrc_log/member_name_matcher.py @@ -0,0 +1,623 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +目的: +VRChat表示名とTOML登録名を安全な順番で紐づける + +特徴: +- 完全一致を先に確定して候補から除外 +- 正規化一致を確定して候補から除外 +- 残った候補だけを high confidence fuzzy 判定 +- guest のみ low confidence fuzzy で再選別 +- pending / ambiguous / unmatched 判定 +- 記号除去 +- 全角半角統一 +- 小文字化 +- 日本語 -> ローマ字変換 +- rapidfuzz による類似度 +- テストケース付き + +前提: +pip install rapidfuzz pykakasi + +期待結果: +- 100%一致した名前を fuzzy 候補に残さない +- 似た名前同士の誤リンクを減らす +""" + +import re +import sys +import unicodedata +from dataclasses import dataclass, field +from typing import Dict, Iterable, List, Optional, Tuple + +try: + from rapidfuzz import fuzz +except Exception as e: + if __name__ == "__main__": + print(f"[FATAL] rapidfuzz import failed: {e}") + input("Press Enter to exit...") + sys.exit(1) + raise + +try: + from pykakasi import kakasi +except Exception as e: + if __name__ == "__main__": + print(f"[FATAL] pykakasi import failed: {e}") + input("Press Enter to exit...") + sys.exit(1) + raise + + +# ========================= +# 設定 +# ========================= + +AUTO_LINK_SCORE = 95 +LOW_CONFIDENCE_SCORE = 80 +AMBIGUOUS_DIFF = 10 +LOW_CONFIDENCE_MAX_REMAINING = 3 +WEAK_MATCH_SCORE = 50 + +STATUS_MATCHED = "matched" +STATUS_MATCHED_LOW_CONFIDENCE = "matched_low_confidence" +STATUS_PENDING = "pending" +STATUS_AMBIGUOUS = "ambiguous" +STATUS_UNMATCHED = "unmatched" +STATUS_UNKNOWN_MEMBER = "unknown_member" + +ROLE_GUEST = "guest" +ROLE_SELF = "self" +ROLE_STAFF = "staff" + +TEST_GUESTS = [ + "okada", + "tanaka", + "yamada", + "messykenty", +] + +TEST_CURRENT_USERS = [ + "岡田", + "ikada", + "okada_", + "OKADA", + "tanaka!!!", + "田中", + "やまだ", + "messy_kenty", + "messy.kenty", + "MESSYKENTY", + "山田", +] + + +@dataclass(frozen=True) +class TomlMember: + name: str + role: str = ROLE_GUEST + + +@dataclass +class MatchResult: + toml_name: str + role: str + status: str + vrchat_name: Optional[str] = None + score: Optional[float] = None + second_score: Optional[float] = None + reason: str = "" + candidates: List[Tuple[str, float]] = field(default_factory=list) + + +# ========================= +# ローマ字変換 +# ========================= + +kks = kakasi() + + +def jp_to_romaji(text: str) -> str: + try: + converted = kks.convert(text) + + result = [] + for item in converted: + result.append(item["hepburn"]) + + return "".join(result) + + except Exception as e: + print(f"[ERROR] romaji convert failed: {text} : {e}") + return text + + +# ========================= +# 正規化 +# ========================= + +def normalize_name(name: str) -> str: + """ + 正規化内容: + - 全角半角統一 + - 小文字化 + - 日本語→ローマ字 + - 記号除去 + """ + + # Unicode normalize + name = unicodedata.normalize("NFKC", name) + + # 小文字化 + name = name.lower() + + # 日本語→ローマ字 + name = jp_to_romaji(name) + + # 記号除去 + name = re.sub(r"[^a-z0-9]", "", name) + + return name.strip() + + +# ========================= +# 類似候補検索 +# ========================= + +def find_candidates( + target_guest: str, + current_users: List[str], +) -> List[Tuple[str, float]]: + + normalized_target = normalize_name(target_guest) + + normalized_map = {} + + for user in current_users: + normalized_map[user] = normalize_name(user) + + results = [] + + for original_name, normalized_name in normalized_map.items(): + + score = fuzz.ratio( + normalized_target, + normalized_name, + ) + + results.append((original_name, score)) + + results.sort(key=lambda x: x[1], reverse=True) + + return results + + +def _to_toml_member(member) -> TomlMember: + if isinstance(member, TomlMember): + return member + + if isinstance(member, str): + return TomlMember(name=member) + + if isinstance(member, dict): + return TomlMember( + name=str(member.get("name", "")), + role=str(member.get("role", ROLE_GUEST)), + ) + + name = getattr(member, "name", "") + role = getattr(member, "role", ROLE_GUEST) + + return TomlMember( + name=str(name), + role=str(role), + ) + + +def _make_candidates( + toml_name: str, + current_users: Iterable[str], +) -> List[Tuple[str, float]]: + normalized_toml = normalize_name(toml_name) + results = [] + + for user in current_users: + score = fuzz.ratio( + normalized_toml, + normalize_name(user), + ) + results.append((user, score)) + + results.sort(key=lambda x: x[1], reverse=True) + + return results + + +def _classify_remaining( + member: TomlMember, + current_users: Iterable[str], +) -> MatchResult: + candidates = _make_candidates( + member.name, + current_users, + ) + + if not candidates: + return MatchResult( + toml_name=member.name, + role=member.role, + status=STATUS_UNMATCHED, + reason="no_candidate", + ) + + top_name, top_score = candidates[0] + second_score = candidates[1][1] if len(candidates) >= 2 else 0 + diff = top_score - second_score + + if top_score < WEAK_MATCH_SCORE: + status = STATUS_UNMATCHED + reason = "weak_candidate" + elif diff < AMBIGUOUS_DIFF: + status = STATUS_AMBIGUOUS + reason = "close_candidates" + else: + status = STATUS_PENDING + reason = "below_auto_link_threshold" + + return MatchResult( + toml_name=member.name, + role=member.role, + status=status, + vrchat_name=top_name, + score=top_score, + second_score=second_score, + reason=reason, + candidates=candidates, + ) + + +def _best_fuzzy_match( + member: TomlMember, + current_users: Iterable[str], + min_score: float, +) -> Optional[MatchResult]: + candidates = _make_candidates( + member.name, + current_users, + ) + + if not candidates: + return None + + top_name, top_score = candidates[0] + second_score = candidates[1][1] if len(candidates) >= 2 else 0 + + if ( + top_score >= min_score + and top_score - second_score >= AMBIGUOUS_DIFF + ): + return MatchResult( + toml_name=member.name, + role=member.role, + status=STATUS_MATCHED, + vrchat_name=top_name, + score=top_score, + second_score=second_score, + reason="fuzzy", + candidates=candidates, + ) + + return None + + +def _apply_fuzzy_round( + remaining_members: Dict[str, TomlMember], + remaining_users: set, + min_score: float, + status: str, + reason: str, + eligible_roles: Optional[set] = None, +) -> List[MatchResult]: + results = [] + + while True: + possible_matches = [] + + for key, member in remaining_members.items(): + if ( + eligible_roles is not None + and member.role not in eligible_roles + ): + continue + + match = _best_fuzzy_match( + member, + remaining_users, + min_score, + ) + + if match is None: + continue + + possible_matches.append((key, match)) + + if not possible_matches: + break + + possible_matches.sort( + key=lambda x: ( + x[1].score if x[1].score is not None else 0, + (x[1].score or 0) - (x[1].second_score or 0), + ), + reverse=True, + ) + + key, match = possible_matches[0] + match.status = status + match.reason = reason + + results.append(match) + remaining_members.pop(key) + remaining_users.remove(match.vrchat_name) + + return results + + +def match_username_sets( + toml_members, + current_users: List[str], +) -> Tuple[List[MatchResult], List[str]]: + """ + TOML登録名とVRChat現在ユーザーを段階的に1対1照合する。 + + 戻り値: + - TOML側の照合結果 + - TOMLに紐づかなかったVRChat名 + """ + + members = [_to_toml_member(member) for member in toml_members] + remaining_members = { + f"{idx}:{member.name}": member + for idx, member in enumerate(members) + } + remaining_users = set(current_users) + results = [] + + # 1. 完全一致: fuzzy判定せず即確定して候補から除外 + for key, member in list(remaining_members.items()): + if member.name in remaining_users: + results.append( + MatchResult( + toml_name=member.name, + role=member.role, + status=STATUS_MATCHED, + vrchat_name=member.name, + score=100, + second_score=None, + reason="exact", + ) + ) + remaining_members.pop(key) + remaining_users.remove(member.name) + + # 2. 正規化一致: 一意に対応できるものだけを確定して除外 + normalized_members: Dict[str, List[str]] = {} + normalized_users: Dict[str, List[str]] = {} + + for key, member in remaining_members.items(): + normalized_members.setdefault( + normalize_name(member.name), + [], + ).append(key) + + for user in remaining_users: + normalized_users.setdefault( + normalize_name(user), + [], + ).append(user) + + for normalized_name, member_keys in normalized_members.items(): + users = normalized_users.get(normalized_name, []) + + if len(member_keys) != 1 or len(users) != 1: + continue + + key = member_keys[0] + user = users[0] + member = remaining_members[key] + + results.append( + MatchResult( + toml_name=member.name, + role=member.role, + status=STATUS_MATCHED, + vrchat_name=user, + score=100, + second_score=None, + reason="normalized", + ) + ) + remaining_members.pop(key) + remaining_users.remove(user) + + # 3. 高信頼fuzzy: 残った候補だけで、確定ごとに候補集合を縮める + results.extend( + _apply_fuzzy_round( + remaining_members, + remaining_users, + AUTO_LINK_SCORE, + STATUS_MATCHED, + "high_confidence_fuzzy", + ) + ) + + # 4. 低信頼guest再選別: guestのみ、候補集合が少ない場合だけ + if len(remaining_members) <= LOW_CONFIDENCE_MAX_REMAINING: + results.extend( + _apply_fuzzy_round( + remaining_members, + remaining_users, + LOW_CONFIDENCE_SCORE, + STATUS_MATCHED_LOW_CONFIDENCE, + "guest_low_confidence_fuzzy", + eligible_roles={ROLE_GUEST}, + ) + ) + + # 5. 残りは無理に確定しない + for member in remaining_members.values(): + results.append( + _classify_remaining( + member, + remaining_users, + ) + ) + + unknown_users = sorted(remaining_users) + + return results, unknown_users + + +# ========================= +# 判定 +# ========================= + +def evaluate_guest_match( + guest_name: str, + current_users: List[str], +): + + print("") + print("=" * 60) + print(f"[CHECK] guest={guest_name}") + + normalized_guest = normalize_name(guest_name) + + print(f"[INFO] normalized_guest={normalized_guest}") + + candidates = find_candidates( + guest_name, + current_users, + ) + + if not candidates: + print("[ERROR] no candidates") + return + + print("") + print("[CHECK] candidate list") + + for idx, (name, score) in enumerate(candidates[:5], start=1): + print(f" {idx}. {name:<20} score={score:.2f}") + + top_name, top_score = candidates[0] + + second_score = 0 + + if len(candidates) >= 2: + second_score = candidates[1][1] + + diff = top_score - second_score + + print("") + print(f"[INFO] top_score={top_score:.2f}") + print(f"[INFO] second_score={second_score:.2f}") + print(f"[INFO] diff={diff:.2f}") + + # 自動確定 + if ( + top_score >= AUTO_LINK_SCORE + and diff >= AMBIGUOUS_DIFF + ): + print("") + print("[INFO] AUTO LINK SAFE") + print(f"[INFO] linked => {top_name}") + return + + # 曖昧 + print("") + print("[WARN] ambiguous match") + print("[WARN] auto link disabled") + + print("") + print("[ACTION] manual review required") + + +# ========================= +# main +# ========================= + +def main(): + + print("[CHECK] normalize preview") + + for user in TEST_CURRENT_USERS: + print( + f"{user:<20}" + f" => " + f"{normalize_name(user)}" + ) + + print("") + print("[CHECK] username sync ordered matching test") + + results, unknown_users = match_username_sets( + TEST_GUESTS, + TEST_CURRENT_USERS, + ) + + for result in results: + if result.vrchat_name is None: + print( + f"{result.toml_name:<20}" + f" => " + f"{result.status:<22}" + f" reason={result.reason}" + ) + continue + + score_text = "" + if result.score is not None: + score_text = f" score={result.score:.2f}" + + second_text = "" + if result.second_score is not None: + second_text = f" second={result.second_score:.2f}" + + print( + f"{result.toml_name:<20}" + f" => " + f"{result.vrchat_name:<20}" + f" {result.status:<22}" + f" reason={result.reason}" + f"{score_text}" + f"{second_text}" + ) + + for user in unknown_users: + print( + f"{user:<20}" + f" => " + f"{STATUS_UNKNOWN_MEMBER}" + ) + + print("") + print("[INFO] completed") + + +if __name__ == "__main__": + + print("[CHECK] start") + + answer = input( + "[ACTION] run fuzzy test? (yes/no): " + ).strip().lower() + + if answer != "yes": + print("[FATAL] cancelled") + input("Press Enter to exit...") + sys.exit(1) + + main()