1016 lines
34 KiB
Python
1016 lines
34 KiB
Python
#!/usr/bin/env python
|
||
# IRC Relay Bot - developed by acidvegas in python (https://git.acid.vegas/pyrelay)
|
||
|
||
import asyncio
|
||
import logging
|
||
import ssl
|
||
import time
|
||
|
||
try:
|
||
import apv
|
||
except ImportError:
|
||
raise ImportError('missing \'apv\' library (pip install apv)')
|
||
|
||
try:
|
||
from python_socks.async_.asyncio import Proxy
|
||
from python_socks import ProxyType
|
||
except ImportError:
|
||
raise ImportError('missing \'python-socks\' library (pip install python-socks[asyncio])')
|
||
|
||
import config
|
||
|
||
# Formatting Control Characters / Color Codes
|
||
bold = '\x02'
|
||
italic = '\x1D'
|
||
underline = '\x1F'
|
||
reverse = '\x16'
|
||
reset = '\x0f'
|
||
white = '00'
|
||
black = '01'
|
||
blue = '02'
|
||
green = '03'
|
||
red = '04'
|
||
brown = '05'
|
||
purple = '06'
|
||
orange = '07'
|
||
yellow = '08'
|
||
light_green = '09'
|
||
cyan = '10'
|
||
light_cyan = '11'
|
||
light_blue = '12'
|
||
pink = '13'
|
||
grey = '14'
|
||
light_grey = '15'
|
||
|
||
|
||
def color(msg: str, foreground: str, background: str = None) -> str:
|
||
'''
|
||
Color a string with the specified foreground and background colors.
|
||
|
||
:param msg: The string to color.
|
||
:param foreground: The foreground color to use.
|
||
:param background: The background color to use.
|
||
'''
|
||
return f'\x03{foreground},{background}{msg}{reset}' if background else f'\x03{foreground}{msg}{reset}'
|
||
|
||
|
||
def has_irc_colors(text: str) -> bool:
|
||
'''
|
||
Check if text contains IRC color codes or formatting.
|
||
|
||
:param text: The text to check.
|
||
'''
|
||
# IRC formatting codes
|
||
formatting_codes = [
|
||
'\x02', # Bold
|
||
'\x03', # Color
|
||
'\x04', # Hex color
|
||
'\x0f', # Reset
|
||
'\x16', # Reverse
|
||
'\x1d', # Italic
|
||
'\x1e', # Strikethrough
|
||
'\x1f', # Underline
|
||
'\x11', # Monospace
|
||
]
|
||
return any(code in text for code in formatting_codes)
|
||
|
||
|
||
def parse_irc_line(line: str) -> str:
|
||
'''
|
||
Parse and colorize an IRC protocol line.
|
||
Preserves existing IRC colors/formatting if present.
|
||
|
||
:param line: The raw IRC line to parse and colorize.
|
||
'''
|
||
if not line:
|
||
return ''
|
||
|
||
# Check if the message content has IRC colors/formatting
|
||
# If so, do minimal colorization to preserve the original formatting
|
||
if has_irc_colors(line):
|
||
parts = line.split(' ')
|
||
result = []
|
||
idx = 0
|
||
|
||
# Parse prefix (if exists) - colorize this part
|
||
if parts[0].startswith(':'):
|
||
prefix = parts[0][1:]
|
||
idx = 1
|
||
|
||
# Check if it's a nick!user@host or server
|
||
if '!' in prefix:
|
||
nick, rest = prefix.split('!', 1)
|
||
if '@' in rest:
|
||
user, host = rest.split('@', 1)
|
||
result.append(color(':', grey))
|
||
result.append(color(nick, light_cyan))
|
||
result.append(color('!', grey))
|
||
result.append(color(user, cyan))
|
||
result.append(color('@', grey))
|
||
result.append(color(host, cyan))
|
||
else:
|
||
result.append(color(':' + prefix, cyan))
|
||
else:
|
||
# Server name
|
||
result.append(color(':' + prefix, light_blue))
|
||
|
||
result.append(' ')
|
||
|
||
# Parse command - colorize this part
|
||
if idx < len(parts):
|
||
command = parts[idx]
|
||
idx += 1
|
||
|
||
# Numeric replies
|
||
if command.isdigit():
|
||
result.append(color(command, pink))
|
||
# Common commands with specific colors
|
||
elif command.upper() in ('PRIVMSG', 'NOTICE'):
|
||
result.append(color(command, green))
|
||
elif command.upper() in ('JOIN', 'PART', 'QUIT', 'KICK'):
|
||
result.append(color(command, yellow))
|
||
elif command.upper() in ('MODE', 'TOPIC'):
|
||
result.append(color(command, orange))
|
||
elif command.upper() in ('NICK'):
|
||
result.append(color(command, light_green))
|
||
elif command.upper() in ('PING', 'PONG'):
|
||
result.append(color(command, cyan))
|
||
elif command.upper() in ('ERROR'):
|
||
result.append(color(command, red))
|
||
else:
|
||
result.append(color(command, light_grey))
|
||
|
||
result.append(' ')
|
||
|
||
# For parameters, preserve original formatting if colors are present
|
||
if idx < len(parts):
|
||
remaining = ' '.join(parts[idx:])
|
||
# Check if this part has colors
|
||
if has_irc_colors(remaining):
|
||
# Just append as-is to preserve colors
|
||
result.append(remaining)
|
||
else:
|
||
# No colors in remaining, colorize normally
|
||
while idx < len(parts):
|
||
part = parts[idx]
|
||
|
||
# Trailing parameter (starts with :)
|
||
if part.startswith(':'):
|
||
trailing = ' '.join(parts[idx:])[1:]
|
||
result.append(color(':', grey))
|
||
result.append(color(trailing, white))
|
||
break
|
||
# Channel
|
||
elif part.startswith('#') or part.startswith('&'):
|
||
result.append(color(part, light_green))
|
||
# Looks like a nickname (no special chars except allowed ones)
|
||
elif part and not any(c in part for c in '!@.:'):
|
||
result.append(color(part, light_cyan))
|
||
else:
|
||
result.append(color(part, light_grey))
|
||
|
||
result.append(' ')
|
||
idx += 1
|
||
|
||
return ''.join(result).rstrip()
|
||
|
||
# No IRC colors detected, use full colorization
|
||
parts = line.split(' ')
|
||
result = []
|
||
idx = 0
|
||
|
||
# Parse prefix (if exists)
|
||
if parts[0].startswith(':'):
|
||
prefix = parts[0][1:]
|
||
idx = 1
|
||
|
||
# Check if it's a nick!user@host or server
|
||
if '!' in prefix:
|
||
nick, rest = prefix.split('!', 1)
|
||
if '@' in rest:
|
||
user, host = rest.split('@', 1)
|
||
result.append(color(':', grey))
|
||
result.append(color(nick, light_cyan))
|
||
result.append(color('!', grey))
|
||
result.append(color(user, cyan))
|
||
result.append(color('@', grey))
|
||
result.append(color(host, cyan))
|
||
else:
|
||
result.append(color(':' + prefix, cyan))
|
||
else:
|
||
# Server name
|
||
result.append(color(':' + prefix, light_blue))
|
||
|
||
result.append(' ')
|
||
|
||
# Parse command
|
||
if idx < len(parts):
|
||
command = parts[idx]
|
||
idx += 1
|
||
|
||
# Numeric replies
|
||
if command.isdigit():
|
||
result.append(color(command, pink))
|
||
# Common commands with specific colors
|
||
elif command.upper() in ('PRIVMSG', 'NOTICE'):
|
||
result.append(color(command, green))
|
||
elif command.upper() in ('JOIN', 'PART', 'QUIT', 'KICK'):
|
||
result.append(color(command, yellow))
|
||
elif command.upper() in ('MODE', 'TOPIC'):
|
||
result.append(color(command, orange))
|
||
elif command.upper() in ('NICK'):
|
||
result.append(color(command, light_green))
|
||
elif command.upper() in ('PING', 'PONG'):
|
||
result.append(color(command, cyan))
|
||
elif command.upper() in ('ERROR'):
|
||
result.append(color(command, red))
|
||
else:
|
||
result.append(color(command, light_grey))
|
||
|
||
result.append(' ')
|
||
|
||
# Parse parameters
|
||
while idx < len(parts):
|
||
part = parts[idx]
|
||
|
||
# Trailing parameter (starts with :)
|
||
if part.startswith(':'):
|
||
trailing = ' '.join(parts[idx:])[1:]
|
||
result.append(color(':', grey))
|
||
result.append(color(trailing, white))
|
||
break
|
||
# Channel
|
||
elif part.startswith('#') or part.startswith('&'):
|
||
result.append(color(part, light_green))
|
||
# Looks like a nickname (no special chars except allowed ones)
|
||
elif part and not any(c in part for c in '!@.:'):
|
||
result.append(color(part, light_cyan))
|
||
else:
|
||
result.append(color(part, light_grey))
|
||
|
||
result.append(' ')
|
||
idx += 1
|
||
|
||
return ''.join(result).rstrip()
|
||
|
||
|
||
def parse_proxy(proxy_string: str) -> dict:
|
||
'''
|
||
Parse proxy string in format user:pass@host:port or host:port.
|
||
|
||
:param proxy_string: The proxy string to parse.
|
||
'''
|
||
if not proxy_string:
|
||
return None
|
||
|
||
username = None
|
||
password = None
|
||
host = None
|
||
port = None
|
||
|
||
# Check if authentication is included
|
||
if '@' in proxy_string:
|
||
auth, server = proxy_string.rsplit('@', 1)
|
||
if ':' in auth:
|
||
username, password = auth.split(':', 1)
|
||
else:
|
||
server = proxy_string
|
||
|
||
# Parse host and port
|
||
if ':' in server:
|
||
host, port_str = server.rsplit(':', 1)
|
||
try:
|
||
port = int(port_str)
|
||
except ValueError:
|
||
raise ValueError(f'Invalid proxy port: {port_str}')
|
||
else:
|
||
raise ValueError('Proxy must include port (host:port)')
|
||
|
||
return {
|
||
'host' : host,
|
||
'port' : port,
|
||
'username' : username,
|
||
'password' : password
|
||
}
|
||
|
||
|
||
class RelayConnection():
|
||
def __init__(self, server: str, port: int, use_ssl: bool, use_proxy: bool = False):
|
||
self.server = server
|
||
self.port = port
|
||
self.use_ssl = use_ssl
|
||
self.use_proxy = use_proxy
|
||
self.reader = None
|
||
self.writer = None
|
||
self.connected = False
|
||
self.registered = False
|
||
self.visible_host = None
|
||
|
||
|
||
async def connect(self):
|
||
'''Connect to the relay IRC server.'''
|
||
try:
|
||
# Use proxy only if requested and configured
|
||
if self.use_proxy and config.proxy:
|
||
proxy_info = parse_proxy(config.proxy)
|
||
proxy_type_map = {
|
||
'socks5' : ProxyType.SOCKS5,
|
||
'socks4' : ProxyType.SOCKS4,
|
||
'http' : ProxyType.HTTP
|
||
}
|
||
|
||
proxy_type_enum = proxy_type_map.get(config.proxy_type.lower(), ProxyType.SOCKS5)
|
||
|
||
proxy = Proxy(
|
||
proxy_type = proxy_type_enum,
|
||
host = proxy_info['host'],
|
||
port = proxy_info['port'],
|
||
username = proxy_info['username'],
|
||
password = proxy_info['password']
|
||
)
|
||
|
||
sock = await proxy.connect(
|
||
dest_host = self.server,
|
||
dest_port = self.port,
|
||
timeout = 15
|
||
)
|
||
|
||
options = {
|
||
'limit' : 1024,
|
||
'ssl' : ssl._create_unverified_context() if self.use_ssl else None,
|
||
'server_hostname': self.server if self.use_ssl else None
|
||
}
|
||
|
||
self.reader, self.writer = await asyncio.open_connection(sock=sock, **options)
|
||
logging.info(f'Relay connected to {self.server}:{self.port} via proxy')
|
||
else:
|
||
options = {
|
||
'host' : self.server,
|
||
'port' : self.port,
|
||
'limit' : 1024,
|
||
'ssl' : ssl._create_unverified_context() if self.use_ssl else None
|
||
}
|
||
self.reader, self.writer = await asyncio.wait_for(asyncio.open_connection(**options), 15)
|
||
logging.info(f'Relay connected to {self.server}:{self.port}')
|
||
|
||
self.connected = True
|
||
except Exception as ex:
|
||
logging.error(f'Relay connection failed: {ex}')
|
||
raise
|
||
|
||
|
||
async def disconnect(self):
|
||
'''Disconnect from the relay IRC server.'''
|
||
if self.writer:
|
||
try:
|
||
self.writer.close()
|
||
await self.writer.wait_closed()
|
||
except Exception:
|
||
pass
|
||
self.connected = False
|
||
self.registered = False
|
||
self.visible_host = None
|
||
logging.info(f'Relay disconnected from {self.server}:{self.port}')
|
||
|
||
|
||
async def raw(self, data: str):
|
||
'''
|
||
Send raw data to the relay IRC server.
|
||
|
||
:param data: The raw data to send to the IRC server.
|
||
'''
|
||
if self.connected and self.writer:
|
||
self.writer.write(data[:510].encode('utf-8') + b'\r\n')
|
||
await self.writer.drain()
|
||
|
||
|
||
class Bot():
|
||
def __init__(self):
|
||
self.nickname = config.nickname
|
||
self.username = config.username
|
||
self.realname = config.realname
|
||
self.reader = None
|
||
self.writer = None
|
||
self.last = time.time()
|
||
self.slow = False
|
||
self.relay = None
|
||
self.relay_task = None
|
||
self.enabled = True # Bot enabled/disabled state
|
||
self.admin_only = False # Admin-only mode
|
||
self.muted = False # Mute relay output in main channel (config.channel)
|
||
|
||
|
||
async def action(self, chan: str, msg: str):
|
||
'''
|
||
Send an ACTION to the IRC server.
|
||
|
||
:param chan: The channel to send the ACTION to.
|
||
:param msg: The message to send to the channel.
|
||
'''
|
||
await self.sendmsg(chan, f'\x01ACTION {msg}\x01')
|
||
|
||
|
||
async def raw(self, data: str):
|
||
'''
|
||
Send raw data to the IRC server.
|
||
|
||
:param data: The raw data to send to the IRC server. (512 bytes max including crlf)
|
||
'''
|
||
self.writer.write(data[:510].encode('utf-8') + b'\r\n')
|
||
|
||
|
||
async def sendmsg(self, target: str, msg: str):
|
||
'''
|
||
Send a PRIVMSG to the IRC server.
|
||
|
||
:param target: The target to send the PRIVMSG to. (channel or user)
|
||
:param msg: The message to send to the target.
|
||
'''
|
||
await self.raw(f'PRIVMSG {target} :{msg}')
|
||
|
||
|
||
async def relay_sendmsg(self, msg: str):
|
||
'''
|
||
Send relay data to #relay (always) and config.channel (unless muted).
|
||
|
||
:param msg: The message to send.
|
||
'''
|
||
# Always send to #relay
|
||
await self.sendmsg('#relay', msg)
|
||
|
||
# Also send to main channel unless muted
|
||
if not self.muted and config.channel.lower() != '#relay':
|
||
await self.sendmsg(config.channel, msg)
|
||
|
||
|
||
async def connect(self):
|
||
'''Connect to the IRC server.'''
|
||
while True:
|
||
try:
|
||
options = {
|
||
'host' : config.server,
|
||
'port' : config.port,
|
||
'limit' : 1024,
|
||
'ssl' : ssl._create_unverified_context() if config.use_ssl else None
|
||
}
|
||
self.reader, self.writer = await asyncio.wait_for(asyncio.open_connection(**options), 15)
|
||
|
||
if config.password:
|
||
await self.raw('PASS ' + config.password)
|
||
await self.raw(f'USER {self.username} 0 * :{self.realname}')
|
||
await self.raw('NICK ' + self.nickname)
|
||
while not self.reader.at_eof():
|
||
data = await asyncio.wait_for(self.reader.readuntil(b'\r\n'), 300)
|
||
await self.handle(data.decode('utf-8').strip())
|
||
except Exception as ex:
|
||
logging.error(f'failed to connect to {config.server} ({str(ex)})')
|
||
finally:
|
||
await asyncio.sleep(30)
|
||
|
||
|
||
async def eventPRIVMSG(self, data: str):
|
||
'''
|
||
Handle the PRIVMSG event.
|
||
|
||
:param data: The data received from the IRC server.
|
||
'''
|
||
parts = data.split()
|
||
ident = parts[0][1:]
|
||
nick = parts[0].split('!')[0][1:]
|
||
target = parts[2]
|
||
msg = ' '.join(parts[3:])[1:]
|
||
|
||
# Check if user is admin
|
||
is_admin = (ident == config.admin_ident)
|
||
|
||
if target == self.nickname:
|
||
if is_admin:
|
||
if msg.startswith('!raw') and len(msg.split()) > 1:
|
||
option = ' '.join(msg.split()[1:])
|
||
await self.raw(option)
|
||
else:
|
||
if self.enabled:
|
||
await self.sendmsg(nick, 'Do NOT message me!')
|
||
|
||
if target.startswith('#'):
|
||
if msg.startswith('.relay'):
|
||
# Allow admin to use .relay toggle even when bot is disabled
|
||
if not self.enabled and not is_admin:
|
||
return
|
||
|
||
# If bot is disabled, only allow toggle command from admin
|
||
if not self.enabled and is_admin:
|
||
if len(msg.split()) >= 2 and msg.split()[1].lower() == 'toggle':
|
||
await self.handle_relay_command(target, nick, msg, ident)
|
||
return
|
||
|
||
# Check admin-only mode
|
||
if self.admin_only and not is_admin:
|
||
return
|
||
|
||
if time.time() - self.last < config.cmd_flood:
|
||
if not self.slow:
|
||
self.slow = True
|
||
await self.sendmsg(target, color('Slow down!', red))
|
||
else:
|
||
self.slow = False
|
||
await self.handle_relay_command(target, nick, msg, ident)
|
||
self.last = time.time()
|
||
|
||
|
||
async def handle_relay_command(self, channel: str, nick: str, msg: str, ident: str = None):
|
||
'''
|
||
Handle relay commands.
|
||
|
||
:param channel: The channel where the command was issued.
|
||
:param nick: The nickname of the user who issued the command.
|
||
:param msg: The full message containing the command.
|
||
:param ident: The full ident of the user (nick!user@host).
|
||
'''
|
||
parts = msg.split()
|
||
if len(parts) < 2:
|
||
await self.sendmsg(channel, color('Usage: ', cyan) + '.relay ' + color('/help', yellow) + ' | ' + color('/connect', yellow) + ' <server> <port> [ssl] [--proxy] | ' + color('/disconnect', yellow) + ' | ' + color('/info', yellow) + ' | ' + color('/mute', yellow) + ' | ' + color('toggle', yellow) + ' | ' + color('ignore', yellow) + ' | ' + color('<IRC_COMMAND>', yellow))
|
||
return
|
||
|
||
cmd = parts[1].lower()
|
||
|
||
# Check if user is admin
|
||
is_admin = (ident == config.admin_ident) if ident else False
|
||
|
||
if cmd == '/help':
|
||
# Display help information
|
||
help_lines = [
|
||
color('[', grey) + color('RELAY', pink) + color(']', grey) + ' ' + color('📖 PyRelay Commands:', green),
|
||
'',
|
||
color(' /connect', yellow) + ' ' + color('<server> <port> [ssl] [--proxy] [nick] [user] [realname]', light_grey),
|
||
' ' + color('→', grey) + ' Connect to an IRC network through the relay',
|
||
' ' + color('Flags:', cyan) + ' ' + color('ssl', yellow) + ' = use SSL/TLS, ' + color('--proxy', yellow) + ' = route through proxy',
|
||
' ' + color('Examples:', cyan),
|
||
' ' + color('.relay /connect irc.example.com 6697 ssl', white),
|
||
' ' + color('.relay /connect irc.example.com 6697 --proxy ssl', white),
|
||
' ' + color('.relay /connect irc.example.com 6667 --proxy MyBot myuser My Bot', white),
|
||
'',
|
||
color(' /info', yellow),
|
||
' ' + color('→', grey) + ' Show current relay connection status and details',
|
||
'',
|
||
color(' /disconnect', yellow),
|
||
' ' + color('→', grey) + ' Disconnect from the current relay network',
|
||
'',
|
||
color(' /mute', yellow),
|
||
' ' + color('→', grey) + ' Toggle relay output in main channel (always shown in #relay)',
|
||
'',
|
||
color(' toggle', yellow) + ' ' + color('[admin only]', red),
|
||
' ' + color('→', grey) + ' Toggle bot on/off (closes all connections when off)',
|
||
'',
|
||
color(' ignore', yellow) + ' ' + color('[admin only]', red),
|
||
' ' + color('→', grey) + ' Toggle admin-only mode (ignores everyone except admin)',
|
||
'',
|
||
color(' <IRC_COMMAND>', yellow),
|
||
' ' + color('→', grey) + ' Send raw IRC commands to the relay network',
|
||
' ' + color('Examples:', cyan),
|
||
' ' + color('.relay JOIN #channel', white),
|
||
' ' + color('.relay PRIVMSG #channel :Hello!', white),
|
||
' ' + color('.relay NICK newnick', white),
|
||
' ' + color('.relay MODE #channel +o user', white),
|
||
'',
|
||
color('Note:', light_blue) + ' Relay data always goes to #relay, use /mute to hide in main channel!'
|
||
]
|
||
|
||
for line in help_lines:
|
||
await self.sendmsg(channel, line)
|
||
|
||
elif cmd == 'toggle':
|
||
# Toggle bot on/off - admin only
|
||
if not is_admin:
|
||
await self.sendmsg(channel, color('[', grey) + color('RELAY', pink) + color(']', grey) + ' ' + color('Error:', red) + ' Admin only command')
|
||
return
|
||
|
||
self.enabled = not self.enabled
|
||
|
||
if self.enabled:
|
||
await self.sendmsg(channel, color('[', grey) + color('RELAY', pink) + color(']', grey) + ' ' + color('✓', green) + ' Bot ' + color('enabled', green))
|
||
else:
|
||
await self.sendmsg(channel, color('[', grey) + color('RELAY', pink) + color(']', grey) + ' ' + color('✗', red) + ' Bot ' + color('disabled', red) + ' - all connections closing')
|
||
|
||
# Disconnect relay if connected
|
||
if self.relay and self.relay.connected:
|
||
try:
|
||
await self.relay.raw('QUIT :Bot disabled')
|
||
await asyncio.sleep(0.5)
|
||
except Exception:
|
||
pass
|
||
|
||
# Cancel the reader task
|
||
if self.relay_task and not self.relay_task.done():
|
||
self.relay_task.cancel()
|
||
try:
|
||
await self.relay_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
|
||
self.relay_task = None
|
||
|
||
elif cmd == 'ignore':
|
||
# Toggle admin-only mode - admin only
|
||
if not is_admin:
|
||
await self.sendmsg(channel, color('[', grey) + color('RELAY', pink) + color(']', grey) + ' ' + color('Error:', red) + ' Admin only command')
|
||
return
|
||
|
||
self.admin_only = not self.admin_only
|
||
|
||
if self.admin_only:
|
||
await self.sendmsg(channel, color('[', grey) + color('RELAY', pink) + color(']', grey) + ' ' + color('Admin-only mode:', orange) + ' ' + color('enabled', green) + ' (ignoring non-admin users)')
|
||
else:
|
||
await self.sendmsg(channel, color('[', grey) + color('RELAY', pink) + color(']', grey) + ' ' + color('Admin-only mode:', orange) + ' ' + color('disabled', red) + ' (accepting all users)')
|
||
|
||
elif cmd == '/mute':
|
||
# Toggle mute mode - relay output in main channel
|
||
self.muted = not self.muted
|
||
|
||
if self.muted:
|
||
await self.sendmsg(channel, color('[', grey) + color('RELAY', pink) + color(']', grey) + ' ' + color('🔇', white) + ' Relay output ' + color('muted', red) + ' in ' + color(config.channel, cyan) + ' (still visible in ' + color('#relay', cyan) + ')')
|
||
else:
|
||
await self.sendmsg(channel, color('[', grey) + color('RELAY', pink) + color(']', grey) + ' ' + color('🔊', white) + ' Relay output ' + color('unmuted', green) + ' in ' + color(config.channel, cyan))
|
||
|
||
elif cmd == '/connect':
|
||
if len(parts) < 4:
|
||
await self.sendmsg(channel, color('Usage: ', cyan) + '.relay /connect ' + color('<server> <port> [ssl] [--proxy] [nick] [user] [realname]', yellow))
|
||
return
|
||
|
||
# Check for existing connection
|
||
if self.relay and self.relay.connected:
|
||
await self.sendmsg(channel, color('[', grey) + color('RELAY', pink) + color(']', grey) + ' Already connected to ' + color(self.relay.server, cyan) + ' - use ' + color('.relay /disconnect', yellow) + ' first')
|
||
return
|
||
|
||
# Clean up any stale relay objects
|
||
if self.relay:
|
||
try:
|
||
await self.relay.disconnect()
|
||
except Exception:
|
||
pass
|
||
self.relay = None
|
||
|
||
if self.relay_task and not self.relay_task.done():
|
||
self.relay_task.cancel()
|
||
try:
|
||
await self.relay_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
self.relay_task = None
|
||
|
||
server = parts[2].lower()
|
||
try:
|
||
port = int(parts[3])
|
||
except ValueError:
|
||
await self.sendmsg(channel, color('[', grey) + color('RELAY', pink) + color(']', grey) + ' Invalid port number')
|
||
return
|
||
|
||
# Prevent connecting to the same network
|
||
if server == config.server.lower() and port == config.port:
|
||
await self.sendmsg(channel, color('[', grey) + color('RELAY', pink) + color(']', grey) + ' ' + color('Error:', red) + ' Cannot relay to the same network the bot is connected to')
|
||
return
|
||
|
||
# Parse optional arguments
|
||
idx = 4
|
||
use_ssl = False
|
||
use_proxy = False
|
||
relay_nick = None
|
||
relay_user = None
|
||
relay_realname = None
|
||
|
||
# Parse flags and arguments
|
||
while idx < len(parts):
|
||
arg = parts[idx]
|
||
|
||
if arg.lower() == 'ssl':
|
||
use_ssl = True
|
||
idx += 1
|
||
elif arg.lower() == '--proxy':
|
||
use_proxy = True
|
||
idx += 1
|
||
elif not relay_nick:
|
||
relay_nick = arg
|
||
idx += 1
|
||
elif not relay_user:
|
||
relay_user = arg
|
||
idx += 1
|
||
else:
|
||
# Everything else is realname
|
||
relay_realname = ' '.join(parts[idx:])
|
||
break
|
||
|
||
# Use config defaults if not provided
|
||
if not relay_nick:
|
||
relay_nick = f'{config.relay_nickname}{str(int(time.time()))[-4:]}'
|
||
if not relay_user:
|
||
relay_user = config.relay_username
|
||
if not relay_realname:
|
||
relay_realname = config.relay_realname
|
||
|
||
# Build connection message
|
||
conn_msg = color('[', grey) + color('RELAY', pink) + color(']', grey) + ' Connecting to ' + color(server, cyan) + ':' + color(str(port), cyan)
|
||
if use_ssl:
|
||
conn_msg += ' ' + color('[SSL]', green)
|
||
if use_proxy:
|
||
conn_msg += ' ' + color('[PROXY]', yellow)
|
||
await self.sendmsg(channel, conn_msg)
|
||
|
||
try:
|
||
self.relay = RelayConnection(server, port, use_ssl, use_proxy)
|
||
await self.relay.connect()
|
||
self.relay_task = asyncio.create_task(self.relay_reader())
|
||
|
||
# Auto-register with provided or default credentials
|
||
await asyncio.sleep(0.5)
|
||
|
||
# Check if relay is still connected (might have been disconnected by relay_reader)
|
||
if self.relay and self.relay.connected:
|
||
await self.sendmsg(channel, color('[', grey) + color('RELAY', pink) + color(']', grey) + ' ' + color('Connected!', green) + ' Registering as ' + color(relay_nick, yellow) + color('!', grey) + color(relay_user, yellow))
|
||
await self.relay.raw(f'NICK {relay_nick}')
|
||
await self.relay.raw(f'USER {relay_user} 0 * :{relay_realname}')
|
||
except Exception as ex:
|
||
await self.sendmsg(channel, color('[', grey) + color('RELAY', pink) + color(']', grey) + ' ' + color('Connection failed:', red) + f' {ex}')
|
||
self.relay = None
|
||
|
||
elif cmd == '/info':
|
||
if not self.relay or not self.relay.connected:
|
||
await self.sendmsg(channel, color('[', grey) + color('RELAY', pink) + color(']', grey) + ' Not connected to any server')
|
||
return
|
||
|
||
# Display current relay connection info
|
||
info_parts = []
|
||
info_parts.append(color('Server:', light_blue) + ' ' + color(f'{self.relay.server}:{self.relay.port}', cyan))
|
||
if self.relay.use_ssl:
|
||
info_parts.append(color('[SSL]', green))
|
||
|
||
if self.relay.registered:
|
||
info_parts.append(color('Status:', light_blue) + ' ' + color('Registered', green))
|
||
else:
|
||
info_parts.append(color('Status:', light_blue) + ' ' + color('Connecting...', yellow))
|
||
|
||
if self.relay.visible_host:
|
||
info_parts.append(color('Host:', light_blue) + ' ' + color(self.relay.visible_host, cyan))
|
||
|
||
formatted_info = (color(' │ ', grey)).join(info_parts)
|
||
await self.sendmsg(channel, color('[', grey) + color('RELAY', pink) + color(']', grey) + ' ' + color('ℹ️ Info:', green) + ' ' + formatted_info)
|
||
|
||
elif cmd == '/disconnect':
|
||
if not self.relay or not self.relay.connected:
|
||
await self.sendmsg(channel, color('[', grey) + color('RELAY', pink) + color(']', grey) + ' Not connected to any server')
|
||
return
|
||
|
||
server_name = self.relay.server
|
||
await self.sendmsg(channel, color('[', grey) + color('RELAY', pink) + color(']', grey) + ' Disconnecting from ' + color(server_name, cyan) + '...')
|
||
|
||
# Send QUIT to the relay server
|
||
try:
|
||
await self.relay.raw('QUIT :Relay closed')
|
||
await asyncio.sleep(0.5)
|
||
except Exception:
|
||
pass
|
||
|
||
# Cancel the reader task (this will trigger cleanup in finally block)
|
||
if self.relay_task and not self.relay_task.done():
|
||
self.relay_task.cancel()
|
||
try:
|
||
await self.relay_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
|
||
self.relay_task = None
|
||
|
||
else:
|
||
if not self.relay or not self.relay.connected:
|
||
await self.sendmsg(channel, color('[', grey) + color('RELAY', pink) + color(']', grey) + ' Not connected. Use ' + color('.relay connect', yellow))
|
||
return
|
||
|
||
# Send the raw IRC command
|
||
try:
|
||
raw_command = ' '.join(parts[1:])
|
||
colorized_command = parse_irc_line(raw_command)
|
||
await self.relay_sendmsg(color('[', grey) + color('RELAY', pink) + color('] ', grey) + color('>>>', green) + ' ' + colorized_command)
|
||
await self.relay.raw(raw_command)
|
||
except Exception as ex:
|
||
await self.sendmsg(channel, color('[', grey) + color('RELAY', pink) + color(']', grey) + ' ' + color('Error sending command:', red) + f' {ex}')
|
||
logging.error(f'Error sending relay command: {ex}')
|
||
|
||
|
||
async def relay_reader(self):
|
||
'''
|
||
Read data from the relay connection and display it in #relay (and main channel unless muted).
|
||
'''
|
||
disconnect_reason = None
|
||
|
||
try:
|
||
while self.relay and self.relay.connected and self.relay.reader:
|
||
data = await asyncio.wait_for(self.relay.reader.readuntil(b'\r\n'), 300)
|
||
line = data.decode('utf-8').strip()
|
||
|
||
if not line:
|
||
continue
|
||
|
||
try:
|
||
parts = line.split()
|
||
|
||
# Detect ERROR messages (server disconnecting us)
|
||
if line.startswith('ERROR :'):
|
||
try:
|
||
colorized_line = parse_irc_line(line)
|
||
except Exception:
|
||
colorized_line = line
|
||
await self.relay_sendmsg(color('[', grey) + color('RELAY', pink) + color('] ', grey) + color('<<<', cyan) + ' ' + colorized_line)
|
||
disconnect_reason = 'Server sent ERROR'
|
||
break
|
||
|
||
# Detect K-Line/G-Line/Z-Line bans
|
||
elif len(parts) > 1 and parts[1] in ('465', '466', '520', '550'):
|
||
try:
|
||
colorized_line = parse_irc_line(line)
|
||
except Exception:
|
||
colorized_line = line
|
||
await self.relay_sendmsg(color('[', grey) + color('RELAY', pink) + color('] ', grey) + color('<<<', cyan) + ' ' + colorized_line)
|
||
disconnect_reason = 'Banned from server'
|
||
break
|
||
|
||
# Detect MODE from self (contains our visible host)
|
||
elif len(parts) > 1 and parts[1] == 'MODE' and parts[0].startswith(':'):
|
||
try:
|
||
colorized_line = parse_irc_line(line)
|
||
except Exception:
|
||
colorized_line = line
|
||
await self.relay_sendmsg(color('[', grey) + color('RELAY', pink) + color('] ', grey) + color('<<<', cyan) + ' ' + colorized_line)
|
||
|
||
# Extract host from :nick!user@host MODE nick :+modes
|
||
try:
|
||
prefix = parts[0][1:] # Remove leading :
|
||
if '!' in prefix and '@' in prefix:
|
||
nick = prefix.split('!')[0]
|
||
host = prefix.split('@')[1]
|
||
target = parts[2] if len(parts) > 2 else ''
|
||
|
||
# Check if it's a MODE for the nick itself (self-mode)
|
||
if target == nick and not self.relay.visible_host:
|
||
self.relay.visible_host = host
|
||
await self.relay_sendmsg(color('[', grey) + color('RELAY', pink) + color(']', grey) + ' ' + color('Host:', light_blue) + ' ' + color(self.relay.visible_host, cyan))
|
||
except Exception as ex:
|
||
logging.debug(f'Error parsing MODE for host: {ex}')
|
||
|
||
# Auto-respond to PING
|
||
elif parts and parts[0] == 'PING':
|
||
await self.relay.raw('PONG ' + parts[1])
|
||
try:
|
||
colorized_line = parse_irc_line(line)
|
||
except Exception:
|
||
colorized_line = line
|
||
await self.relay_sendmsg(color('[', grey) + color('RELAY', pink) + color('] ', grey) + color('<<<', cyan) + ' ' + colorized_line)
|
||
|
||
# Detect successful registration
|
||
elif len(parts) > 1 and parts[1] == '001' and not self.relay.registered:
|
||
self.relay.registered = True
|
||
try:
|
||
colorized_line = parse_irc_line(line)
|
||
except Exception:
|
||
colorized_line = line
|
||
await self.relay_sendmsg(color('[', grey) + color('RELAY', pink) + color('] ', grey) + color('<<<', cyan) + ' ' + colorized_line)
|
||
|
||
# Try to extract visible host from welcome message
|
||
# Format: :server 001 nick :Welcome message nick!user@host
|
||
try:
|
||
welcome_msg = ' '.join(parts[3:])[1:]
|
||
if '!' in welcome_msg and '@' in welcome_msg:
|
||
# Extract the nick!user@host from the message
|
||
for word in welcome_msg.split():
|
||
if '!' in word and '@' in word:
|
||
self.relay.visible_host = word.split('@', 1)[1]
|
||
break
|
||
except Exception:
|
||
pass
|
||
|
||
if self.relay.visible_host:
|
||
await self.relay_sendmsg(color('[', grey) + color('RELAY', pink) + color(']', grey) + ' ' + color('✓', green) + ' ' + color('Registered!', green) + ' Visible host: ' + color(self.relay.visible_host, cyan))
|
||
else:
|
||
await self.relay_sendmsg(color('[', grey) + color('RELAY', pink) + color(']', grey) + ' ' + color('✓', green) + ' ' + color('Registered!', green) + ' You can now send commands')
|
||
|
||
# Detect hostname reveals (numeric 396, 042, 378, etc.)
|
||
elif len(parts) > 1 and parts[1] in ('396', '042', '378'):
|
||
try:
|
||
colorized_line = parse_irc_line(line)
|
||
except Exception:
|
||
colorized_line = line
|
||
await self.relay_sendmsg(color('[', grey) + color('RELAY', pink) + color('] ', grey) + color('<<<', cyan) + ' ' + colorized_line)
|
||
|
||
# Extract visible host from these numerics
|
||
# 396: :server 396 nick host :message (IRCd host change notification)
|
||
# 042: :server 042 nick unique_id :your unique ID
|
||
# 378: :server 378 nick :is connecting from *@host (some IRCds)
|
||
try:
|
||
if parts[1] == '396' and len(parts) >= 4:
|
||
self.relay.visible_host = parts[3]
|
||
await self.relay_sendmsg(color('[', grey) + color('RELAY', pink) + color(']', grey) + ' ' + color('Host:', light_blue) + ' ' + color(self.relay.visible_host, cyan))
|
||
|
||
elif parts[1] == '378' and len(parts) >= 4:
|
||
# Extract from "is connecting from *@host" or similar
|
||
msg = ' '.join(parts[4:])
|
||
if '@' in msg:
|
||
potential_host = msg.split('@')[-1].strip()
|
||
# Clean up any trailing text
|
||
potential_host = potential_host.split()[0]
|
||
self.relay.visible_host = potential_host
|
||
await self.relay_sendmsg(color('[', grey) + color('RELAY', pink) + color(']', grey) + ' ' + color('Host:', light_blue) + ' ' + color(self.relay.visible_host, cyan))
|
||
except Exception:
|
||
pass
|
||
|
||
else:
|
||
try:
|
||
colorized_line = parse_irc_line(line)
|
||
except Exception:
|
||
colorized_line = line
|
||
await self.relay_sendmsg(color('[', grey) + color('RELAY', pink) + color('] ', grey) + color('<<<', cyan) + ' ' + colorized_line)
|
||
|
||
except UnicodeDecodeError:
|
||
continue
|
||
|
||
except Exception as ex:
|
||
# If any error occurs while processing this line, still display the raw line
|
||
logging.error(f'Error processing relay line: {ex}')
|
||
try:
|
||
await self.relay_sendmsg(color('[', grey) + color('RELAY', pink) + color('] ', grey) + color('<<<', cyan) + ' ' + line)
|
||
except Exception:
|
||
pass # If we can't even send the raw line, just continue
|
||
|
||
except asyncio.CancelledError:
|
||
disconnect_reason = 'Disconnected by user'
|
||
except asyncio.TimeoutError:
|
||
disconnect_reason = 'Ping timeout'
|
||
except asyncio.IncompleteReadError:
|
||
disconnect_reason = 'Connection closed by remote host'
|
||
except ConnectionResetError:
|
||
disconnect_reason = 'Connection reset'
|
||
except Exception as ex:
|
||
disconnect_reason = f'Error: {ex}'
|
||
finally:
|
||
# Clean up the relay connection
|
||
if self.relay:
|
||
await self.relay.disconnect()
|
||
self.relay = None
|
||
|
||
# Notify channels of disconnect
|
||
if disconnect_reason:
|
||
await self.relay_sendmsg(color('[', grey) + color('RELAY', pink) + color(']', grey) + ' ' + color('✗', red) + ' ' + color('Disconnected:', red) + f' {disconnect_reason}')
|
||
logging.info(f'Relay disconnected: {disconnect_reason}')
|
||
|
||
|
||
async def handle(self, data: str):
|
||
'''
|
||
Handle the data received from the IRC server.
|
||
|
||
:param data: The data received from the IRC server.
|
||
'''
|
||
logging.info(data)
|
||
try:
|
||
parts = data.split()
|
||
if data.startswith('ERROR :Closing Link:'):
|
||
raise Exception('BANNED')
|
||
if parts[0] == 'PING':
|
||
await self.raw('PONG ' + parts[1]) # Respond to the server's PING request with a PONG to prevent ping timeout
|
||
elif parts[1] == '001': # RPL_WELCOME
|
||
await self.raw(f'MODE {self.nickname} +B')
|
||
await asyncio.sleep(3)
|
||
if config.key:
|
||
await self.raw(f'JOIN {config.channel} {config.key}')
|
||
else:
|
||
await self.raw(f'JOIN {config.channel}')
|
||
# Always join #relay for relay output
|
||
await self.raw('JOIN #relay')
|
||
elif parts[1] == '433': # ERR_NICKNAMEINUSE
|
||
self.nickname += '_' # If the nickname is already in use, append an underscore to the end of it
|
||
await self.raw('NICK ' + self.nickname) # Send the new nickname to the server
|
||
elif parts[1] == 'INVITE':
|
||
target = parts[2]
|
||
chan = parts[3][1:]
|
||
if target == self.nickname: # If we were invited to a channel, join it
|
||
await self.raw(f'JOIN {chan}')
|
||
elif parts[1] == 'KICK':
|
||
chan = parts[2]
|
||
kicked = parts[3]
|
||
if kicked == self.nickname: # If we were kicked from the channel, rejoin it after 3 seconds
|
||
await asyncio.sleep(3)
|
||
await self.raw(f'JOIN {chan}')
|
||
elif parts[1] == 'PRIVMSG':
|
||
await self.eventPRIVMSG(data) # We put this in a separate function since it will likely be the most used/handled event
|
||
except (UnicodeDecodeError, UnicodeEncodeError):
|
||
pass # Some IRCds allow invalid UTF-8 characters, this is a very important exception to catch
|
||
except Exception as ex:
|
||
logging.exception(f'Unknown error has occured! ({ex})')
|
||
|
||
|
||
|
||
if __name__ == '__main__':
|
||
print(f'Connecting to {config.server}:{config.port} (SSL: {config.use_ssl}) and joining {config.channel}')
|
||
|
||
apv.setup_logging(level='INFO', show_details=True)
|
||
|
||
bot = Bot()
|
||
|
||
asyncio.run(bot.connect())
|