winx_prinx-api / local_worker /chat_mod_worker.py
Sasha
feat: throttle VOD resolution checks to 5m and auto-complete streams older than 3 hours with no VOD
fe2d35c
Raw
History Blame Contribute Delete
28.8 kB
from datetime import datetime, timedelta, timezone
import os
import sys
import time
last_resolve_time = 0
import socket
import select
import threading
import queue
import random
import requests
from dotenv import load_dotenv
from vod_backfiller import download_vod_chat
# Load configurations
load_dotenv()
API_URL = os.getenv("API_URL", "http://localhost:3000")
API_KEY = os.getenv("API_KEY", "")
TWITCH_CHANNEL = os.getenv("TWITCH_CHANNEL", "winx_prinx").lower()
# Local storage file to remember which stream chats we have already backfilled
PROCESSED_FILE = "processed_chat_streams.txt"
# Verify essential secrets
if not API_KEY:
print("[Error] API_KEY is missing in .env! Local chat/mod worker cannot push data.")
sys.exit(1)
# Thread-safe queues
chat_queue = queue.Queue()
mod_action_queue = queue.Queue()
# Flag to signal thread termination
stop_flag = threading.Event()
# Coverage window tracking
coverage_id = None
coverage_stop_flag = threading.Event()
def start_coverage(stream_id, source='live', covered_from=None, covered_to=None):
"""Register a new coverage window with the backend"""
global coverage_id
headers = {"x-api-key": API_KEY, "Content-Type": "application/json"}
payload = {"streamId": stream_id, "source": source}
if covered_from:
payload["coveredFrom"] = covered_from
if covered_to:
payload["coveredTo"] = covered_to
try:
res = requests.post(f"{API_URL}/api/log/coverage/start",
json=payload,
headers=headers, timeout=5)
if res.status_code == 200:
cid = res.json().get("coverageId")
print(f"[Coverage] Started window #{cid} (source={source}) for stream {stream_id}")
if source == 'live':
coverage_id = cid
return cid
except Exception as e:
print(f"[Coverage] Error starting coverage: {e}")
return None
def stop_coverage(cid=None, covered_to=None):
"""Finalize a coverage window"""
global coverage_id
target_id = cid if cid is not None else coverage_id
if not target_id:
return
headers = {"x-api-key": API_KEY, "Content-Type": "application/json"}
payload = {"coverageId": target_id}
if covered_to:
payload["coveredTo"] = covered_to
try:
requests.post(f"{API_URL}/api/log/coverage/end",
json=payload,
headers=headers, timeout=5)
print(f"[Coverage] Ended window #{target_id}")
except Exception as e:
print(f"[Coverage] Error ending coverage: {e}")
if cid is None or cid == coverage_id:
coverage_id = None
def coverage_heartbeat_loop():
"""Background thread: ping coverage heartbeat every 2 minutes"""
headers = {"x-api-key": API_KEY}
while not coverage_stop_flag.is_set():
time.sleep(120)
if coverage_id:
try:
requests.patch(f"{API_URL}/api/log/coverage/{coverage_id}/heartbeat",
headers=headers, timeout=5)
except Exception:
pass
def parse_irc_tags(tags_str):
"""Parse IRC v3 tags into a dictionary"""
tags = {}
if not tags_str:
return tags
parts = tags_str.split(";")
for part in parts:
if "=" in part:
k, v = part.split("=", 1)
tags[k] = v
return tags
def new_iso_timestamp():
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
# =========================================================================
# TWITCH CHAT & MOD ACTION IRC LISTENER
# =========================================================================
def twitch_irc_listener():
"""Background thread to connect to Twitch IRC and read chat & mod actions"""
server = "irc.chat.twitch.tv"
port = 6667
anon_nick = f"justinfan{random.randint(10000, 99999)}"
print(f"[Twitch IRC] Connecting to chat anonymously as {anon_nick}...")
while not stop_flag.is_set():
try:
irc_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
irc_sock.settimeout(10.0)
irc_sock.connect((server, port))
irc_sock.send(f"PASS oauth:anonymous\r\n".encode("utf-8"))
irc_sock.send(f"NICK {anon_nick}\r\n".encode("utf-8"))
irc_sock.send("CAP REQ :twitch.tv/tags twitch.tv/commands twitch.tv/membership\r\n".encode("utf-8"))
irc_sock.send(f"JOIN #{TWITCH_CHANNEL}\r\n".encode("utf-8"))
print(f"[Twitch IRC] Joined channel #{TWITCH_CHANNEL}. Listening for chat and moderation events...")
buffer = ""
irc_sock.setblocking(False)
last_msg_time = time.time()
while not stop_flag.is_set():
# Check for network timeout (no messages for 5 minutes)
if time.time() - last_msg_time > 300:
print("[Twitch IRC] Network timeout (no messages for 5 minutes). Reconnecting...")
break
ready = select.select([irc_sock], [], [], 1.0)
if not ready[0]:
continue
try:
data = irc_sock.recv(4096).decode("utf-8", errors="ignore")
except socket.timeout:
continue
if not data:
print("[Twitch IRC] Connection closed by remote host.")
break
last_msg_time = time.time()
buffer += data
while "\r\n" in buffer:
line, buffer = buffer.split("\r\n", 1)
if line.startswith("PING"):
irc_sock.send("PONG :tmi.twitch.tv\r\n".encode("utf-8"))
continue
if "PRIVMSG" in line:
tags = {}
tags_str = ""
if line.startswith("@"):
tags_str, remainder = line[1:].split(" ", 1)
tags = parse_irc_tags(tags_str)
line = remainder
parts = line.split(" PRIVMSG ", 1)
if len(parts) < 2:
continue
prefix, msg_parts = parts
user = prefix.split("!", 1)[0].replace(":", "")
channel_part, message_text = msg_parts.split(" :", 1)
msg_id = tags.get("id", f"local-{time.time_ns()}")
display_name = tags.get("display-name", user)
badges = tags.get("badges", "")
is_streamer = (user.lower() == TWITCH_CHANNEL)
is_mod = "moderator" in badges or "broadcaster" in badges
is_sub = "subscriber" in badges or "founder" in badges
is_vip = "vip" in badges
parsed_msg = {
"id": msg_id,
"username": user,
"displayName": display_name,
"message": message_text,
"timestamp": new_iso_timestamp(),
"isStreamer": is_streamer,
"isMod": is_mod,
"isSub": is_sub,
"isVip": is_vip
}
chat_queue.put(parsed_msg)
elif "CLEARCHAT" in line:
tags = {}
tags_str = ""
if line.startswith("@"):
tags_str, remainder = line[1:].split(" ", 1)
tags = parse_irc_tags(tags_str)
line = remainder
parts = line.split(" CLEARCHAT ")
if len(parts) >= 2:
channel_part = parts[1]
if " :" in channel_part:
_, target_user = channel_part.split(" :", 1)
target_user = target_user.strip()
ban_duration = tags.get("ban-duration")
action_type = "timeout" if ban_duration else "ban"
duration = int(ban_duration) if ban_duration else None
mod_action = {
"actionType": action_type,
"moderator": "TwitchIRC",
"targetUser": target_user,
"duration": duration,
"reason": tags.get("ban-reason", "No reason provided via IRC"),
"timestamp": new_iso_timestamp()
}
print(f"[Twitch IRC] Detected moderation event: {action_type.upper()} for user '{target_user}'" + (f" (duration: {duration}s)" if duration else ""))
mod_action_queue.put(mod_action)
elif "CLEARMSG" in line:
tags = {}
tags_str = ""
if line.startswith("@"):
tags_str, remainder = line[1:].split(" ", 1)
tags = parse_irc_tags(tags_str)
line = remainder
parts = line.split(" CLEARMSG ")
if len(parts) >= 2:
channel_part = parts[1]
if " :" in channel_part:
_, message_text = channel_part.split(" :", 1)
message_text = message_text.strip()
target_user = tags.get("login", "")
mod_action = {
"actionType": "delete",
"moderator": "TwitchIRC",
"targetUser": target_user,
"messageText": message_text,
"timestamp": new_iso_timestamp()
}
print(f"[Twitch IRC] Detected moderation event: DELETE message of '{target_user}': \"{message_text}\"")
mod_action_queue.put(mod_action)
except Exception as e:
print(f"[Twitch IRC] Connection error: {e}. Retrying in 10 seconds...")
time.sleep(10)
finally:
try:
irc_sock.close()
except:
pass
# =========================================================================
# BACKGROUND SENDERS
# =========================================================================
def chat_sender():
"""Periodically sends accumulated chat messages to backend API"""
headers = {"x-api-key": API_KEY, "Content-Type": "application/json"}
url = f"{API_URL}/api/log/messages"
while not stop_flag.is_set():
messages = []
while not chat_queue.empty():
try:
messages.append(chat_queue.get_nowait())
except queue.Empty:
break
if messages:
try:
# 1. Send messages
response = requests.post(url, json={"messages": messages}, headers=headers, timeout=5)
if response.status_code != 200:
print(f"[Chat Sender] Failed to sync messages. API returned status {response.status_code}")
except Exception as e:
print(f"[Chat Sender] Network error sending messages: {e}")
try:
# 2. Send roles for users who have at least one badge
roles = [
{
"username": m["username"],
"displayName": m.get("displayName", m["username"]),
"isMod": m.get("isMod", False),
"isSub": m.get("isSub", False),
"isVip": m.get("isVip", False),
"timestamp": m.get("timestamp")
}
for m in messages
if m.get("isMod") or m.get("isSub") or m.get("isVip")
]
if roles:
requests.post(f"{API_URL}/api/log/roles", json={"roles": roles}, headers=headers, timeout=5)
except Exception as e:
print(f"[Chat Sender] Network error sending roles: {e}")
time.sleep(3)
def mod_action_sender():
"""Periodically sends accumulated mod actions to backend API"""
headers = {"x-api-key": API_KEY, "Content-Type": "application/json"}
url = f"{API_URL}/api/log/mod-action"
while not stop_flag.is_set():
actions = []
while not mod_action_queue.empty():
try:
actions.append(mod_action_queue.get_nowait())
except queue.Empty:
break
for action in actions:
try:
response = requests.post(url, json=action, headers=headers, timeout=5)
if response.status_code == 200:
print(f"[Mod Sender] Successfully logged {action['actionType']} action to backend.")
else:
print(f"[Mod Sender] Failed to sync mod action. API returned status {response.status_code}")
except Exception as e:
print(f"[Mod Sender] Network error sending mod action: {e}")
time.sleep(1)
# =========================================================================
# VOD ID AUTO-RESOLUTION & CHAT BACKFILLING LOOP (NO AUDIO)
# =========================================================================
def get_recent_twitch_vods(channel_name):
"""Fetch recent Twitch VODs of a user using public Twitch GQL API"""
url = "https://gql.twitch.tv/gql"
headers = {
"Client-Id": "kimne78kx3ncx6brgo4mv6wki5h1ko",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
}
payload = {
"query": """
query($login: String!) {
user(login: $login) {
videos(first: 10, type: ARCHIVE) {
edges {
node {
id
title
createdAt
lengthSeconds
}
}
}
}
}
""",
"variables": {
"login": channel_name
}
}
try:
res = requests.post(url, json=payload, headers=headers, timeout=10)
if res.status_code == 200:
data = res.json()
edges = data.get("data", {}).get("user", {}).get("videos", {}).get("edges", [])
return [edge.get("node") for edge in edges if edge and edge.get("node")]
except Exception as e:
print(f"[VOD Resolve] Error fetching Twitch VODs: {e}")
return []
def find_matching_vod(stream_start_time_iso, recent_vods):
try:
from datetime import datetime
clean_stream = stream_start_time_iso.replace('Z', '+00:00')
stream_dt = datetime.fromisoformat(clean_stream)
except Exception as e:
print(f"[Match] Error parsing stream start time: {e}")
return None
best_match = None
min_diff = float('inf')
for node in recent_vods:
vod_id = node.get("id")
created_at_raw = node.get("createdAt")
if not vod_id or not created_at_raw:
continue
try:
clean_vod = created_at_raw.replace('Z', '+00:00')
vod_dt = datetime.fromisoformat(clean_vod)
except Exception:
continue
diff = abs((stream_dt - vod_dt).total_seconds())
# If the start times are within 2.5 hours (9000 seconds)
if diff < 9000 and diff < min_diff:
min_diff = diff
best_match = vod_id
return best_match
def resolve_missing_vods():
"""Find and resolve twitch_vod_id for pending streams that are missing it"""
headers = {"x-api-key": API_KEY}
try:
# 1. Fetch missing VOD streams from backend
res = requests.get(f"{API_URL}/api/streams/missing-vod", headers=headers, timeout=10)
if res.status_code != 200:
return
streams = res.json().get("streams", [])
if not streams:
return
print(f"[VOD Resolve] Found {len(streams)} pending stream(s) lacking VOD ID.")
# 2. Get recent VODs from Twitch
recent_vods = get_recent_twitch_vods(TWITCH_CHANNEL)
if not recent_vods:
print("[VOD Resolve] Could not retrieve recent Twitch VODs. Skipping resolve.")
return
# 3. Match each stream to a Twitch VOD
for stream in streams:
stream_id = stream.get("id")
start_time_str = stream.get("start_time")
end_time_str = stream.get("end_time")
if not stream_id or not start_time_str:
continue
# Calculate stream age from end_time
try:
clean_end = end_time_str.replace('Z', '+00:00') if end_time_str else start_time_str.replace('Z', '+00:00')
end_dt = datetime.fromisoformat(clean_end)
now_utc = datetime.now(timezone.utc)
age_hours = (now_utc - end_dt).total_seconds() / 3600.0
except Exception as age_err:
age_hours = 0
matched_vod_id = find_matching_vod(start_time_str, recent_vods)
if matched_vod_id:
print(f"[VOD Resolve] Stream ID {stream_id} ({start_time_str}) matched with Twitch VOD {matched_vod_id}.")
# Send update to server
up_res = requests.post(f"{API_URL}/api/streams/{stream_id}/resolve-vod",
json={"twitchVodId": matched_vod_id},
headers={"x-api-key": API_KEY, "Content-Type": "application/json"},
timeout=10)
if up_res.status_code == 200:
print(f"[VOD Resolve] Successfully updated VOD ID for stream {stream_id}!")
else:
print(f"[VOD Resolve] Failed to update VOD ID: {up_res.status_code}")
else:
if age_hours > 3.0:
print(f"[VOD Resolve] Stream ID {stream_id} is older than 3 hours and has no matching VOD. Marking as completed to clear queue.")
try:
requests.post(f"{API_URL}/api/streams/mark-backfilled", json={"streamId": stream_id}, headers=headers, timeout=10)
except Exception as mark_e:
print(f"[VOD Resolve] Error marking stream as completed: {mark_e}")
else:
print(f"[VOD Resolve] No matching VOD found for stream ID {stream_id} ({start_time_str}) within timeframe (will retry).")
except Exception as e:
print(f"[VOD Resolve] Exception during resolve loop: {e}")
def load_processed_streams():
"""Load successfully processed stream IDs from a local file"""
if not os.path.exists(PROCESSED_FILE):
return set()
try:
with open(PROCESSED_FILE, "r") as f:
return set(line.strip() for line in f if line.strip())
except Exception as e:
print(f"[Backfill] Error loading processed streams file: {e}")
return set()
def save_processed_stream(stream_id):
"""Save a successfully processed stream ID to the local file"""
try:
with open(PROCESSED_FILE, "a") as f:
f.write(f"{stream_id}\n")
except Exception as e:
print(f"[Backfill] Error writing to processed streams file: {e}")
def run_backfill_loop():
"""Main loop to check and backfill VOD chat comments ONLY"""
while not stop_flag.is_set():
# Try to resolve any missing VOD IDs once every 5 minutes (300s) to avoid log spam
global last_resolve_time
now_sec = time.time()
if now_sec - last_resolve_time > 300:
resolve_missing_vods()
last_resolve_time = now_sec
print(f"[Backfill] Checking for pending VOD backfill tasks...")
processed_streams = load_processed_streams()
try:
headers_get = {"x-api-key": API_KEY}
res = requests.get(f"{API_URL}/api/streams/pending-backfill", headers=headers_get, timeout=10)
if res.status_code == 200:
data = res.json()
pending_streams = data.get("streams", [])
# Filter out streams we have already backfilled chat for
unprocessed_streams = [s for s in pending_streams if str(s.get("id")) not in processed_streams]
if unprocessed_streams:
print(f"[Backfill] Found {len(unprocessed_streams)} pending VOD chat backfill(s). Starting automated processing...")
target = unprocessed_streams[0]
stream_id = target.get("id")
vod_id = target.get("twitch_vod_id")
title = target.get("title", "Unknown Archive")
start_time = target.get("start_time")
gaps = target.get("gaps", [])
if not gaps:
print(f"[Backfill] No gaps found for stream {stream_id}, skipping.")
save_processed_stream(stream_id)
continue
print(f"\n=========================================================")
print(f"[Backfill] Processing Chat Gaps: {title}")
print(f"[Backfill] VOD ID: {vod_id}")
print(f"[Backfill] Stream ID: {stream_id}")
print(f"[Backfill] Gaps count: {len(gaps)}")
print(f"=========================================================")
# 1. Download and upload chat segments for each gap
for gap_idx, gap in enumerate(gaps, 1):
from_off = gap.get('from_offset', 0)
to_off = gap.get('to_offset', None)
to_str = str(to_off) + 's' if to_off is not None else 'end'
print(f"\n[Backfill] Gap {gap_idx}/{len(gaps)}: chat {from_off}s → {to_str}")
chat_comments = download_vod_chat(vod_id, start_offset=from_off, end_offset=to_off)
if chat_comments:
print(f"[Backfill] Uploading {len(chat_comments)} messages...")
batch_size = 100
headers_post = {"x-api-key": API_KEY, "Content-Type": "application/json"}
for i in range(0, len(chat_comments), batch_size):
batch = chat_comments[i:i+batch_size]
try:
requests.post(f"{API_URL}/api/log/messages", json={"messages": batch}, headers=headers_post, timeout=10)
except Exception as e:
print(f"[Backfill] Chat batch upload error: {e}")
# Extract and upload roles
roles = [
{
"username": m["username"],
"displayName": m.get("displayName", m["username"]),
"isMod": m.get("isMod", False),
"isSub": m.get("isSub", False),
"isVip": m.get("isVip", False),
"timestamp": m.get("timestamp")
}
for m in chat_comments
if m.get("isMod") or m.get("isSub") or m.get("isVip")
]
if roles:
print(f"[Backfill] Uploading {len(roles)} roles...")
for i in range(0, len(roles), 500):
batch = roles[i:i+500]
try:
requests.post(f"{API_URL}/api/log/roles", json={"roles": batch}, headers=headers_post, timeout=10)
except Exception as e:
print(f"[Backfill] Role batch upload error: {e}")
else:
print(f"[Backfill] No chat in this gap.")
# 2. Mark locally as processed (does NOT mark completed on server, so worker.py can do voice)
save_processed_stream(stream_id)
print(f"[Backfill] Successfully backfilled chat for stream {stream_id}. Recorded to local processed file.")
# Loop again immediately
continue
else:
print("[Backfill] No pending VOD chat backfills found.")
else:
print(f"[Backfill] Failed to fetch pending backfills: {res.status_code}")
except Exception as err:
print(f"[Backfill] Error checking pending backfills: {err}")
print(f"[Backfill] Retrying check in 60 seconds...")
time.sleep(60)
# =========================================================================
# MAIN ENTRYPOINT
# =========================================================================
if __name__ == "__main__":
print("=========================================================")
print(" Twitch Chat & Moderation Actions Logger & Chat-Sync ")
print("=========================================================")
print(f"Target Channel: {TWITCH_CHANNEL}")
print(f"API Backend URL: {API_URL}")
print("=========================================================")
# Start Twitch IRC background threads (runs 24/7 to catch chat/mod actions)
t_irc = threading.Thread(target=twitch_irc_listener, daemon=True)
t_chat_send = threading.Thread(target=chat_sender, daemon=True)
t_mod_send = threading.Thread(target=mod_action_sender, daemon=True)
t_heartbeat = threading.Thread(target=coverage_heartbeat_loop, daemon=True)
t_irc.start()
t_chat_send.start()
t_mod_send.start()
t_heartbeat.start()
# Start coverage window for the active stream
try:
import requests as _req
_headers = {"x-api-key": API_KEY}
_stream_res = _req.get(f"{API_URL}/api/streams/active", headers=_headers, timeout=5)
if _stream_res.status_code == 200:
_active = _stream_res.json()
_sid = _active.get("stream", {}).get("id") or _active.get("id")
if _sid:
start_coverage(_sid, source='live')
except Exception as _e:
print(f"[Coverage] Could not get active stream for coverage: {_e}")
# Start VOD backfilling (GQL chat ONLY) in the main thread
try:
run_backfill_loop()
except KeyboardInterrupt:
print("\n[Shutting Down] Gracefully stopping threads...")
finally:
stop_coverage()
coverage_stop_flag.set()
stop_flag.set()
time.sleep(1)
print("[Shutting Down] Done.")