639 lines
19 KiB
Python
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()
|