329 lines
13 KiB
Python
329 lines
13 KiB
Python
import queue
|
|
import redis
|
|
import requests
|
|
import socket
|
|
import ssl
|
|
import threading
|
|
import time
|
|
from auth_db import auth_db
|
|
from utils import strip_bbcode, parse_username
|
|
from config import (
|
|
SERVER, PORT, BOTNICK, NICKSERV_USER, NICKSERV_PASS, API_ENDPOINT, API_TOKEN, VERIFY_ENDPOINT,
|
|
LOBBY_CHANNEL, MAIN_CHANNEL, ADMIN_CHANNEL, STAFF_CHANNELS,
|
|
RECONNECT_DELAY, GROUP_NAMES, STAFF_GROUP_IDS, ADMIN_GROUP_IDS, OPER_USER, OPER_PASS, REDIS_PW
|
|
)
|
|
|
|
class IRCBot:
|
|
def __init__(self):
|
|
self.irc = None
|
|
self.nickname = BOTNICK
|
|
self.running = False
|
|
self.lock = threading.Lock()
|
|
self.send_queue = queue.Queue()
|
|
|
|
# Connect to IRC server, handle nick conflicts, wait for welcome, and join channel
|
|
def connect(self):
|
|
attempt = 0
|
|
while True:
|
|
try:
|
|
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
self.irc = ssl.wrap_socket(raw_sock)
|
|
self.irc.connect((SERVER, PORT))
|
|
self.irc.send(f"NICK {self.nickname}\r\n".encode("utf-8"))
|
|
self.irc.send(f"USER {self.nickname} 0 * :{self.nickname}\r\n".encode("utf-8"))
|
|
print("[+] Sent NICK/USER, waiting...")
|
|
|
|
while True:
|
|
resp = self.irc.recv(2048)
|
|
if not resp:
|
|
raise ConnectionError("No data received.")
|
|
for line in resp.decode(errors="ignore").split("\r\n"):
|
|
if not line:
|
|
continue
|
|
|
|
print(f"[IRC RAW] {line}")
|
|
|
|
if line.startswith("PING"):
|
|
self.irc.send(f"PONG {line.split()[1]}\r\n".encode("utf-8"))
|
|
|
|
elif " 001 " in line:
|
|
print(f"[+] Connected as {self.nickname}")
|
|
|
|
# Identify with NickServ
|
|
self.irc.send(f"PRIVMSG NickServ :IDENTIFY {NICKSERV_USER} {NICKSERV_PASS}\r\n".encode("utf-8"))
|
|
time.sleep(2) # give time for identify to settle
|
|
|
|
# Send OPER command
|
|
self.irc.send(f"OPER {OPER_USER} {OPER_PASS}\r\n".encode("utf-8"))
|
|
print("[IRC CMD] Sent OPER")
|
|
|
|
# Optionally ghost the previous nick if not using SASL
|
|
if self.nickname != BOTNICK:
|
|
print("[*] Reclaiming original nickname")
|
|
self.irc.send(f"PRIVMSG NickServ :GHOST {BOTNICK} {NICKSERV_PASS}\r\n".encode("utf-8"))
|
|
time.sleep(1)
|
|
self.irc.send(f"NICK {BOTNICK}\r\n".encode("utf-8"))
|
|
self.nickname = BOTNICK
|
|
time.sleep(1)
|
|
self.irc.send(f"PRIVMSG NickServ :IDENTIFY {NICKSERV_USER} {NICKSERV_PASS}\r\n".encode("utf-8"))
|
|
time.sleep(1)
|
|
|
|
self.irc.send(f"JOIN {LOBBY_CHANNEL}\r\n".encode("utf-8"))
|
|
return
|
|
elif " 433 " in line:
|
|
# Nickname is already in use
|
|
print(f"[!] Nick {self.nickname} already in use, trying alternate...")
|
|
attempt += 1
|
|
self.nickname = f"{BOTNICK}_{attempt}"
|
|
self.irc.send(f"NICK {self.nickname}\r\n".encode("utf-8"))
|
|
break # Break inner loop to restart connection flow with new nick
|
|
except Exception as e:
|
|
print(f"[-] IRC connect error: {e}")
|
|
time.sleep(RECONNECT_DELAY)
|
|
|
|
def start(self):
|
|
with self.lock:
|
|
if self.running:
|
|
return
|
|
self.running = True
|
|
print("[+] Starting IRC listener thread")
|
|
threading.Thread(target=self.listen_loop, daemon=True).start()
|
|
print("[+] Starting IRC writer thread")
|
|
threading.Thread(target=self.writer_loop, daemon=True).start()
|
|
print("[+] Starting Redis subscriber thread")
|
|
threading.Thread(target=self.redis_subscribe_loop, daemon=True).start()
|
|
print("[+] IRC + Redis bridge fully initialized")
|
|
|
|
def listen_loop(self):
|
|
while True:
|
|
try:
|
|
data = self.irc.recv(2048).decode(errors="ignore")
|
|
if not data:
|
|
raise ConnectionError("Disconnected.")
|
|
for line in data.strip().split("\r\n"):
|
|
print(f"[IRC RAW] {line}") # DEBUG FOR NOW
|
|
if line.startswith("PING"):
|
|
self.irc.send(f"PONG {line.split()[1]}\r\n".encode("utf-8"))
|
|
elif "You are now an IRC Operator" in line or "oper" in line.lower():
|
|
print("[+] Oper login succeeded")
|
|
elif "PRIVMSG" in line:
|
|
self.handle_message(line)
|
|
elif " PART " in line or " QUIT " in line:
|
|
self.handle_join_leave(line)
|
|
except Exception as e:
|
|
print(f"[-] IRC error: {e}")
|
|
self.connect()
|
|
|
|
def redis_subscribe_loop(self):
|
|
r = redis.Redis(host='localhost', port=6969, password=REDIS_PW, db=0)
|
|
pubsub = r.pubsub()
|
|
pubsub.subscribe("chatbox_to_irc")
|
|
print("[REDIS] Subscribed to chatbox_to_irc")
|
|
|
|
for message in pubsub.listen():
|
|
if message["type"] == "message":
|
|
try:
|
|
text = message["data"].decode()
|
|
print(f"[REDIS] Got message: {text}")
|
|
self.send_to_channel(text)
|
|
except Exception as e:
|
|
print(f"[-] Redis IRC dispatch error: {e}")
|
|
|
|
# Handle verification messages from users
|
|
def handle_message(self, line):
|
|
user = parse_username(line)
|
|
if user.lower() == self.nickname.lower():
|
|
return
|
|
|
|
# Extract target (channel or bot nick) and message
|
|
try:
|
|
prefix, trailing = line.split(" :", 1)
|
|
_, _, target = prefix.strip().split(" ", 2)
|
|
msg = trailing.strip()
|
|
except ValueError:
|
|
print(f"[-] Malformed PRIVMSG line: {line}")
|
|
return
|
|
|
|
is_private = target.lower() == self.nickname.lower()
|
|
|
|
if msg.startswith("!verify"):
|
|
if is_private:
|
|
self.verify_user(user, msg)
|
|
else:
|
|
self.send_notice(user, "Please send !verify as a private message.")
|
|
return
|
|
|
|
# Relay public message if user is verified
|
|
if not is_private:
|
|
user_data = auth_db.get_verified_user(user)
|
|
print(f"[DEBUG] Lookup for user '{user}' returned: {user_data}")
|
|
if user_data:
|
|
site_user, irc_key, _ = user_data
|
|
print(f"[DEBUG] Sending message from {site_user} with token {irc_key}: {msg}")
|
|
self.api_call(site_user, msg, irc_key)
|
|
else:
|
|
print(f"[DEBUG] No verified user found for: {user}")
|
|
|
|
# Handle verification messages from users
|
|
def verify_user(self, nick, msg):
|
|
parts = msg.split(" ")
|
|
if len(parts) != 3:
|
|
self.send_notice(nick, "Usage: !verify <username> <irc_key>")
|
|
return
|
|
|
|
_, site_user, irc_key = parts
|
|
|
|
try:
|
|
resp = requests.post(
|
|
VERIFY_ENDPOINT,
|
|
headers={
|
|
"Authorization": f"Bearer {API_TOKEN}",
|
|
"Content-Type": "application/json"
|
|
},
|
|
json={
|
|
"username": site_user,
|
|
"irc_key": irc_key
|
|
},
|
|
timeout=10
|
|
)
|
|
|
|
if resp.ok and resp.json().get("success"):
|
|
group_id = resp.json().get("group_id", 4)
|
|
group_name = GROUP_NAMES.get(group_id, "Unknown")
|
|
|
|
auth_db.verify_user(nick, site_user, irc_key, group_id)
|
|
print(f"[DEBUG] Verification success: {site_user} ({group_name}) - group_id={group_id}")
|
|
self.send_notice(nick, f"Verified as {site_user} ({group_name})")
|
|
time.sleep(1) # Add delay before sending SAJOIN
|
|
|
|
# Join main lobby
|
|
print(f"[DEBUG] Sending SAJOIN for {nick} to {MAIN_CHANNEL}")
|
|
self.send_raw_command(f"SAJOIN {nick} {MAIN_CHANNEL}")
|
|
|
|
# Join staff chats if in staff group
|
|
if group_id in STAFF_GROUP_IDS:
|
|
for channel in STAFF_CHANNELS:
|
|
print(f"[DEBUG] Sending SAJOIN for {nick} to {channel}")
|
|
self.send_raw_command(f"SAJOIN {nick} {channel}")
|
|
|
|
# Join admin room if in admin group
|
|
if group_id in ADMIN_GROUP_IDS:
|
|
self.send_raw_command(f"SAJOIN {nick} {ADMIN_CHANNEL}")
|
|
|
|
else:
|
|
self.send_notice(nick, f"Verification failed: {resp.json().get('error', 'Unknown')}")
|
|
|
|
except Exception as e:
|
|
print(f"[-] Error verifying IRC key: {e}")
|
|
self.send_notice(nick, "Verification failed (server error)")
|
|
|
|
# Send notice to user
|
|
def send_notice(self, nick, msg):
|
|
if not self.irc or not self.is_connected():
|
|
print("[-] IRC connection is closed or unavailable.")
|
|
return
|
|
try:
|
|
with self.lock: # Ensure thread-safe socket access
|
|
print(f"[QUEUE] Queuing message: {msg.strip()}")
|
|
self.send_queue.put(f"NOTICE {nick} :{msg}\r\n")
|
|
except Exception as e:
|
|
print(f"[-] Notice failed: {e}")
|
|
|
|
# Send IRC message to channel
|
|
def send_to_channel(self, msg):
|
|
if not self.irc or not self.is_connected():
|
|
print("[-] IRC connection is closed or unavailable.")
|
|
return
|
|
clean = strip_bbcode(msg).replace("\n", " ").replace("\r", " ")
|
|
try:
|
|
with self.lock: # Ensure thread-safe socket access
|
|
print(f"[QUEUE] Queuing message: {clean.strip()}")
|
|
self.send_queue.put(f"PRIVMSG {LOBBY_CHANNEL} :{clean}\r\n")
|
|
except ssl.SSLEOFError as e:
|
|
print(f"[SSL ERROR] IRC connection closed: {e}")
|
|
self.irc = None # Mark socket dead
|
|
except Exception as e:
|
|
print(f"[-] Failed to send: {e}")
|
|
|
|
# Send IRC commands to server
|
|
def send_raw_command(self, command):
|
|
if not self.irc or not self.is_connected():
|
|
print("[-] IRC connection is closed or unavailable.")
|
|
return
|
|
if self.irc:
|
|
try:
|
|
with self.lock: # Ensure thread-safe socket access
|
|
print(f"[QUEUE] Queuing message: {command.strip()}")
|
|
self.send_queue.put(f"{command}\r\n")
|
|
print(f"[IRC CMD] {command}")
|
|
except Exception as e:
|
|
print(f"[-] Failed to send IRC command: {e}")
|
|
|
|
|
|
# API call ChatBridgeController
|
|
def api_call(self, user, msg, irc_key):
|
|
try:
|
|
payload = {
|
|
"username": user,
|
|
"irc_key": irc_key,
|
|
"text": msg,
|
|
"source": "IRC"
|
|
}
|
|
headers = {
|
|
"Authorization": f"Bearer {API_TOKEN}",
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json"
|
|
}
|
|
print(f"[DEBUG] Posting to API: {API_ENDPOINT}")
|
|
print(f"[DEBUG] Headers: {headers}")
|
|
print(f"[DEBUG] Payload: {payload}")
|
|
resp = requests.post(API_ENDPOINT, headers=headers, json=payload)
|
|
|
|
print(f"[DEBUG] API response status: {resp.status_code}")
|
|
print(f"[DEBUG] API response body: {resp.text}")
|
|
|
|
except Exception as e:
|
|
print(f"[-] API call failed: {e}")
|
|
|
|
def handle_join_leave(self, line):
|
|
user = parse_username(line)
|
|
auth_db.remove_verified_user(user)
|
|
|
|
# Helper method for checking connection
|
|
def is_connected(self):
|
|
try:
|
|
if not self.irc:
|
|
return False
|
|
self.irc.getpeername()
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
# IRC writer method to work with queue
|
|
def writer_loop(self):
|
|
print("[WRITER] Writer loop started")
|
|
while True:
|
|
try:
|
|
msg = self.send_queue.get()
|
|
print(f"[WRITER] Got message from queue: {msg.strip()}")
|
|
|
|
if not self.irc or not self.is_connected():
|
|
print("[-] Writer loop: IRC socket is down, message discarded.")
|
|
continue
|
|
|
|
with self.lock:
|
|
print(f"[WRITER] Sending to IRC: {msg.strip()}")
|
|
self.irc.send(msg.encode("utf-8"))
|
|
|
|
except (ConnectionResetError, BrokenPipeError, ssl.SSLError) as net_err:
|
|
print(f"[WRITER NET ERROR] Socket error: {net_err}")
|
|
self.irc = None
|
|
time.sleep(RECONNECT_DELAY)
|
|
continue
|
|
|
|
except Exception as e:
|
|
print(f"[WRITER ERROR] Unexpected error: {e}")
|
|
time.sleep(1)
|
|
|
|
bot = IRCBot()
|