Port
This commit is contained in:
25
EXE.md
Normal file
25
EXE.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# exe Packaging
|
||||
|
||||
## Build
|
||||
|
||||
```powershell
|
||||
.\build_exe.ps1
|
||||
```
|
||||
|
||||
This uses `PyInstaller` and writes the result to `dist\vrc_osc\`.
|
||||
|
||||
## Distribution Layout
|
||||
|
||||
Place these next to `vrc_osc.exe`:
|
||||
|
||||
- `config\config.toml`
|
||||
- `config\secrets.toml`
|
||||
- `config\guests.txt`
|
||||
- `runtime\`
|
||||
|
||||
`runtime\` is created on startup if it does not exist.
|
||||
|
||||
## Notes
|
||||
|
||||
- `src\common\project_paths.py` resolves paths relative to the exe when frozen.
|
||||
- Keep `config\` beside the exe. The app does not look inside the PyInstaller temp directory for persistent files.
|
||||
10
README.md
10
README.md
@@ -8,6 +8,7 @@ VRC_OSC は、VRChat の状態を見て Discord やログ連携を行うため
|
||||
## できること
|
||||
|
||||
- VRChat のログを見て Discord に通知する
|
||||
- 変更があった部分だけを差分で送る
|
||||
- Discord のミュートを VRChat 側の操作に合わせて切り替える
|
||||
- VRChat 画面を読み取って OCR する
|
||||
- OCR した文字を翻訳してコピーする
|
||||
@@ -116,6 +117,15 @@ YourNameHere
|
||||
この README は、まず使い方が分かることを優先して簡単にまとめています。
|
||||
細かい内部仕様が必要な場合は、ソースコードと `config/*.example.*` を見てください。
|
||||
|
||||
## 生成されるログ
|
||||
|
||||
- `runtime/runtime.log`
|
||||
- 通常の実行ログ
|
||||
- `runtime/join_leave.log`
|
||||
- join / leave だけを抜き出したログ
|
||||
- `runtime/latest_ocr.png`
|
||||
- `runtime/latest_screenshot.png`
|
||||
|
||||
## 詳しい資料
|
||||
|
||||
- [設計書](doc/design.md)
|
||||
|
||||
6
build_exe.ps1
Normal file
6
build_exe.ps1
Normal file
@@ -0,0 +1,6 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Set-Location $ScriptDir
|
||||
|
||||
python -m PyInstaller .\vrc_osc.spec --noconfirm --clean
|
||||
21
cmd/vrc_osc/main.go
Normal file
21
cmd/vrc_osc/main.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"vrc_osc_go/internal/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var configPath string
|
||||
flag.StringVar(&configPath, "config", "config/config.toml", "path to config.toml")
|
||||
flag.Parse()
|
||||
|
||||
if err := app.Run(configPath); err != nil {
|
||||
log.SetOutput(os.Stderr)
|
||||
log.Fatalf("vrc_osc_go: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,16 @@ file = "config/guests.txt"
|
||||
[notice]
|
||||
missing_count = 0
|
||||
|
||||
[vrc_log]
|
||||
patterns = [
|
||||
"OnPlayerJoined",
|
||||
"OnPlayerLeft",
|
||||
"Entering Room",
|
||||
"Joining or Creating Room",
|
||||
"worldId=",
|
||||
"wrld_",
|
||||
]
|
||||
|
||||
[ocr.crop]
|
||||
left = 0.05
|
||||
top = 0.35
|
||||
|
||||
37
internal/app/app.go
Normal file
37
internal/app/app.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"vrc_osc_go/internal/config"
|
||||
"vrc_osc_go/internal/osc"
|
||||
)
|
||||
|
||||
func Run(configPath string) error {
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
server := osc.NewServer(cfg.OSC.Host, cfg.OSC.Port)
|
||||
server.Map("DiscordSend", func(_ string, args []osc.Value) error {
|
||||
return fmt.Errorf("discord mute not implemented yet")
|
||||
})
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- server.Serve() }()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case <-sigCh:
|
||||
server.Close()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
58
internal/config/config.go
Normal file
58
internal/config/config.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
OSC OSCConfig
|
||||
}
|
||||
|
||||
type OSCConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
cfg := &Config{OSC: OSCConfig{Host: "127.0.0.1", Port: 9001}}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return cfg, nil
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var section string
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
section = strings.Trim(line, "[]")
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.TrimSpace(parts[1])
|
||||
val = strings.Trim(val, "\"'")
|
||||
if section == "osc" {
|
||||
switch key {
|
||||
case "host":
|
||||
cfg.OSC.Host = val
|
||||
case "port":
|
||||
if n, err := strconv.Atoi(val); err == nil {
|
||||
cfg.OSC.Port = n
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cfg, scanner.Err()
|
||||
}
|
||||
|
||||
33
internal/config/config_test.go
Normal file
33
internal/config/config_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadDefaultsWhenMissing(t *testing.T) {
|
||||
cfg, err := Load("does-not-exist.toml")
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
if cfg.OSC.Host != "127.0.0.1" || cfg.OSC.Port != 9001 {
|
||||
t.Fatalf("unexpected defaults: %+v", cfg.OSC)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOSCValues(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
if err := os.WriteFile(path, []byte("[osc]\nhost = \"0.0.0.0\"\nport = 9002\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
if cfg.OSC.Host != "0.0.0.0" || cfg.OSC.Port != 9002 {
|
||||
t.Fatalf("unexpected values: %+v", cfg.OSC)
|
||||
}
|
||||
}
|
||||
|
||||
77
internal/osc/server.go
Normal file
77
internal/osc/server.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package osc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Value struct {
|
||||
Type byte
|
||||
Float float32
|
||||
Int int32
|
||||
Bool bool
|
||||
Str string
|
||||
}
|
||||
|
||||
type Handler func(address string, args []Value) error
|
||||
|
||||
type Server struct {
|
||||
addr string
|
||||
conn *net.UDPConn
|
||||
handlers map[string]Handler
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewServer(host string, port int) *Server {
|
||||
return &Server{
|
||||
addr: fmt.Sprintf("%s:%d", host, port),
|
||||
handlers: map[string]Handler{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Map(param string, h Handler) { s.handlers["/avatar/parameters/"+param] = h }
|
||||
func (s *Server) Close() error { if s.conn != nil { return s.conn.Close() }; return nil }
|
||||
|
||||
func (s *Server) Serve() error {
|
||||
addr, err := net.ResolveUDPAddr("udp", s.addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn, err := net.ListenUDP("udp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.conn = conn
|
||||
buf := make([]byte, 2048)
|
||||
for {
|
||||
n, _, err := conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a, v, err := parseMessage(buf[:n])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s.mu.RLock()
|
||||
h := s.handlers[a]
|
||||
s.mu.RUnlock()
|
||||
if h != nil {
|
||||
_ = h(a, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseMessage(b []byte) (string, []Value, error) {
|
||||
parts := bytes.SplitN(b, []byte{0}, 2)
|
||||
if len(parts) == 0 {
|
||||
return "", nil, fmt.Errorf("invalid osc")
|
||||
}
|
||||
address := string(parts[0])
|
||||
if !strings.HasPrefix(address, "/") {
|
||||
return "", nil, fmt.Errorf("invalid address")
|
||||
}
|
||||
return address, nil, nil
|
||||
}
|
||||
14
internal/osc/server_test.go
Normal file
14
internal/osc/server_test.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package osc
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseMessageAddress(t *testing.T) {
|
||||
addr, _, err := parseMessage([]byte("/avatar/parameters/DiscordSend\x00"))
|
||||
if err != nil {
|
||||
t.Fatalf("parseMessage returned error: %v", err)
|
||||
}
|
||||
if addr != "/avatar/parameters/DiscordSend" {
|
||||
t.Fatalf("unexpected address: %q", addr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,6 @@ pykakasi
|
||||
pyperclip
|
||||
python-osc
|
||||
rapidfuzz
|
||||
pyinstaller
|
||||
chardet==5.2.0
|
||||
vrchatapi
|
||||
|
||||
@@ -14,10 +14,6 @@ if str(SRC_DIR) not in sys.path:
|
||||
|
||||
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 vision.vision_actions import runVisionFromScreen
|
||||
from vrc_log.log_actions import collectVrchatLog
|
||||
from vrc_log.log_actions import startSelfMonitor
|
||||
from vrc_log.log_actions import startVrcLogMonitor
|
||||
@@ -68,6 +64,7 @@ def create_gateway():
|
||||
|
||||
if gateway.is_rising_edge(address, args[0]):
|
||||
gateway.log("INFO", f"received {address} args={args}")
|
||||
from ocr.ocr_actions import runOcrFromScreen
|
||||
runOcrFromScreen()
|
||||
|
||||
def on_translate_ocr(address, *args):
|
||||
@@ -78,6 +75,9 @@ def create_gateway():
|
||||
if gateway.is_rising_edge(address, args[0]):
|
||||
gateway.log("INFO", f"received {address} args={args}")
|
||||
|
||||
from ocr.ocr_actions import runOcrFromScreen
|
||||
from translate.translate_actions import saveTranslationText
|
||||
from translate.translate_actions import translateTextToJapanese
|
||||
result = runOcrFromScreen()
|
||||
if not result:
|
||||
gateway.log("ERROR", "OCR result is None")
|
||||
@@ -105,6 +105,7 @@ def create_gateway():
|
||||
|
||||
if gateway.is_rising_edge(address, args[0]):
|
||||
gateway.log("INFO", f"received {address} args={args}")
|
||||
from vision.vision_actions import runVisionFromScreen
|
||||
runVisionFromScreen()
|
||||
|
||||
gateway.map(PARAM_DISCORD_MUTE, on_discord_mute)
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SRC_DIR = Path(__file__).resolve().parents[1]
|
||||
ROOT_DIR = SRC_DIR.parent
|
||||
def _get_runtime_base_dir():
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path(sys.executable).resolve().parent
|
||||
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
ROOT_DIR = _get_runtime_base_dir()
|
||||
SRC_DIR = ROOT_DIR / "src"
|
||||
RUNTIME_DIR = ROOT_DIR / "runtime"
|
||||
RUNTIME_LOG_FILE = RUNTIME_DIR / "runtime.log"
|
||||
RUNTIME_JOIN_LEAVE_LOG_FILE = RUNTIME_DIR / "join_leave.log"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import datetime
|
||||
|
||||
from common.project_paths import RUNTIME_LOG_FILE
|
||||
from common.project_paths import RUNTIME_JOIN_LEAVE_LOG_FILE
|
||||
|
||||
|
||||
def appendRuntimeLog(title, text):
|
||||
@@ -17,3 +18,19 @@ def appendRuntimeLog(title, text):
|
||||
file.write("\n")
|
||||
|
||||
return RUNTIME_LOG_FILE
|
||||
|
||||
|
||||
def appendJoinLeaveLog(text):
|
||||
RUNTIME_JOIN_LEAVE_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_JOIN_LEAVE_LOG_FILE.open("a", encoding="utf-8") as file:
|
||||
file.write(f"\n[{now}] VRC JOIN/LEAVE\n")
|
||||
file.write(body)
|
||||
file.write("\n")
|
||||
|
||||
return RUNTIME_JOIN_LEAVE_LOG_FILE
|
||||
|
||||
@@ -61,18 +61,12 @@ class VrcOscGateway:
|
||||
|
||||
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)
|
||||
def serve_forever(self):
|
||||
self.log("CHECK", "STEP=1 状態確認")
|
||||
self.log("INFO", f"listen={self.host}:{self.port}")
|
||||
|
||||
try:
|
||||
server = BlockingOSCUDPServer((self.host, self.port), self.dispatcher)
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
self.log("INFO", "Ctrl+C detected")
|
||||
|
||||
@@ -18,6 +18,8 @@ from discord_control.actions import setDiscordMuted
|
||||
_monitor_thread = None
|
||||
_monitor_stop_event = threading.Event()
|
||||
_last_sent_output = None
|
||||
_last_join_leave_output = ""
|
||||
_monitor_vrc_state = None
|
||||
_self_monitor_thread = None
|
||||
_self_monitor_stop_event = threading.Event()
|
||||
_last_self_state = None
|
||||
@@ -39,7 +41,10 @@ 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=([^,}]+)"
|
||||
r"worldId=(wrld_[0-9a-fA-F-]+)(?::[^,}]*)?,\s*(?:instanceId=([^,}]+),\s*)?worldName=([^,}]+)"
|
||||
)
|
||||
WORLD_LOCATION_PATTERN = re.compile(
|
||||
r"worldId=(wrld_[0-9a-fA-F-]+)(?::[^,}]*)?,\s*instanceId=([^,}]+)"
|
||||
)
|
||||
TIME_PATTERN = re.compile(
|
||||
r"(\d{4}\.\d{2}\.\d{2}\s+\d{2}:\d{2}:\d{2})"
|
||||
@@ -77,7 +82,9 @@ param(
|
||||
$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)
|
||||
if ($Response.StatusCode -ne 200) {
|
||||
Write-Output ("STATUS=" + $Response.StatusCode)
|
||||
}
|
||||
'''
|
||||
|
||||
ps_path = None
|
||||
@@ -127,7 +134,7 @@ param(
|
||||
|
||||
stdout = (result.stdout or "").strip()
|
||||
if stdout:
|
||||
log("INFO", f"Discord webhook PowerShell stdout={stdout}")
|
||||
log("DEBUG", f"Discord webhook PowerShell stdout={stdout}")
|
||||
log("INFO", "Discord webhook sent via PowerShell")
|
||||
return True
|
||||
|
||||
@@ -205,7 +212,7 @@ def extractWorldNameMap(text):
|
||||
match = WORLD_NAME_PATTERN.search(raw_line)
|
||||
if match:
|
||||
world_id = match.group(1).strip()
|
||||
world_name = match.group(2).strip()
|
||||
world_name = match.group(3).strip()
|
||||
if world_id and world_name:
|
||||
world_name_map[world_id] = world_name
|
||||
continue
|
||||
@@ -322,10 +329,9 @@ def matchesAnyPattern(text, 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"])
|
||||
total_count = len(state["present_set"])
|
||||
guest_count = len(state["guest_names"])
|
||||
return f'[{formatClock(line_time)}][{action}] {name} {member_count - staff_count}/{guest_count}'
|
||||
return f'[{formatClock(line_time)}][{action}] {name} {total_count}/{guest_count}'
|
||||
|
||||
|
||||
def parseVrchatEvents(text, config):
|
||||
@@ -340,12 +346,11 @@ def parseVrchatEvents(text, config):
|
||||
"location": "",
|
||||
"world_id": "",
|
||||
"instance_id": "",
|
||||
"present_set": set(),
|
||||
"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,
|
||||
}
|
||||
@@ -383,9 +388,8 @@ def parseVrchatEvents(text, config):
|
||||
state["location"] = location or world_id
|
||||
state["world_id"] = world_id
|
||||
state["instance_id"] = instance_id
|
||||
state["present_set"].clear()
|
||||
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)
|
||||
@@ -398,7 +402,7 @@ def parseVrchatEvents(text, config):
|
||||
join_leave_line = None
|
||||
if isSelf(name, self_name):
|
||||
self_state = "joined"
|
||||
state["member_present_set"].add(name)
|
||||
state["present_set"].add(name)
|
||||
join_leave_line = formatJoinLeaveLine(line_time, "join", name, state, guest_names)
|
||||
output_lines.append(join_leave_line)
|
||||
continue
|
||||
@@ -407,16 +411,16 @@ def parseVrchatEvents(text, config):
|
||||
|
||||
if role == "guest":
|
||||
state["guest_present_set"].add(name)
|
||||
state["member_present_set"].add(name)
|
||||
state["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)
|
||||
state["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)
|
||||
state["present_set"].add(name)
|
||||
join_leave_line = formatJoinLeaveLine(line_time, "join", name, state, guest_names)
|
||||
output_lines.append(join_leave_line)
|
||||
continue
|
||||
@@ -427,7 +431,7 @@ def parseVrchatEvents(text, config):
|
||||
join_leave_line = None
|
||||
if isSelf(name, self_name):
|
||||
self_state = "left"
|
||||
state["member_present_set"].discard(name)
|
||||
state["present_set"].discard(name)
|
||||
join_leave_line = formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
|
||||
output_lines.append(join_leave_line)
|
||||
continue
|
||||
@@ -436,16 +440,16 @@ def parseVrchatEvents(text, config):
|
||||
|
||||
if role == "guest":
|
||||
state["guest_present_set"].discard(name)
|
||||
state["member_present_set"].discard(name)
|
||||
state["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)
|
||||
state["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)
|
||||
state["present_set"].discard(name)
|
||||
join_leave_line = formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
|
||||
output_lines.append(join_leave_line)
|
||||
continue
|
||||
@@ -453,6 +457,142 @@ def parseVrchatEvents(text, config):
|
||||
return output_lines, self_state
|
||||
|
||||
|
||||
def createVrcState(config, world_name_map=None):
|
||||
guest_names = config["guest_names"]
|
||||
return {
|
||||
"location": "",
|
||||
"world_id": "",
|
||||
"instance_id": "",
|
||||
"present_set": set(),
|
||||
"guest_names": guest_names,
|
||||
"guest_name_set": set(guest_names),
|
||||
"staff_names": set(config["staff_names"]),
|
||||
"guest_present_set": set(),
|
||||
"missing_count": config["missing_count"],
|
||||
"last_notice_key": None,
|
||||
"world_name_map": world_name_map or {},
|
||||
"last_join_leave_output": "",
|
||||
}
|
||||
|
||||
|
||||
def processVrcText(text, config, state):
|
||||
return processVrcLines(text.splitlines(), config, state)
|
||||
|
||||
|
||||
def processVrcLines(raw_lines, config, state):
|
||||
output_lines = []
|
||||
world_name_map = state.get("world_name_map") or {}
|
||||
guest_names = state["guest_names"]
|
||||
staff_names = state["staff_names"]
|
||||
self_name = config.get("self_name", "")
|
||||
compiled_patterns = compileLogPatterns(config.get("log_patterns", []))
|
||||
|
||||
for raw_line in raw_lines:
|
||||
line_time = parseLineTime(raw_line)
|
||||
if not line_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_location_match = WORLD_LOCATION_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()
|
||||
instance_id = (world_name_match.group(2) or "").strip()
|
||||
location = world_id
|
||||
elif world_location_match:
|
||||
world_id = world_location_match.group(1).strip()
|
||||
instance_id = world_location_match.group(2).strip()
|
||||
location = f"{world_id}:{instance_id}" if instance_id else 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)
|
||||
|
||||
next_location = f"{world_id}:{instance_id}" if world_id else ""
|
||||
if next_location and next_location != state["location"]:
|
||||
state["location"] = next_location
|
||||
state["world_id"] = world_id
|
||||
state["instance_id"] = instance_id
|
||||
state["present_set"].clear()
|
||||
state["guest_present_set"].clear()
|
||||
state["last_notice_key"] = None
|
||||
state["last_join_leave_output"] = ""
|
||||
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()
|
||||
if isSelf(name, self_name):
|
||||
state["present_set"].add(name)
|
||||
output_lines.append(
|
||||
formatJoinLeaveLine(line_time, "join", name, state, guest_names)
|
||||
)
|
||||
continue
|
||||
|
||||
role = classifyUser(name, state["guest_name_set"], staff_names)
|
||||
if role == "guest":
|
||||
state["guest_present_set"].add(name)
|
||||
state["present_set"].add(name)
|
||||
output_lines.append(
|
||||
formatJoinLeaveLine(line_time, "join", name, state, guest_names)
|
||||
)
|
||||
appendNoticeIfNeeded(output_lines, state)
|
||||
elif role == "staff":
|
||||
state["present_set"].add(name)
|
||||
output_lines.append(
|
||||
formatJoinLeaveLine(line_time, "join", name, state, guest_names)
|
||||
)
|
||||
else:
|
||||
state["present_set"].add(name)
|
||||
output_lines.append(
|
||||
formatJoinLeaveLine(line_time, "join", name, state, guest_names)
|
||||
)
|
||||
continue
|
||||
|
||||
left_match = LEFT_PATTERN.search(raw_line)
|
||||
if left_match and line_matches:
|
||||
name = left_match.group(1).strip()
|
||||
if isSelf(name, self_name):
|
||||
state["present_set"].discard(name)
|
||||
output_lines.append(
|
||||
formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
|
||||
)
|
||||
continue
|
||||
|
||||
role = classifyUser(name, state["guest_name_set"], staff_names)
|
||||
if role == "guest":
|
||||
state["guest_present_set"].discard(name)
|
||||
state["present_set"].discard(name)
|
||||
output_lines.append(
|
||||
formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
|
||||
)
|
||||
appendNoticeIfNeeded(output_lines, state)
|
||||
elif role == "staff":
|
||||
state["present_set"].discard(name)
|
||||
output_lines.append(
|
||||
formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
|
||||
)
|
||||
else:
|
||||
state["present_set"].discard(name)
|
||||
output_lines.append(
|
||||
formatJoinLeaveLine(line_time, "leave", name, state, guest_names)
|
||||
)
|
||||
continue
|
||||
|
||||
return output_lines, state
|
||||
|
||||
|
||||
def extractLatestSelfLogState(text, self_name):
|
||||
if not self_name:
|
||||
return None
|
||||
@@ -481,7 +621,9 @@ def collectVrchatLog(pattern=None, notify_changed=True):
|
||||
latest_log = findLatestVrchatLog()
|
||||
|
||||
text = readTextSafe(latest_log)
|
||||
lines, self_state = parseVrchatEvents(text, config)
|
||||
world_name_map = extractWorldNameMap(text)
|
||||
state = createVrcState(config, world_name_map=world_name_map)
|
||||
lines, state = processVrcText(text, config, state)
|
||||
|
||||
output_text = "\n".join(lines) if lines else ""
|
||||
output_path = None
|
||||
@@ -489,13 +631,22 @@ def collectVrchatLog(pattern=None, notify_changed=True):
|
||||
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))
|
||||
global _last_join_leave_output
|
||||
current_join_leave_output = "\n".join(join_leave_lines)
|
||||
join_leave_diff = buildDiffMessage(
|
||||
_last_join_leave_output,
|
||||
current_join_leave_output,
|
||||
)
|
||||
if join_leave_diff:
|
||||
appendJoinLeaveLog(join_leave_diff)
|
||||
_last_join_leave_output = current_join_leave_output
|
||||
|
||||
if notify_changed:
|
||||
global _last_sent_output
|
||||
diff_text = buildDiffMessage(_last_sent_output, output_text)
|
||||
if diff_text:
|
||||
try:
|
||||
appendRuntimeLog("VRC LOG DIFF", diff_text)
|
||||
ok = sendWebhook(diff_text)
|
||||
if ok:
|
||||
log("INFO", "Discord webhook diff sent")
|
||||
@@ -512,6 +663,16 @@ def collectVrchatLog(pattern=None, notify_changed=True):
|
||||
return output_path
|
||||
|
||||
|
||||
def initializeVrcMonitorState():
|
||||
global _monitor_vrc_state
|
||||
config = loadVrcLogConfig()
|
||||
latest_log = findLatestVrchatLog()
|
||||
text = readTextSafe(latest_log)
|
||||
world_name_map = extractWorldNameMap(text)
|
||||
_monitor_vrc_state = createVrcState(config, world_name_map=world_name_map)
|
||||
return _monitor_vrc_state
|
||||
|
||||
|
||||
def _readNewText(path, offset):
|
||||
for encoding in ("utf-8", "utf-8-sig", "cp932", "shift_jis"):
|
||||
try:
|
||||
@@ -561,11 +722,54 @@ def _watchVrchatLogChanges(stop_event, label, on_relevant_change):
|
||||
|
||||
|
||||
def _monitor_loop():
|
||||
global _monitor_vrc_state
|
||||
|
||||
if _monitor_vrc_state is None:
|
||||
try:
|
||||
initializeVrcMonitorState()
|
||||
except Exception as e:
|
||||
log("ERROR", f"VRC log monitor init failed detail={e}")
|
||||
|
||||
def on_relevant_change(text):
|
||||
global _monitor_vrc_state
|
||||
global _last_sent_output
|
||||
global _last_join_leave_output
|
||||
|
||||
if not any(token in text for token in ("OnPlayerJoined", "OnPlayerLeft", "Entering Room", "Joining or Creating Room", "worldId=", "wrld_")):
|
||||
return
|
||||
|
||||
collectVrchatLog(notify_changed=True)
|
||||
try:
|
||||
config = loadVrcLogConfig()
|
||||
if _monitor_vrc_state is None:
|
||||
initializeVrcMonitorState()
|
||||
lines, state = processVrcText(text, config, _monitor_vrc_state)
|
||||
_monitor_vrc_state = state
|
||||
output_text = "\n".join(lines) if lines else ""
|
||||
if not output_text:
|
||||
return
|
||||
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:
|
||||
current_join_leave_output = "\n".join(join_leave_lines)
|
||||
if current_join_leave_output != _last_join_leave_output:
|
||||
join_leave_diff = buildDiffMessage(_last_join_leave_output, current_join_leave_output)
|
||||
if join_leave_diff:
|
||||
appendJoinLeaveLog(join_leave_diff)
|
||||
_last_join_leave_output = current_join_leave_output
|
||||
global _last_sent_output
|
||||
diff_text = buildDiffMessage(_last_sent_output, output_text)
|
||||
if diff_text:
|
||||
appendRuntimeLog("VRC LOG DIFF", diff_text)
|
||||
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")
|
||||
for line in lines:
|
||||
print(line, flush=True)
|
||||
except Exception as e:
|
||||
log("ERROR", f"VRC log monitor chunk failed detail={e}")
|
||||
|
||||
_watchVrchatLogChanges(
|
||||
_monitor_stop_event,
|
||||
|
||||
59
vrc_osc.spec
Normal file
59
vrc_osc.spec
Normal file
@@ -0,0 +1,59 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
block_cipher = None
|
||||
project_dir = Path(SPECPATH).resolve()
|
||||
src_dir = project_dir / "src"
|
||||
|
||||
hiddenimports = [
|
||||
"paddleocr",
|
||||
"pygetwindow",
|
||||
"pyautogui",
|
||||
"pythonosc",
|
||||
"rapidfuzz",
|
||||
"deep_translator",
|
||||
"chardet.pipeline.orchestrator__mypyc",
|
||||
]
|
||||
|
||||
a = Analysis(
|
||||
[str(src_dir / "app.py")],
|
||||
pathex=[str(src_dir)],
|
||||
binaries=[],
|
||||
datas=[
|
||||
(str(project_dir / "config"), "config"),
|
||||
(str(project_dir / "doc"), "doc"),
|
||||
],
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
name="vrc_osc",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
Reference in New Issue
Block a user