import os import re import difflib 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 common.runtime_log import appendJoinLeaveLog 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 _MONITOR_PREVIEW_WINDOW_SECONDS = 1.0 _last_monitor_preview = "" _last_monitor_preview_at = None 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})" ) 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) $Response = Invoke-WebRequest -Uri $WebhookUrl -Method Post -ContentType 'application/json; charset=utf-8' -Body $Body -UseBasicParsing Write-Output ("STATUS=" + $Response.StatusCode) ''' 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 stdout = (result.stdout or "").strip() if stdout: log("INFO", f"Discord webhook PowerShell stdout={stdout}") 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 debug(message): log("DEBUG", message) def buildDiffMessage(previous_text, current_text): previous_lines = (previous_text or "").splitlines() current_lines = (current_text or "").splitlines() if previous_text == current_text: return "" diff_lines = list( difflib.unified_diff( previous_lines, current_lines, fromfile="before", tofile="after", lineterm="", ) ) if not diff_lines: return "" return "\n".join(diff_lines) def compileLogPatterns(patterns): compiled = [] for pattern in patterns or []: try: compiled.append(re.compile(pattern)) except re.error as e: log("ERROR", f"invalid vrc_log pattern={pattern} detail={e}") return compiled def matchesAnyPattern(text, compiled_patterns): if not compiled_patterns: return True return any(pattern.search(text) for pattern in compiled_patterns) def formatJoinLeaveLine(line_time, action, name, state, guest_names): staff_count = len(state["staff_present_set"]) member_count = len(state["member_present_set"]) guest_count = len(state["guest_names"]) return f'[{formatClock(line_time)}][{action}] {name} {member_count - staff_count}/{guest_count}' 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) compiled_patterns = compileLogPatterns(config.get("log_patterns", [])) 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 if not guest_names: debug("guest_names is empty; guest comparison is disabled") for raw_line in text.splitlines(): line_time = parseLineTime(raw_line) if not line_time or line_time < start_time: continue line_matches = matchesAnyPattern(raw_line, compiled_patterns) room_match = ENTERING_ROOM_PATTERN.search(raw_line) world_name_match = WORLD_NAME_PATTERN.search(raw_line) world_match = WORLD_PATTERN.search(raw_line) location = "" world_id = "" instance_id = "" if world_name_match: world_id = world_name_match.group(1).strip() location = world_id elif room_match and world_match: location = world_match.group(1).strip() world_id, instance_id = splitWorldLocation(location) elif world_match: location = world_match.group(1).strip() world_id, instance_id = splitWorldLocation(location) if world_id and world_id != state["world_id"]: state["location"] = location or world_id 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 = [] world_label = formatWorldLabel(world_id, world_name_map) if world_label != "不明": output_lines.append(f"ワールド入室: {world_label}") join_match = JOIN_PATTERN.search(raw_line) if join_match and line_matches: name = join_match.group(1).strip() join_leave_line = None if isSelf(name, self_name): self_state = "joined" state["member_present_set"].add(name) join_leave_line = formatJoinLeaveLine(line_time, "join", name, state, guest_names) output_lines.append(join_leave_line) 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) join_leave_line = formatJoinLeaveLine(line_time, "join", name, state, guest_names) output_lines.append(join_leave_line) appendNoticeIfNeeded(output_lines, state) elif role == "staff": state["staff_present_set"].add(name) join_leave_line = formatJoinLeaveLine(line_time, "join", name, state, guest_names) output_lines.append(join_leave_line) else: state["member_present_set"].add(name) join_leave_line = formatJoinLeaveLine(line_time, "join", name, state, guest_names) output_lines.append(join_leave_line) continue left_match = LEFT_PATTERN.search(raw_line) if left_match and line_matches: name = left_match.group(1).strip() join_leave_line = None if isSelf(name, self_name): self_state = "left" state["member_present_set"].discard(name) join_leave_line = formatJoinLeaveLine(line_time, "leave", name, state, guest_names) output_lines.append(join_leave_line) 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) join_leave_line = formatJoinLeaveLine(line_time, "leave", name, state, guest_names) output_lines.append(join_leave_line) appendNoticeIfNeeded(output_lines, state) elif role == "staff": state["staff_present_set"].discard(name) join_leave_line = formatJoinLeaveLine(line_time, "leave", name, state, guest_names) output_lines.append(join_leave_line) else: state["member_present_set"].discard(name) join_leave_line = formatJoinLeaveLine(line_time, "leave", name, state, guest_names) output_lines.append(join_leave_line) continue 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() text = readTextSafe(latest_log) lines, self_state = parseVrchatEvents(text, config) output_text = "\n".join(lines) if lines else "" output_path = None if lines: output_path = appendRuntimeLog("VRC LOG", output_text) join_leave_lines = [line for line in lines if "[join]" in line or "[leave]" in line] if join_leave_lines: appendJoinLeaveLog("\n".join(join_leave_lines)) if notify_changed: global _last_sent_output diff_text = buildDiffMessage(_last_sent_output, output_text) if diff_text: try: ok = sendWebhook(diff_text) if ok: log("INFO", "Discord webhook diff sent") _last_sent_output = output_text else: log("ERROR", "Discord webhook diff send failed") except Exception as e: log("ERROR", f"Discord webhook diff failed detail={e}") if lines: for line in lines: 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) 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): if not any(token in text for token in ("OnPlayerJoined", "OnPlayerLeft", "Entering Room", "Joining or Creating Room", "worldId=", "wrld_")): return 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()