Files
VRCWT-OSC/src/vrc_log/log_actions.py
2026-06-09 02:37:06 +09:00

639 lines
19 KiB
Python

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()