Initial VRC OSC commit

This commit is contained in:
every_holiday
2026-06-04 00:46:37 +09:00
commit 1e9a77b90d
22 changed files with 2831 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
config/config.toml
config/secrets.toml
config/guests.txt
runtime/
.vrchat_cookie.txt
src/.vrchat_cookie.txt
*.bak
*.bak_*
backup_*/

7
bin/vrc_osc.ps1 Normal file
View File

@@ -0,0 +1,7 @@
$ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$RootDir = Resolve-Path (Join-Path $ScriptDir "..")
$EntryPoint = Join-Path $RootDir "src\app.py"
python $EntryPoint

View File

@@ -0,0 +1,22 @@
[self]
name = ""
[staff]
names = []
[guest]
file = "config/guests.txt"
[notice]
missing_count = 0
[ocr.crop]
left = 0.05
top = 0.35
right = 0.95
bottom = 0.95
[ocr]
preprocess = true
scale = 1.5
use_angle_cls = false

View File

@@ -0,0 +1,3 @@
GuestUserA
GuestUserB
GuestUserC

View File

@@ -0,0 +1,6 @@
[discord]
webhook_url = ""
[vrchat]
username = ""
password = ""

237
readme.txt Normal file
View File

@@ -0,0 +1,237 @@
# VRC_OSC
VRChat の OSC パラメータを受けて、Discord ミュート、VRChat ログ監視、OCR、翻訳を実行するローカルツール。
## 起動
依存関係を入れる。
```powershell
python -m pip install -r requirements.txt
```
PowerShell から起動する。
```powershell
bin\vrc_osc.ps1
```
Python から直接起動する場合。
```powershell
python src\app.py
```
OSC は `127.0.0.1:9001` で待ち受ける。
## 構成
```text
bin/ PowerShell 起動スクリプト
config/ example とローカル設定置き場
src/app.py アプリ本体のエントリ
src/common/ パス、設定ロード、runtime ログなどの共通基盤
src/discord_control/ Discord ウィンドウ操作
src/ocr/ OCR 実行
src/osc/ OSC gateway
src/screen/ VRChat ウィンドウ検出とキャプチャ
src/tools/ 単体実行用の補助ツール
src/translate/ 翻訳
src/vrc_log/ VRChat ログ解析と名簿照合
```
## 設定ファイル
実設定は `config/config.toml` に置く。このファイルは Git に入れない。
雛形は `config/config.example.toml`。
```toml
[self]
name = "自分のVRChat表示名"
[staff]
names = [
"StaffUserA"
]
[guest]
file = "config/guests.txt"
[notice]
missing_count = 3
[ocr.crop]
left = 0.05
top = 0.35
right = 0.95
bottom = 0.95
[ocr]
preprocess = true
scale = 1.5
use_angle_cls = false
```
`self.name` は Discord 自動ミュート判定に使う。
`staff.names` は表示対象だが guest 人数には含めない。
`guest.file` は guest 名簿ファイル。guest 人数の母数と在室判定に使う。
`notice.missing_count` は「あと何人以内で全員集合か」の通知範囲。例えば guest が 23 人で `missing_count = 3` の場合、20〜22 人のときに未入室 guest を出す。
OCR 設定は速度と精度の調整用。
`ocr.crop` は VRChat ウィンドウ内の相対座標。`0.0` から `1.0` で指定する。デフォルトは画面下側の中央寄りだけを読む。
フル画面 OCR に戻す場合。
```toml
[ocr.crop]
left = 0
top = 0
right = 1
bottom = 1
```
`ocr.preprocess = true` はグレースケール化、コントラスト補正、シャープ化、拡大を行う。
`ocr.scale` は前処理時の拡大率。大きいほど小さい文字に強くなるが遅くなる。
`ocr.use_angle_cls = false` は角度分類を切って高速化する。縦書きや回転文字を読む必要がある場合だけ `true` にする。
## guest 名簿
`config/guests.txt` に 1 行 1 名で書く。このファイルは Git に入れない。
雛形は `config/guests.example.txt`。
```text
GuestUserA
GuestUserB
GuestUserC
```
空行と `#` で始まる行は無視する。
## 秘密情報
秘密情報は `config/secrets.toml` に置く。このファイルは Git に入れない。
雛形は `config/secrets.example.toml`。
```toml
[discord]
webhook_url = ""
[vrchat]
username = ""
password = ""
```
`discord.webhook_url` は VRC ログ結果の Discord Webhook 通知に使う。
`vrchat.username` / `vrchat.password` は `src/tools/resolve_vrchat_user.py` を使う場合だけ必要。
## OSC パラメータ
Avatar Parameters 側の名前と一致させる。
| Parameter | 動作 |
| --- | --- |
| `DiscordSend` | 押した瞬間に Discord の mute hotkey を送る |
| `vrc_log` | VRC ログ監視の on/off |
| `ocrEnabled` | VRChat 画面を撮って OCR |
| `translation` | OCR して日本語翻訳 |
## VRC ログ監視
`vrc_log` を押すと、現在の VRChat ログを一度解析して Discord Webhook に送る。その後はログファイル末尾を追い、新しい join / leave / world 系ログが追記されたタイミングで即再解析する。
以前のような `output_log = 10` の秒数ポーリング設定は使わない。
解析対象は昼 12:00 以降。実行時刻が昼 12:00 前なら前日 12:00 以降を見る。
インスタンスが変わったら在室状態はリセットする。
出力例。
```text
ワールド入室: Midnight Rooftop
現在Guest: 0/3
[01:20] [join] GuestUserA 1/3
[01:21] [join] GuestUserB 2/3
[01:25] [leave] GuestUserA 1/3
[01:26] [join] [staff] StaffUserA 1/3
```
notice 条件に入った場合。
```text
現在Guest: 2/3
未入室Guest:
- GuestUserC
```
## Discord 自動ミュート
常時 self monitor が VRChat ログの新規追記だけを見る。
新しく追記されたログの中で、自分の最後のイベントが `OnPlayerJoined` なら Discord を mute、`OnPlayerLeft` なら unmute する。
過去ログ全体の最後が self leave / join だっただけでは反応しない。
`setDiscordMuted()` にはロックがあり、同時実行で mute hotkey が連続送信されないようにしている。
## OCR / 翻訳
`ocrEnabled` は VRChat ウィンドウを撮影して PaddleOCR にかける。結果はクリップボードへコピーする。
`translation` は OCR 結果を翻訳し、翻訳結果をクリップボードへコピーする。
OCR はデフォルトで画面全体ではなく、画面下側の中央寄りを切り出して読む。会話ログや字幕を読む用途では速く、余計な UI 文字を拾いにくい。
## runtime
新規の実行ログは基本的に以下へまとめる。
```text
runtime/runtime.log
runtime/latest_ocr.png
runtime/latest_screenshot.png
```
`runtime/` は Git に入れない。
OCR text、翻訳 text、VRC ログ結果は `runtime/runtime.log` に追記する。
画像は最新ファイルに上書きする。日時別ファイルを増やさない。
## ユーザー名照合
`src/vrc_log/member_name_matcher.py` は TOML 登録名と VRChat 表示名を安全な順番で紐づける補助実装。
照合順。
1. 完全一致
2. 正規化一致
3. 高信頼 fuzzy
4. guest のみ低信頼 fuzzy
5. pending / ambiguous / unmatched
完全一致・正規化一致で確定した名前は fuzzy 候補から除外する。
## Git に入れないもの
以下はローカル専用。
- `config/config.toml`
- `config/secrets.toml`
- `config/guests.txt`
- `runtime/`
- `__pycache__/`
- `*.pyc`
- `.vrchat_cookie.txt`
秘密情報を含む可能性があるものはコミットしない。

10
requirements.txt Normal file
View File

@@ -0,0 +1,10 @@
deep-translator
paddleocr
pillow
pyautogui
pygetwindow
pykakasi
pyperclip
python-osc
rapidfuzz
vrchatapi

114
src/app.py Normal file
View File

@@ -0,0 +1,114 @@
import os
from pathlib import Path
import sys
os.environ["FLAGS_enable_pir_api"] = "0"
os.environ["FLAGS_use_onednn"] = "0"
os.environ["FLAGS_use_mkldnn"] = "0"
os.environ["FLAGS_use_onednn_bfloat16"] = "0"
os.environ["ONEDNN_VERBOSE"] = "0"
SRC_DIR = Path(__file__).resolve().parents[1]
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
from osc.gateway import VrcOscGateway
from discord_control.actions import setDiscordMute
from ocr.ocr_actions import runOcrFromScreen
from translate.translate_actions import saveTranslationText
from translate.translate_actions import translateTextToJapanese
from vrc_log.log_actions import collectVrchatLog
from vrc_log.log_actions import startSelfMonitor
from vrc_log.log_actions import startVrcLogMonitor
from vrc_log.log_actions import stopSelfMonitor
from vrc_log.log_actions import stopVrcLogMonitor
PARAM_DISCORD_MUTE = "DiscordSend"
PARAM_LOG_GREP = "vrc_log"
PARAM_OCR = "ocrEnabled"
PARAM_TRANSLATE_OCR = "translation"
def create_gateway():
gateway = VrcOscGateway(host="127.0.0.1", port=9001)
def on_discord_mute(address, *args):
if not args:
gateway.log("ERROR", "DiscordSend args empty")
return
if gateway.is_rising_edge(address, args[0]):
gateway.log("INFO", f"received {address} args={args}")
setDiscordMute()
vrc_log_enabled = False
def on_log_grep(address, *args):
nonlocal vrc_log_enabled
if not args:
gateway.log("ERROR", "vrc_log args empty")
return
if gateway.is_rising_edge(address, args[0]):
vrc_log_enabled = not vrc_log_enabled
gateway.log("INFO", f"received {address} args={args} enabled={vrc_log_enabled}")
if vrc_log_enabled:
collectVrchatLog(notify_changed=True)
startVrcLogMonitor()
else:
stopVrcLogMonitor()
def on_ocr(address, *args):
if not args:
gateway.log("ERROR", "ocrEnabled args empty")
return
if gateway.is_rising_edge(address, args[0]):
gateway.log("INFO", f"received {address} args={args}")
runOcrFromScreen()
def on_translate_ocr(address, *args):
if not args:
gateway.log("ERROR", "translateOcr args empty")
return
if gateway.is_rising_edge(address, args[0]):
gateway.log("INFO", f"received {address} args={args}")
result = runOcrFromScreen()
if not result:
gateway.log("ERROR", "OCR result is None")
return
text = result.get("text", "")
if not text.strip():
gateway.log("INFO", "OCR text is empty. translation skipped")
return
translated = translateTextToJapanese(text)
if not translated.strip():
gateway.log("INFO", "translation result is empty")
return
image_path = result.get("image_path")
stem = image_path.stem if image_path else "translation"
saved = saveTranslationText(stem, translated)
gateway.log("INFO", f"translation saved={saved}")
gateway.map(PARAM_DISCORD_MUTE, on_discord_mute)
gateway.map(PARAM_LOG_GREP, on_log_grep)
gateway.map(PARAM_OCR, on_ocr)
gateway.map(PARAM_TRANSLATE_OCR, on_translate_ocr)
gateway.set_default_debug(enabled=False)
return gateway
def main():
gateway = create_gateway()
startSelfMonitor()
gateway.serve_forever()
if __name__ == "__main__":
main()

130
src/common/config_loader.py Normal file
View File

@@ -0,0 +1,130 @@
from pathlib import Path
import tomllib
from common.project_paths import ROOT_DIR
CONFIG_DIR = ROOT_DIR / "config"
CONFIG_FILE = CONFIG_DIR / "config.toml"
SECRETS_FILE = CONFIG_DIR / "secrets.toml"
DEFAULT_EVENT_FILE = CONFIG_DIR / "event.toml"
DEFAULT_GUEST_FILE = CONFIG_DIR / "guests.txt"
def loadTomlFile(path):
if not path.exists():
return {}
return tomllib.loads(path.read_text(encoding="utf-8"))
def resolveConfigPath(value, default_path):
if not value:
return default_path
candidate = Path(value)
if candidate.is_absolute():
return candidate
return (ROOT_DIR / candidate).resolve()
def loadNameListFile(path):
if not path.exists():
return []
names = []
for line in path.read_text(encoding="utf-8-sig", errors="replace").splitlines():
name = line.strip()
if not name or name.startswith("#"):
continue
names.append(name)
return names
def loadSecretConfig():
return loadTomlFile(SECRETS_FILE)
def getSecretValue(section_name, key, default=""):
secrets = loadSecretConfig()
section = secrets.get(section_name, {})
return str(section.get(key, default)).strip()
def loadOcrConfig():
config = loadTomlFile(CONFIG_FILE)
ocr = config.get("ocr", {})
crop = ocr.get("crop", {})
return {
"crop_left": float(crop.get("left", 0.05)),
"crop_top": float(crop.get("top", 0.35)),
"crop_right": float(crop.get("right", 0.95)),
"crop_bottom": float(crop.get("bottom", 0.95)),
"preprocess": bool(ocr.get("preprocess", True)),
"scale": float(ocr.get("scale", 1.5)),
"use_angle_cls": bool(ocr.get("use_angle_cls", False)),
}
def loadVrcLogConfig():
config = loadTomlFile(CONFIG_FILE)
vrc_log = config.get("vrc_log", {})
self_section = config.get("self", {})
event_file_value = (
vrc_log.get("event_toml")
or vrc_log.get("event_file")
or config.get("event_toml")
or config.get("event_file")
)
event_file = resolveConfigPath(event_file_value, DEFAULT_EVENT_FILE)
event_config = loadTomlFile(event_file)
if not event_config:
event_config = vrc_log or config
guest_section = event_config.get("guest", {})
guest_names = guest_section.get("names", [])
guest_file_value = (
guest_section.get("file")
or event_config.get("guest_file")
or config.get("guest_file")
)
guest_file = resolveConfigPath(guest_file_value, DEFAULT_GUEST_FILE)
if not guest_names:
guest_names = loadNameListFile(guest_file)
staff_names = event_config.get("staff", {}).get("names", [])
if not guest_names and isinstance(event_config.get("guest_names"), list):
guest_names = event_config.get("guest_names", [])
if not staff_names and isinstance(event_config.get("staff_names"), list):
staff_names = event_config.get("staff_names", [])
notice_section = event_config.get("notice", {})
missing_count = notice_section.get("missing_count", 0)
if not missing_count:
missing_count = event_config.get("missing_count", 0)
self_name = (
self_section.get("name")
or config.get("self_name")
or vrc_log.get("self_name")
or event_config.get("self", {}).get("name")
or ""
)
return {
"config_file": CONFIG_FILE,
"event_file": event_file,
"guest_file": guest_file,
"guest_names": [str(name).strip() for name in guest_names if str(name).strip()],
"staff_names": [str(name).strip() for name in staff_names if str(name).strip()],
"missing_count": int(missing_count),
"self_name": str(self_name).strip(),
}

View File

@@ -0,0 +1,6 @@
from pathlib import Path
SRC_DIR = Path(__file__).resolve().parents[1]
ROOT_DIR = SRC_DIR.parent
RUNTIME_DIR = ROOT_DIR / "runtime"
RUNTIME_LOG_FILE = RUNTIME_DIR / "runtime.log"

19
src/common/runtime_log.py Normal file
View File

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

View File

@@ -0,0 +1,203 @@
from datetime import datetime
import ctypes
import threading
import time
import pyautogui
import pygetwindow as gw
BROWSER_KEYWORDS = [
"google chrome",
"microsoft edge",
"mozilla firefox",
]
_discord_muted = True
_discord_mute_lock = threading.Lock()
def log(level, message):
now = datetime.now().strftime("%H:%M:%S")
print(f"[{level}] {now} {message}", flush=True)
def findDiscordWindow():
candidates = []
for window in gw.getAllWindows():
title = (window.title or "").strip()
if not title:
continue
if "discord" not in title.lower():
continue
candidates.append(window)
if not candidates:
raise RuntimeError("Discordウィンドウが見つかりません")
candidates.sort(key=lambda window: -(window.width * window.height))
return candidates[0]
def findBrowserWindow():
candidates = []
for window in gw.getAllWindows():
title = (window.title or "").strip()
if not title:
continue
lowered = title.lower()
if not any(keyword in lowered for keyword in BROWSER_KEYWORDS):
continue
candidates.append(window)
if not candidates:
raise RuntimeError("ブラウザウィンドウが見つかりません")
candidates.sort(key=lambda window: -(window.width * window.height))
return candidates[0]
def findDiscordBrowserWindow():
candidates = []
for window in gw.getAllWindows():
title = (window.title or "").strip()
if not title:
continue
lowered = title.lower()
if "discord" not in lowered:
continue
if not any(keyword in lowered for keyword in BROWSER_KEYWORDS):
continue
candidates.append(window)
if not candidates:
return None
candidates.sort(key=lambda window: -(window.width * window.height))
return candidates[0]
def activateDiscordWindow():
window = findDiscordWindow()
hwnd = getattr(window, "_hWnd", None)
try:
if window.isMinimized:
window.restore()
except Exception:
pass
if hwnd:
try:
ctypes.windll.user32.ShowWindow(hwnd, 9)
except Exception:
pass
try:
window.activate()
except Exception as e:
log("ERROR", f"Discord activate() failed detail={e}")
if hwnd:
try:
ctypes.windll.user32.BringWindowToTop(hwnd)
ctypes.windll.user32.SetForegroundWindow(hwnd)
except Exception as e:
log("ERROR", f"Discord SetForegroundWindow failed detail={e}")
time.sleep(0.15)
log("INFO", f"Discord window title={window.title}")
return window
def findVrchatWindow():
candidates = []
for window in gw.getAllWindows():
title = (window.title or "").strip()
if not title:
continue
lowered = title.lower()
if lowered == "vrchat":
candidates.append((0, window))
continue
if lowered.startswith("vrchat "):
candidates.append((1, window))
continue
if lowered.startswith("vrchat"):
candidates.append((2, window))
if not candidates:
raise RuntimeError("VRChatウィンドウが見つかりません")
candidates.sort(key=lambda item: (item[0], -(item[1].width * item[1].height)))
return candidates[0][1]
def activateWindow(window, label):
if not window:
return
hwnd = getattr(window, "_hWnd", None)
try:
if window.isMinimized:
window.restore()
except Exception:
pass
if hwnd:
try:
ctypes.windll.user32.ShowWindow(hwnd, 9)
except Exception:
pass
try:
window.activate()
except Exception as e:
log("ERROR", f"{label} activate() failed detail={e}")
if hwnd:
try:
ctypes.windll.user32.BringWindowToTop(hwnd)
ctypes.windll.user32.SetForegroundWindow(hwnd)
except Exception as e:
log("ERROR", f"{label} SetForegroundWindow failed detail={e}")
time.sleep(0.15)
try:
pyautogui.click(window.left + window.width // 2, window.top + window.height // 2)
except Exception:
pass
time.sleep(0.1)
log("INFO", f"{label} window title={window.title}")
def setDiscordMute():
log("ACTION", "setDiscordMute called")
try:
activateDiscordWindow()
except Exception as e:
log("ERROR", f"Discord window unavailable detail={e}")
browser = findDiscordBrowserWindow() or findBrowserWindow()
activateWindow(browser, "Browser")
log("INFO", "Discord hotkey Ctrl+Shift+M")
pyautogui.hotkey("ctrl", "shift", "m")
activateWindow(findVrchatWindow(), "VRChat")
def setDiscordMuted(muted):
global _discord_muted
muted = bool(muted)
with _discord_mute_lock:
if _discord_muted == muted:
log("INFO", f"Discord mute already {'on' if muted else 'off'}")
return
setDiscordMute()
_discord_muted = muted

186
src/ocr/ocr_actions.py Normal file
View File

@@ -0,0 +1,186 @@
import os
from datetime import datetime
os.environ["FLAGS_enable_pir_api"] = "0"
os.environ["FLAGS_use_onednn"] = "0"
os.environ["FLAGS_use_mkldnn"] = "0"
os.environ["FLAGS_use_onednn_bfloat16"] = "0"
os.environ["MKLDNN_DISABLE_WORKSPACE"] = "1"
os.environ["ONEDNN_VERBOSE"] = "0"
import pyperclip
from paddleocr import PaddleOCR
from PIL import Image
from PIL import ImageFilter
from PIL import ImageOps
from common.config_loader import loadOcrConfig
from common.runtime_log import appendRuntimeLog
from screen.screen_actions import captureOcrRegion
_ocr_engine = None
def log(level, message):
now = datetime.now().strftime("%H:%M:%S")
print(f"[{level}] {now} {message}", flush=True)
def getOcrEngine():
global _ocr_engine
if _ocr_engine is None:
log("INFO", "PaddleOCR initializing")
config = loadOcrConfig()
options = {
"lang": "japan",
"use_angle_cls": config["use_angle_cls"],
"show_log": False,
}
try:
_ocr_engine = PaddleOCR(**options)
except (TypeError, ValueError):
options.pop("show_log", None)
_ocr_engine = PaddleOCR(**options)
return _ocr_engine
def saveOcrText(image_path, text):
return appendRuntimeLog(
"OCR TEXT",
f"image={image_path}\n{text}",
)
def extractTextFromAny(obj):
lines = []
def walk(x):
if x is None:
return
if isinstance(x, dict):
for key in ("rec_texts", "texts"):
value = x.get(key)
if isinstance(value, list):
for item in value:
text = str(item).strip()
if text:
lines.append(text)
for key in ("text", "rec_text"):
value = x.get(key)
if isinstance(value, str) and value.strip():
lines.append(value.strip())
for value in x.values():
walk(value)
return
if isinstance(x, (list, tuple)):
if len(x) >= 2 and isinstance(x[1], (list, tuple)) and len(x[1]) >= 1:
candidate = x[1][0]
if isinstance(candidate, str) and candidate.strip():
lines.append(candidate.strip())
for item in x:
walk(item)
walk(obj)
unique_lines = []
seen = set()
for line in lines:
if line not in seen:
unique_lines.append(line)
seen.add(line)
return "\n".join(unique_lines)
def printOcrResult(text):
cleaned = text.strip()
if not cleaned:
log("INFO", "OCR結果は空です")
return
log("CHECK", "OCR結果 full")
for line in cleaned.splitlines():
print(line, flush=True)
def preprocessOcrImage(image_path):
config = loadOcrConfig()
enabled = config["preprocess"]
if not enabled:
return image_path
try:
image = Image.open(image_path)
image = image.convert("L")
image = ImageOps.autocontrast(image)
image = image.filter(ImageFilter.SHARPEN)
scale = max(1.0, min(2.0, config["scale"]))
if scale > 1.0:
width, height = image.size
resampling = getattr(
getattr(Image, "Resampling", Image),
"LANCZOS",
)
image = image.resize(
(int(width * scale), int(height * scale)),
resampling,
)
image.save(image_path)
log("INFO", f"OCR image preprocessed={image_path}")
except Exception as e:
log("ERROR", f"OCR preprocess failed detail={e}")
return image_path
def runPaddleOcr(engine, image_path):
if hasattr(engine, "predict"):
return engine.predict(str(image_path))
return engine.ocr(str(image_path))
def runOcrFromImage(image_path, show_result=True):
log("ACTION", f"PaddleOCR start image={image_path}")
try:
image_path = preprocessOcrImage(image_path)
engine = getOcrEngine()
result = runPaddleOcr(engine, image_path)
except Exception as e:
log("ERROR", f"PaddleOCR failed detail={e}")
return None
text = extractTextFromAny(result)
text_path = saveOcrText(image_path, text)
try:
pyperclip.copy(text)
log("INFO", "OCR結果をクリップボードへコピーしました")
except Exception as e:
log("ERROR", f"clipboard copy failed detail={e}")
log("INFO", f"OCR text appended={text_path}")
if show_result:
printOcrResult(text)
return {
"image_path": image_path,
"text_path": text_path,
"text": text,
}
def runOcrFromScreen():
log("ACTION", "runOcrFromScreen called")
image_path = captureOcrRegion()
return runOcrFromImage(image_path, show_result=True)

85
src/osc/gateway.py Normal file
View File

@@ -0,0 +1,85 @@
from pythonosc.dispatcher import Dispatcher
from pythonosc.osc_server import BlockingOSCUDPServer
from datetime import datetime
import sys
class VrcOscGateway:
def __init__(self, host="127.0.0.1", port=9001):
self.host = host
self.port = port
self.dispatcher = Dispatcher()
self.last_values = {}
def log(self, level, message):
now = datetime.now().strftime("%H:%M:%S")
print(f"[{level}] {now} {message}", flush=True)
def to_bool(self, value):
if isinstance(value, bool):
return value
if isinstance(value, int) or isinstance(value, float):
return value != 0
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in ("true", "1", "on"):
return True
if lowered in ("false", "0", "off"):
return False
raise ValueError(f"unsupported OSC value: {value}")
def is_rising_edge(self, address, value):
current = self.to_bool(value)
previous = self.last_values.get(address, False)
self.last_values[address] = current
return current and not previous
def is_changed(self, address, value):
current = self.to_bool(value)
previous = self.last_values.get(address, None)
if previous == current:
return False
self.last_values[address] = current
return True
def map(self, parameter_name, handler):
address = f"/avatar/parameters/{parameter_name}"
self.dispatcher.map(address, handler)
self.log("INFO", f"mapped {address}")
def set_default_debug(self, enabled=False):
if not enabled:
return
def on_any(address, *args):
if address.startswith("/avatar/parameters/"):
self.log("INFO", f"other address={address} args={args}")
self.dispatcher.set_default_handler(on_any)
def serve_forever(self):
self.log("CHECK", "STEP=1 状態確認")
self.log("INFO", f"listen={self.host}:{self.port}")
self.log("CHECK", "STEP=2 想定状態判定")
self.log("INFO", "VRChat OSC Enabled")
self.log("INFO", "Python は Windows PowerShell 側で実行")
self.log("INFO", "Unity側 MA Parameters と OSC名が一致していること")
try:
server = BlockingOSCUDPServer((self.host, self.port), self.dispatcher)
server.serve_forever()
except KeyboardInterrupt:
self.log("INFO", "Ctrl+C detected")
except Exception as e:
self.log("FATAL", f"OSC server error STEP=3 detail={e}")
input("Press Enter to exit...")
sys.exit(1)
self.log("CHECK", "STEP=4 結果確認")
self.log("INFO", "終了しました")

View File

@@ -0,0 +1,167 @@
import pyautogui
import pygetwindow as gw
from common.config_loader import loadOcrConfig
from common.project_paths import RUNTIME_DIR
SCREENSHOT_PATH = RUNTIME_DIR / "latest_screenshot.png"
OCR_SCREENSHOT_PATH = RUNTIME_DIR / "latest_ocr.png"
EXCLUDED_WINDOW_KEYWORDS = [
"Google Chrome",
"Microsoft Edge",
"Mozilla Firefox",
"Visual Studio Code",
"PowerShell",
"Windows Terminal",
"ChatGPT",
]
def log(level, message):
from datetime import datetime
now = datetime.now().strftime("%H:%M:%S")
print(f"[{level}] {now} {message}", flush=True)
def findVrchatWindow():
windows = gw.getAllWindows()
candidates = []
for window in windows:
title = window.title or ""
normalized = title.strip().lower()
if not normalized:
continue
if any(keyword.lower() in normalized for keyword in EXCLUDED_WINDOW_KEYWORDS):
continue
if normalized == "vrchat":
candidates.append((0, window))
continue
if normalized.startswith("vrchat "):
candidates.append((1, window))
continue
if normalized.startswith("vrchat"):
candidates.append((2, window))
if not candidates:
visible_titles = []
for window in windows:
title = (window.title or "").strip()
if title:
visible_titles.append(title)
log("ERROR", "VRChatウィンドウが見つかりません")
log("CHECK", "現在検出できるウィンドウ")
for title in visible_titles[:30]:
print(title, flush=True)
raise RuntimeError("VRChatウィンドウが見つかりません")
candidates = sorted(
candidates,
key=lambda item: (item[0], -(item[1].width * item[1].height)),
)
window = candidates[0][1]
if window.width <= 0 or window.height <= 0:
raise RuntimeError("VRChatウィンドウサイズが不正です")
return window
def getVrchatRegion():
window = findVrchatWindow()
try:
if window.isMinimized:
window.restore()
except Exception:
pass
left = int(window.left)
top = int(window.top)
width = int(window.width)
height = int(window.height)
if width <= 0 or height <= 0:
raise RuntimeError("VRChatウィンドウ範囲が不正です")
log("INFO", f"VRChat window title={window.title}")
log("INFO", f"VRChat region left={left} top={top} width={width} height={height}")
return left, top, width, height
def clampRatio(value):
return max(0.0, min(1.0, value))
def getOcrRegion():
left, top, width, height = getVrchatRegion()
config = loadOcrConfig()
# Default to the lower central area. It is much faster than OCRing the
# whole VRChat window and usually contains chat/subtitle text.
crop_left = clampRatio(config["crop_left"])
crop_top = clampRatio(config["crop_top"])
crop_right = clampRatio(config["crop_right"])
crop_bottom = clampRatio(config["crop_bottom"])
if crop_right <= crop_left:
crop_left = 0.0
crop_right = 1.0
if crop_bottom <= crop_top:
crop_top = 0.0
crop_bottom = 1.0
region_left = left + int(width * crop_left)
region_top = top + int(height * crop_top)
region_width = max(1, int(width * (crop_right - crop_left)))
region_height = max(1, int(height * (crop_bottom - crop_top)))
log(
"INFO",
f"OCR crop left={region_left} top={region_top} width={region_width} height={region_height}",
)
return region_left, region_top, region_width, region_height
def captureRegionTo(image_path, region):
image_path.parent.mkdir(parents=True, exist_ok=True)
left, top, width, height = region
image = pyautogui.screenshot(region=(left, top, width, height))
image.save(image_path)
log("ACTION", f"captureVrchatWindow saved={image_path}")
return image_path
def captureVrchatWindowTo(image_path):
return captureRegionTo(
image_path,
getVrchatRegion(),
)
def captureVrchatWindow():
return captureVrchatWindowTo(SCREENSHOT_PATH)
def captureOcrRegion():
return captureRegionTo(
OCR_SCREENSHOT_PATH,
getOcrRegion(),
)
def captureScreen():
return captureVrchatWindow()
def takeScreenshot():
return captureVrchatWindow()

View File

@@ -0,0 +1,124 @@
from pythonosc.dispatcher import Dispatcher
from pythonosc.osc_server import BlockingOSCUDPServer
from datetime import datetime
import pyautogui
import time
import sys
HOST = "127.0.0.1"
PORT = 9001
PARAMETER = "/avatar/parameters/DiscordSend"
# Discordの初期状態を「ミュート中」と仮定
discord_muted = True
button_pressed = False
last_action_time = 0.0
DEBOUNCE_SECONDS = 0.3
def log(level, message):
now = datetime.now().strftime("%H:%M:%S")
print(f"[{level}] {now} {message}", flush=True)
def wait_exit():
input("Press Enter to exit...")
def press_discord_mute_hotkey():
pyautogui.hotkey("ctrl", "shift", "m")
def handle_discord_send(address, *args):
global discord_muted
global button_pressed
global last_action_time
if not args:
log("ERROR", "OSC args is empty")
return
value = args[0]
now_ts = time.time()
if isinstance(value, bool):
is_pressed = value
elif isinstance(value, int) or isinstance(value, float):
is_pressed = value != 0
else:
log("ERROR", f"unsupported value type value={value}")
return
if now_ts - last_action_time < DEBOUNCE_SECONDS:
return
# 押した瞬間: ミュート解除
if is_pressed and not button_pressed:
button_pressed = True
if discord_muted:
log("ACTION", "VRChat button pressed -> Discord unmute hotkey")
press_discord_mute_hotkey()
discord_muted = False
last_action_time = now_ts
else:
log("INFO", "already unmuted")
return
# 離した瞬間: ミュート
if not is_pressed and button_pressed:
button_pressed = False
if not discord_muted:
log("ACTION", "VRChat button released -> Discord mute hotkey")
press_discord_mute_hotkey()
discord_muted = True
last_action_time = now_ts
else:
log("INFO", "already muted")
return
def main():
log("CHECK", "STEP=1 OSC受信状態確認")
log("INFO", f"host={HOST}")
log("INFO", f"port={PORT}")
log("INFO", f"parameter={PARAMETER}")
log("CHECK", "STEP=2 想定状態判定")
log("INFO", "Windows PowerShellで実行していること")
log("INFO", "VRChat OSC Enabledであること")
log("INFO", "Discordのミュート切替キーが Ctrl+Shift+M であること")
log("INFO", "Discordの現在状態はミュート中として扱います")
answer = input("[ACTION] 続行しますか? yes/no: ").strip().lower()
if answer != "yes":
if answer == "no":
log("FATAL", "ユーザー操作により停止 STEP=2")
wait_exit()
sys.exit(1)
log("ERROR", "不正入力です STEP=2")
wait_exit()
sys.exit(1)
log("ACTION", "STEP=3 OSC受信開始")
log("INFO", "VRChatのメニューを押している間だけDiscordミュート解除します")
log("INFO", "停止する場合は Ctrl+C")
dispatcher = Dispatcher()
dispatcher.map(PARAMETER, handle_discord_send)
try:
server = BlockingOSCUDPServer((HOST, PORT), dispatcher)
server.serve_forever()
except KeyboardInterrupt:
log("INFO", "Ctrl+C detected")
except Exception as e:
log("FATAL", f"OSC server error STEP=3 detail={e}")
wait_exit()
sys.exit(1)
log("CHECK", "STEP=4 結果確認")
log("INFO", "終了しました")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,21 @@
from pythonosc.dispatcher import Dispatcher
from pythonosc.osc_server import BlockingOSCUDPServer
from datetime import datetime
HOST = "127.0.0.1"
PORT = 9001
def on_any(address, *args):
now = datetime.now().strftime("%H:%M:%S")
print(f"[INFO] {now} address={address} args={args}", flush=True)
dispatcher = Dispatcher()
dispatcher.set_default_handler(on_any)
print("[CHECK] OSC受信開始")
print(f"[INFO] host={HOST}")
print(f"[INFO] port={PORT}")
print("[ACTION] VRChatでメニューを押してください")
server = BlockingOSCUDPServer((HOST, PORT), dispatcher)
server.serve_forever()

View File

@@ -0,0 +1,54 @@
from pythonosc.dispatcher import Dispatcher
from pythonosc.osc_server import BlockingOSCUDPServer
from datetime import datetime
HOST = "127.0.0.1"
PORT = 9001
def log(msg):
print(msg, flush=True)
def on_test(address, *args):
now = datetime.now().strftime("%H:%M:%S")
log("")
log(f"[INFO] time={now}")
log(f"[ACTION] OSC parameter received")
log(f"[INFO] address={address}")
log(f"[INFO] args={args}")
dispatcher = Dispatcher()
dispatcher.map(
"/avatar/parameters/TestPrint",
on_test
)
log("[CHECK] STEP=1 OSC待受開始")
log(f"[INFO] host={HOST}")
log(f"[INFO] port={PORT}")
try:
server = BlockingOSCUDPServer(
(HOST, PORT),
dispatcher
)
log("[INFO] OSC server started")
log("[ACTION] VRChatでTestPrintを押してください")
server.serve_forever()
except KeyboardInterrupt:
log("")
log("[INFO] Ctrl+C detected")
log("[INFO] shutdown")
except Exception as e:
log("")
log("[FATAL] OSC server error")
log(f"[ERROR] detail={e}")
print("")
input("Press Enter to exit...")
raise SystemExit(1)

View File

@@ -0,0 +1,134 @@
import re
from http.cookiejar import MozillaCookieJar
import vrchatapi
from vrchatapi.api import authentication_api
from vrchatapi.api import users_api
from vrchatapi.exceptions import UnauthorizedException
from vrchatapi.models.two_factor_auth_code import TwoFactorAuthCode
from vrchatapi.models.two_factor_email_code import TwoFactorEmailCode
from common.config_loader import getSecretValue
COOKIE_FILE = ".vrchat_cookie.txt"
USER_AGENT = (
"VRC_OSC/1.0 "
"(contact: test@gmail.com)"
)
def get_required_secret(section, key):
value = getSecretValue(section, key)
if not value:
raise RuntimeError(f"{section}.{key} is not set in config/secrets.toml")
return value
def setup_cookie_jar(api_client):
cookie_jar = MozillaCookieJar(COOKIE_FILE)
if os.path.exists(COOKIE_FILE):
try:
cookie_jar.load(
ignore_discard=True,
ignore_expires=True,
)
except Exception:
pass
api_client.rest_client.cookie_jar = cookie_jar
return cookie_jar
def save_cookie_jar(cookie_jar):
try:
cookie_jar.save(
ignore_discard=True,
ignore_expires=True,
)
except Exception:
pass
def login(api_client, cookie_jar):
auth_api = authentication_api.AuthenticationApi(api_client)
try:
current_user = auth_api.get_current_user()
save_cookie_jar(cookie_jar)
return current_user
except UnauthorizedException as e:
reason = str(e.reason)
if "Email 2 Factor Authentication" in reason:
code = input("Email 2FA Code: ").strip()
auth_api.verify2_fa_email_code(
TwoFactorEmailCode(code)
)
elif "2 Factor Authentication" in reason:
code = input("TOTP Code: ").strip()
auth_api.verify2_fa(
TwoFactorAuthCode(code)
)
else:
raise
current_user = auth_api.get_current_user()
save_cookie_jar(cookie_jar)
return current_user
def extract_user_id(text):
match = re.search(
r"(usr_[0-9a-fA-F\-]+)",
text,
)
if not match:
return None
return match.group(1)
def main():
text = input("参加者名: ").strip()
user_id = extract_user_id(text)
if not user_id:
print(text)
return
configuration = vrchatapi.Configuration(
username=get_required_secret("vrchat", "username"),
password=get_required_secret("vrchat", "password"),
)
with vrchatapi.ApiClient(configuration) as api_client:
api_client.user_agent = USER_AGENT
api_client.default_headers["User-Agent"] = USER_AGENT
cookie_jar = setup_cookie_jar(api_client)
login(api_client, cookie_jar)
users = users_api.UsersApi(api_client)
user = users.get_user(user_id)
print(user.display_name)
save_cookie_jar(cookie_jar)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,102 @@
from datetime import datetime
import re
import pyperclip
from deep_translator import GoogleTranslator
from common.runtime_log import appendRuntimeLog
IGNORE_EXACT = {
"戻る",
"翻訳",
"OCR",
"VRCLOG",
"Discord_Mute",
"Screenshot",
"OCR_Toggle",
"DCR_Toggle",
"HI",
}
def log(level, message):
now = datetime.now().strftime("%H:%M:%S")
print(f"[{level}] {now} {message}", flush=True)
def saveTranslationText(stem, text):
return appendRuntimeLog(
"TRANSLATION TEXT",
f"source={stem}\n{text}",
)
def looksLikeNoise(line):
value = line.strip()
if not value:
return True
if value in IGNORE_EXACT:
return True
if len(value) <= 2:
return True
if "_" in value:
return True
if re.fullmatch(r"[A-Za-z0-9\-_/]+", value):
return True
return False
def normalizeSourceText(text):
lines = []
for raw_line in text.splitlines():
line = raw_line.strip()
if looksLikeNoise(line):
continue
lines.append(line)
unique_lines = []
seen = set()
for line in lines:
if line not in seen:
unique_lines.append(line)
seen.add(line)
return "\n".join(unique_lines).strip()
def translateTextToJapanese(text):
source = normalizeSourceText(text)
if not source:
log("INFO", "翻訳対象が空です")
return ""
log("CHECK", "Translation source")
for line in source.splitlines():
print(line, flush=True)
try:
translated = GoogleTranslator(
source="auto",
target="ja",
).translate(source)
except Exception as e:
log("ERROR", f"translation failed detail={e}")
return ""
translated = translated.strip()
if not translated:
log("INFO", "翻訳結果は空です")
return ""
try:
pyperclip.copy(translated)
log("INFO", "翻訳結果をクリップボードへコピーしました")
except Exception as e:
log("ERROR", f"clipboard copy failed detail={e}")
log("CHECK", "Translation result")
for line in translated.splitlines():
print(line, flush=True)
return translated

565
src/vrc_log/log_actions.py Normal file
View File

@@ -0,0 +1,565 @@
import os
import re
import subprocess
import tempfile
import threading
import time
from datetime import datetime
from datetime import timedelta
from pathlib import Path
from common.config_loader import loadVrcLogConfig
from common.config_loader import getSecretValue
from common.runtime_log import appendRuntimeLog
from discord_control.actions import setDiscordMuted
_monitor_thread = None
_monitor_stop_event = threading.Event()
_last_sent_output = None
_self_monitor_thread = None
_self_monitor_stop_event = threading.Event()
_last_self_state = None
REALTIME_IDLE_SECONDS = 0.2
JOIN_PATTERN = re.compile(
r"OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)"
)
LEFT_PATTERN = re.compile(
r"OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)"
)
WORLD_PATTERN = re.compile(
r"(wrld_[0-9a-fA-F-]+(?::[^\s\]\)\"']+)?)"
)
ENTERING_ROOM_PATTERN = re.compile(
r"\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$"
)
WORLD_NAME_PATTERN = re.compile(
r"worldId=(wrld_[0-9a-fA-F-]+)(?::[^,}]*)?,\s*worldName=([^,}]+)"
)
TIME_PATTERN = re.compile(
r"(\d{4}\.\d{2}\.\d{2}\s+\d{2}:\d{2}:\d{2})"
)
RELEVANT_LOG_PATTERN = re.compile(
r"OnPlayerJoined|OnPlayerLeft|Entering Room|Joining or Creating Room|worldId=|wrld_"
)
def log(level, message):
now = datetime.now().strftime("%H:%M:%S")
print(f"[{level}] {now} {message}", flush=True)
def getWebhookUrl():
return getSecretValue("discord", "webhook_url")
def sendWebhook(text):
webhook_url = getWebhookUrl().strip()
if not webhook_url:
log("ERROR", "Discord webhook URL is not set config=config/secrets.toml")
return False
message = str(text).strip()
if not message:
log("INFO", "Webhook送信内容が空です")
return False
if len(message) > 1800:
message = message[-1800:]
ps_script = r'''
param(
[string]$WebhookUrl,
[string]$Message
)
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$Payload = @{ content = $Message } | ConvertTo-Json -Compress
$Body = [System.Text.Encoding]::UTF8.GetBytes($Payload)
Invoke-WebRequest -Uri $WebhookUrl -Method Post -ContentType 'application/json; charset=utf-8' -Body $Body -UseBasicParsing | Out-Null
'''
ps_path = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
suffix=".ps1",
delete=False,
encoding="utf-8-sig",
) as tmp:
tmp.write(ps_script)
ps_path = tmp.name
result = subprocess.run(
[
"powershell",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
ps_path,
webhook_url,
message,
],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
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()

View File

@@ -0,0 +1,623 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
目的:
VRChat表示名とTOML登録名を安全な順番で紐づける
特徴:
- 完全一致を先に確定して候補から除外
- 正規化一致を確定して候補から除外
- 残った候補だけを high confidence fuzzy 判定
- guest のみ low confidence fuzzy で再選別
- pending / ambiguous / unmatched 判定
- 記号除去
- 全角半角統一
- 小文字化
- 日本語 -> ローマ字変換
- rapidfuzz による類似度
- テストケース付き
前提:
pip install rapidfuzz pykakasi
期待結果:
- 100%一致した名前を fuzzy 候補に残さない
- 似た名前同士の誤リンクを減らす
"""
import re
import sys
import unicodedata
from dataclasses import dataclass, field
from typing import Dict, Iterable, List, Optional, Tuple
try:
from rapidfuzz import fuzz
except Exception as e:
if __name__ == "__main__":
print(f"[FATAL] rapidfuzz import failed: {e}")
input("Press Enter to exit...")
sys.exit(1)
raise
try:
from pykakasi import kakasi
except Exception as e:
if __name__ == "__main__":
print(f"[FATAL] pykakasi import failed: {e}")
input("Press Enter to exit...")
sys.exit(1)
raise
# =========================
# 設定
# =========================
AUTO_LINK_SCORE = 95
LOW_CONFIDENCE_SCORE = 80
AMBIGUOUS_DIFF = 10
LOW_CONFIDENCE_MAX_REMAINING = 3
WEAK_MATCH_SCORE = 50
STATUS_MATCHED = "matched"
STATUS_MATCHED_LOW_CONFIDENCE = "matched_low_confidence"
STATUS_PENDING = "pending"
STATUS_AMBIGUOUS = "ambiguous"
STATUS_UNMATCHED = "unmatched"
STATUS_UNKNOWN_MEMBER = "unknown_member"
ROLE_GUEST = "guest"
ROLE_SELF = "self"
ROLE_STAFF = "staff"
TEST_GUESTS = [
"okada",
"tanaka",
"yamada",
"messykenty",
]
TEST_CURRENT_USERS = [
"岡田",
"ikada",
"okada_",
"",
"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()