forked from pwgen2155/dawdle
590 lines
21 KiB
Python
590 lines
21 KiB
Python
import asyncio
|
|
import collections
|
|
import logging
|
|
import os
|
|
import re
|
|
import textwrap
|
|
import time
|
|
|
|
from dawdle import abstract
|
|
from dawdle import chunk
|
|
from dawdle import conf
|
|
from dawdle.log import log
|
|
from typing import Dict, Iterable, List, Optional, Set, TypeVar
|
|
|
|
|
|
T = TypeVar("T")
|
|
|
|
class IRCClient(abstract.AbstractClient):
|
|
"""IRCClient acts as a layer between the IRC protocol and the bot protocol.
|
|
|
|
This class has the following responsibilities:
|
|
- Connection and disconnection
|
|
- Nick recovery
|
|
- Output throttling
|
|
- NickServ authentication
|
|
- IRC message decoding and parsing
|
|
- Tracking users in the channel
|
|
- Calling methods on the bot interface
|
|
"""
|
|
MESSAGE_RE = re.compile(r'^(?:@(\S*) )?(?::([^ !]*)(?:!([^ @]*)(?:@([^ ]*))?)?\s+)?(\S+)\s*((?:[^:]\S*(?:\s+|$))*)(?::(.*))?')
|
|
|
|
Message = collections.namedtuple('Message', ['tags', 'src', 'user', 'host', 'cmd', 'args', 'trailing', 'line', 'time'])
|
|
|
|
|
|
@staticmethod
|
|
def parse_message(line: str) -> Optional[Message]:
|
|
"""Parse IRC line into a Message."""
|
|
# Parse IRC message with a regular expression
|
|
match = IRCClient.MESSAGE_RE.match(line)
|
|
if not match:
|
|
return None
|
|
rawtags, src, user, host, cmd, argstr, trailing = match.groups()
|
|
# IRCv3 supports tags
|
|
tags: Dict[str, Optional[str]] = {}
|
|
if rawtags is not None and rawtags != "":
|
|
for pairstr in rawtags.split(';'):
|
|
pair = pairstr.split('=')
|
|
if len(pair) == 2:
|
|
tags[pair[0]] = re.sub(r"\\(.)",
|
|
lambda m: {":": ";", "s": " ", "r": "\r", "n": "\n"}.get(m[1], m[1]),
|
|
pair[1])
|
|
else:
|
|
tags[pair[0]] = None
|
|
# Arguments before the trailing argument (after the colon) are space-delimited
|
|
args = [] if argstr == "" else argstr.rstrip().split(' ')
|
|
# There's nothing special about the trailing argument except it can have spaces.
|
|
if trailing is not None:
|
|
args.append(trailing)
|
|
# Numeric responses specify a useless target afterwards
|
|
if re.match(r'\d+', cmd):
|
|
args = args[1:]
|
|
# Support time tag, which allows servers and bouncers to send history
|
|
if 'time' in tags and tags["time"] is not None:
|
|
msgtime = time.mktime(time.strptime(tags['time'], "%Y-%m-%dT%H:%M:%S"))
|
|
else:
|
|
msgtime = time.time()
|
|
return IRCClient.Message(tags, src, user, host, cmd, args, trailing, line, msgtime)
|
|
|
|
|
|
class User(abstract.AbstractClient.User):
|
|
"""An IRC user in the channel."""
|
|
|
|
def __init__(self, nick: str, userhost: str, modes: List[str], joined: float):
|
|
self.nick = nick
|
|
self.userhost = userhost
|
|
self.modes = set(modes)
|
|
self.joined = joined
|
|
|
|
|
|
_bot: abstract.AbstractBot
|
|
_writer: Optional[asyncio.StreamWriter]
|
|
_nick: str
|
|
_bytes_sent: int
|
|
_bytes_received: int
|
|
_caps: Set[str]
|
|
_server: Optional[str]
|
|
_messages_sent: int
|
|
_writeq: List[bytes]
|
|
_flushq_task: Optional[asyncio.Task] # type: ignore
|
|
_prefixmodes: Dict[str, str]
|
|
_maxmodes: int
|
|
_modetypes: Dict[str, int]
|
|
_users: Dict[str, abstract.AbstractClient.User]
|
|
quitting: bool
|
|
|
|
def __init__(self, bot: abstract.AbstractBot):
|
|
self._bot = bot
|
|
self._writer = None
|
|
self._nick = conf.get("botnick")
|
|
self._bytes_sent = 0
|
|
self._bytes_received = 0
|
|
self._caps = set()
|
|
self._server = None
|
|
self._messages_sent = 0
|
|
self._writeq = []
|
|
self._flushq_task = None
|
|
self._prefixmodes = {}
|
|
self._maxmodes = 3
|
|
self._modetypes = {}
|
|
self._users = {}
|
|
self.quitting = False
|
|
|
|
|
|
async def connect(self, addr: str, port: str) -> None:
|
|
"""Connect to IRC network and handle messages."""
|
|
reader, self._writer = await asyncio.open_connection(addr, port, ssl=True, local_addr=conf.get("localaddr"))
|
|
self._server = addr
|
|
self._connected = True
|
|
self._messages_sent = 0
|
|
self._writeq = []
|
|
self._flushq_task = None
|
|
self._prefixmodes = {}
|
|
self._maxmodes = 3
|
|
self._modetypes = {}
|
|
self._users = {}
|
|
self._caps = set() # all enabled capabilities
|
|
self.sendnow("CAP REQ :multi-prefix userhost-in-names")
|
|
self.sendnow("CAP END")
|
|
if 'BOTPASS' in os.environ:
|
|
self.sendnow(f"PASS {os.environ['BOTPASS']}")
|
|
self.sendnow(f"NICK {conf.get('botnick')}")
|
|
self.sendnow(f"USER {conf.get('botuser')} 0 * :{conf.get('botrlnm')}")
|
|
self._bot.connected(self)
|
|
while True:
|
|
linebytes = await reader.readline()
|
|
if not linebytes:
|
|
if self._flushq_task:
|
|
self._flushq_task.cancel()
|
|
self._bot.disconnected()
|
|
self._writer.close()
|
|
await self._writer.wait_closed()
|
|
self._writer = None
|
|
break
|
|
self._bytes_received += len(linebytes)
|
|
# Assume utf-8 encoding, fall back to latin-1, which has no invalid encodings from bytes.
|
|
try:
|
|
line = str(linebytes, encoding='utf8')
|
|
except UnicodeDecodeError:
|
|
line = str(linebytes, encoding='latin-1')
|
|
line = line.rstrip('\r\n')
|
|
loglevel = 5 if re.match(r"^PING ", line) else logging.DEBUG
|
|
log.log(loglevel, "<- %s", line)
|
|
msg = IRCClient.parse_message(line)
|
|
if msg:
|
|
self.dispatch(msg)
|
|
|
|
|
|
def send(self, s: str, loglevel: int=logging.DEBUG) -> None:
|
|
"""Send throttled messages."""
|
|
assert self._writer is not None
|
|
b = bytes(s+"\r\n", encoding='utf8')
|
|
|
|
if not conf.get("throttle"):
|
|
log.log(loglevel, "-> %s", s)
|
|
self._writer.write(b)
|
|
self._bytes_sent += len(b)
|
|
return
|
|
|
|
if self._messages_sent < conf.get("throttle_rate"):
|
|
log.log(loglevel, "(%d)-> %s", self._messages_sent, s)
|
|
self._writer.write(b)
|
|
self._messages_sent += 1
|
|
self._bytes_sent += len(b)
|
|
else:
|
|
self._writeq.append(b)
|
|
|
|
# The flushq task will reset messages_sent after the throttle period.
|
|
if not self._flushq_task:
|
|
self._flushq_task = asyncio.create_task(self.flushq_task())
|
|
|
|
|
|
def sendnow(self, s: str, loglevel:int=logging.DEBUG) -> None:
|
|
"""Send messages ignoring throttle."""
|
|
assert self._writer is not None
|
|
log.log(loglevel, "=> %s", s)
|
|
b = bytes(s+"\r\n", encoding='utf8')
|
|
self._writer.write(b)
|
|
self._messages_sent += 1
|
|
self._bytes_sent += len(b)
|
|
if conf.get("throttle") and not self._flushq_task:
|
|
self._flushq_task = asyncio.create_task(self.flushq_task())
|
|
|
|
|
|
async def flushq_task(self) -> None:
|
|
"""Flush send queue and release throttle."""
|
|
assert self._writer is not None
|
|
await asyncio.sleep(conf.get("throttle_period"))
|
|
self._messages_sent = max(0, self._messages_sent - conf.get("throttle_rate"))
|
|
while self._writeq:
|
|
while self._writeq and self._messages_sent < conf.get("throttle_rate"):
|
|
log.debug("(%d)~> %s", self._messages_sent, str(self._writeq[0], encoding='utf8').rstrip())
|
|
self._writer.write(self._writeq[0])
|
|
self._messages_sent += 1
|
|
self._bytes_sent += len(self._writeq[0])
|
|
self._writeq = self._writeq[1:]
|
|
if self._writeq:
|
|
await asyncio.sleep(conf.get("throttle_period"))
|
|
self._messages_sent = max(0, self._messages_sent - conf.get("throttle_rate"))
|
|
|
|
self._flushq_task = None
|
|
|
|
|
|
def servername(self) -> str:
|
|
if self._server:
|
|
return self._server
|
|
return "<disconnected>"
|
|
|
|
|
|
def bytes_sent(self) -> int:
|
|
return self._bytes_sent
|
|
|
|
|
|
def bytes_received(self) -> int:
|
|
return self._bytes_received
|
|
|
|
|
|
def writeq_len(self) -> int:
|
|
"""Returns number of messages in the write queue."""
|
|
return sum([len(b) for b in self._writeq])
|
|
|
|
|
|
def writeq_bytes(self) -> int:
|
|
"""Returns number of bytes in the write queue."""
|
|
return sum([len(b) for b in self._writeq])
|
|
|
|
|
|
def clear_writeq(self) -> None:
|
|
self._writeq.clear()
|
|
|
|
|
|
def dispatch(self, msg: Message) -> None:
|
|
"""Dispatch the IRC command to a handler method."""
|
|
if hasattr(self, "handle_"+msg.cmd.lower()):
|
|
getattr(self, "handle_"+msg.cmd.lower())(msg)
|
|
|
|
|
|
def handle_ping(self, msg: Message) -> None:
|
|
"""PING - sends PONG back to server for keepalive."""
|
|
self.sendnow(f"PONG :{msg.trailing}", loglevel=5)
|
|
|
|
|
|
def handle_005(self, msg: Message) -> None:
|
|
"""RPL_ISUPPORT - server features and information"""
|
|
self._server = msg.src
|
|
params = dict([arg.split('=') if '=' in arg else (arg, arg) for arg in msg.args])
|
|
if 'MODES' in params:
|
|
self._maxmodes = int(params['MODES'])
|
|
if 'PREFIX' in params:
|
|
m = re.match(r'\(([^)]*)\)(.*)', params['PREFIX'])
|
|
if m:
|
|
self._prefixmodes.update(zip(m[2], m[1]))
|
|
for mode in m[1]:
|
|
self._modetypes[mode] = 2
|
|
if 'CHANMODES' in params:
|
|
m = re.match(r'([^,]*),([^,]*),([^,]*),(.*)', params['CHANMODES'])
|
|
if m:
|
|
for mode in m[1]:
|
|
self._modetypes[mode] = 1 # adds to a list and always has a parameter
|
|
for mode in m[2]:
|
|
self._modetypes[mode] = 2 # changes a setting and always has param
|
|
for mode in m[3]:
|
|
self._modetypes[mode] = 3 # only has a parameter when set
|
|
for mode in m[4]:
|
|
self._modetypes[mode] = 4 # never has a parameter
|
|
|
|
|
|
def handle_376(self, msg: Message) -> None:
|
|
"""RPL_ENDOFMOTD - server is ready"""
|
|
self.mode(conf.get("botnick"), conf.get("botmodes"))
|
|
if conf.has("botident"):
|
|
self.send(conf.get("botident"))
|
|
if conf.has("botlogin"):
|
|
self.sendnow(conf.get("botlogin"))
|
|
self.join(conf.get("botchan"))
|
|
|
|
|
|
def handle_422(self, msg: Message) -> None:
|
|
"""ERR_NOTMOTD - server is ready, but without a MOTD"""
|
|
self.mode(conf.get("botnick"), conf.get("botmodes"))
|
|
if conf.has("botident"):
|
|
self.sendnow(f"{conf.get('botident')}")
|
|
if conf.has("botlogin"):
|
|
self.sendnow(conf.get("botlogin"))
|
|
self.join(conf.get("botchan"))
|
|
|
|
|
|
def handle_352(self, msg: Message) -> None:
|
|
"""RPL_WHOREPLY - Response to WHO command"""
|
|
self.add_user(msg.args[4],
|
|
f"{msg.src}!{msg.args[1]}@{msg.args[2]}",
|
|
[self._prefixmodes[p] for p in msg.args[5][1:]], # Format is [GH]\S*
|
|
msg.time)
|
|
|
|
|
|
def handle_315(self, msg: Message) -> None:
|
|
"""RPL_ENDOFWHO - End of WHO command response"""
|
|
self._bot.ready()
|
|
|
|
|
|
def handle_353(self, msg: Message) -> None:
|
|
"""RPL_NAMREPLY - names in the channel"""
|
|
if 'userhost-in-names' not in self._caps:
|
|
return
|
|
prefixes=''.join(self._prefixmodes.keys())
|
|
userhost_re = re.compile(f"([{prefixes}]*)" + r"((\S+)!\S+@\S+)")
|
|
for u in msg.trailing.split(' '):
|
|
m = userhost_re.match(u)
|
|
if m:
|
|
self.add_user(m[3], m[2], [self._prefixmodes[p] for p in m[1]], msg.time)
|
|
|
|
|
|
def handle_366(self, msg: Message) -> None:
|
|
"""RPL_ENDOFNAMES - the actual end of channel joining"""
|
|
# We know who is in the channel now
|
|
if conf.has("botopcmd"):
|
|
self.sendnow(re.sub(r'%botnick%', self._nick, conf.get("botopcmd")))
|
|
if 'userhost-in-names' in self._caps:
|
|
self._bot.ready()
|
|
else:
|
|
self.send(f"WHO {conf.get('botchan')}")
|
|
|
|
|
|
def handle_433(self, msg: Message) -> None:
|
|
"""ERR_NICKNAME_IN_USE - try another nick"""
|
|
self._nick = self._nick + "0"
|
|
self.nick(self._nick)
|
|
if conf.has("botghostcmd"):
|
|
self.sendnow(conf.get("botghostcmd"))
|
|
|
|
|
|
def handle_444(self, msg: Message) -> None:
|
|
"""ERR_NOLOGIN - """
|
|
if conf.has("botident"):
|
|
self.sendnow(conf.get("botident"))
|
|
if conf.has("botlogin"):
|
|
self.sendnow(conf.get("botlogin"))
|
|
|
|
|
|
def handle_cap(self, msg: Message) -> None:
|
|
"""CAP - notification of capability"""
|
|
# We only care about enabled capabilities.
|
|
if msg.args[1] == "ACK":
|
|
self._caps.update(msg.args[2].split(' '))
|
|
|
|
|
|
def handle_join(self, msg: Message) -> None:
|
|
"""JOIN - bot or user joined the channel."""
|
|
self.add_user(msg.src, f"{msg.src}!{msg.user}@{msg.host}", [], msg.time)
|
|
|
|
|
|
def handle_part(self, msg: Message) -> None:
|
|
"""PART - bot or user left the channel."""
|
|
user = self.remove_user(msg.src)
|
|
self._bot.nick_parted(user)
|
|
|
|
|
|
def handle_kick(self, msg: Message) -> None:
|
|
"""KICK - user was kicked from the channel."""
|
|
user = self.remove_user(msg.args[1])
|
|
self._bot.nick_kicked(user)
|
|
|
|
|
|
def handle_mode(self, msg: Message) -> None:
|
|
"""MODE - bot or channel changed its mode."""
|
|
# ignore mode changes to everything except the bot channel
|
|
if msg.args[0] != conf.get("botchan"):
|
|
return
|
|
changes = []
|
|
params = []
|
|
for arg in msg.args[1:]:
|
|
m = re.match(r'([-+])(.*)', arg)
|
|
if m:
|
|
changes.extend([(m[1], term) for term in m[2]])
|
|
else:
|
|
params.append(arg)
|
|
for change in changes:
|
|
# all this modetype machinery is required to accurately parse modelines
|
|
modetype = self._modetypes[change[1]]
|
|
if modetype == 1 or modetype == 2 or (modetype == 3 and change[0] == '+'):
|
|
param = params.pop()
|
|
if modetype != 2:
|
|
continue
|
|
if change[0] == '+':
|
|
self._users[param].modes.add(change[1])
|
|
if param == self._nick and change[1] == 'o':
|
|
# Acquiring op is special to the bot
|
|
self._bot.acquired_ops()
|
|
else:
|
|
self._users[param].modes.discard(change[1])
|
|
|
|
|
|
def handle_nick(self, msg: Message) -> None:
|
|
"""NICK - bot or user had its nick changed."""
|
|
|
|
# Do this first so that the user still matches the player.
|
|
self._bot.nick_changed(self._users[msg.src], msg.args[0])
|
|
|
|
self._users[msg.args[0]] = self._users[msg.src]
|
|
self._users[msg.args[0]].nick = msg.args[0]
|
|
del self._users[msg.src]
|
|
|
|
if msg.src == self._nick:
|
|
# Update my nick
|
|
self._nick = msg.args[0]
|
|
return
|
|
|
|
if msg.src == conf.get("botnick"):
|
|
# Grab my nick that someone left
|
|
self.nick(conf.get("botnick"))
|
|
|
|
|
|
def handle_quit(self, msg: Message) -> None:
|
|
"""QUIT - bot or user was disconnected."""
|
|
if msg.src == conf.get("botnick"):
|
|
# Grab my nick that someone left
|
|
self.nick(conf.get("botnick"))
|
|
user = self.remove_user(msg.src)
|
|
if conf.get("detectsplits") and re.match(r'\S+\.\S+ \S+\.\S+', msg.trailing):
|
|
# Don't penalize on netsplit
|
|
self._bot.netsplit(user)
|
|
elif re.match(r"Read error|Ping timeout", msg.trailing):
|
|
self._bot.nick_dropped(user)
|
|
else:
|
|
self._bot.nick_quit(user)
|
|
|
|
|
|
def handle_notice(self, msg: Message) -> None:
|
|
"""NOTICE - Message sent, used to prevent loops in bots."""
|
|
if msg.args[0] != self._nick and msg.src in self._users and self.user_is_ok(msg):
|
|
# we ignore private notices
|
|
self._bot.channel_notice(self._users[msg.src], msg.trailing)
|
|
|
|
|
|
def handle_privmsg(self, msg: Message) -> None:
|
|
"""PRIVMSG - Message sent."""
|
|
if msg.src not in self._users:
|
|
# Server messages
|
|
return
|
|
if msg.args[0] == self._nick:
|
|
self._bot.private_message(self._users[msg.src], msg.trailing)
|
|
elif self.user_is_ok(msg):
|
|
self._bot.channel_message(self._users[msg.src], msg.trailing)
|
|
|
|
|
|
def add_user(self, nick: str, userhost: str, modes: List[str], joined: float) -> None:
|
|
"""Adds channel user with the given properties."""
|
|
self._users[nick] = IRCClient.User(nick, userhost, modes, joined)
|
|
|
|
|
|
def remove_user(self, nick: str) -> "abstract.AbstractClient.User":
|
|
"""Remove user with the given nick. Returns that user."""
|
|
user = self._users[nick]
|
|
del self._users[nick]
|
|
if len(self._users) == 1 and not self.bot_has_ops():
|
|
# Try to acquire ops by leaving and joining
|
|
self.sendnow(f"PART {conf.get('botchan')} :Acquiring ops")
|
|
self.sendnow(f"JOIN {conf.get('botchan')}")
|
|
return user
|
|
|
|
|
|
def user_exists(self, nick: str) -> bool:
|
|
return nick in self._users
|
|
|
|
|
|
def user_is_ok(self, msg: Message) -> bool:
|
|
"""Check to see if msg should cause user to be kickbanned."""
|
|
if not conf.get("doban"):
|
|
# Bot doesn't do bans
|
|
return True
|
|
if not self.bot_has_ops():
|
|
# Bot can't do bans
|
|
return True
|
|
if msg.src == self._nick:
|
|
# Bot is always ok
|
|
return True
|
|
if msg.src not in self._users:
|
|
# Not in channel - maybe channel could use mode +n
|
|
return False
|
|
if msg.time > self._users[msg.src].joined + conf.get("bannable_time"):
|
|
# Been in channel for a while, prob ok?
|
|
return True
|
|
|
|
for host in re.findall(r"https?://([^/]+)/", msg.trailing):
|
|
if host not in conf.get("okurls"):
|
|
# User not okay
|
|
self.kickban(msg.src)
|
|
return False
|
|
return True
|
|
|
|
|
|
def match_user(self, nick: str, userhost: str) -> bool:
|
|
"""Return True if the nick and userhost match an existing user."""
|
|
return nick in self._users and userhost == self._users[nick].userhost
|
|
|
|
|
|
def is_bot_nick(self, nick: str) -> bool:
|
|
return nick == self._nick or nick == conf.get("botnick")
|
|
|
|
|
|
def bot_has_ops(self) -> bool:
|
|
"""Return True if the bot has ops in the channel."""
|
|
return self._nick in self._users and 'o' in self._users[self._nick].modes
|
|
|
|
|
|
def nick_userhost(self, nick: str) -> Optional[str]:
|
|
if nick not in self._users:
|
|
return None
|
|
return self._users[nick].userhost
|
|
|
|
|
|
def kickban(self, nick: str) -> None:
|
|
"""Kick a nick from the channel and ban them."""
|
|
self.sendnow(f"MODE {conf.get('botchan')} +b {nick}")
|
|
self.sendnow(f"KICK {conf.get('botchan')} {nick} :No advertising")
|
|
|
|
|
|
def nick(self, nick: str) -> None:
|
|
"""Send nick change request."""
|
|
self.sendnow(f"NICK {nick}")
|
|
|
|
|
|
def join(self, channel: str) -> None:
|
|
"""Send channel join request."""
|
|
time.sleep(5)
|
|
self.send(f"JOIN {channel}")
|
|
|
|
|
|
def grant_voice(self, *targets: str) -> None:
|
|
for subset in chunk.chunk(targets, self._maxmodes):
|
|
self.send(f"MODE {conf.get('botchan')} +{'v' * len(subset)} {' '.join(subset)}")
|
|
|
|
|
|
def revoke_voice(self, *targets: str) -> None:
|
|
for subset in chunk.chunk(targets, self._maxmodes):
|
|
self.send(f"MODE {conf.get('botchan')} -{'v' * len(subset)} {' '.join(subset)}")
|
|
|
|
|
|
def set_channel_voices(self, voiced_nicks: Iterable[str]) -> None:
|
|
add_voice = []
|
|
remove_voice = []
|
|
for u in self._users.keys():
|
|
if 'v' in self._users[u].modes:
|
|
if u not in voiced_nicks:
|
|
remove_voice.append(u)
|
|
else:
|
|
if u in voiced_nicks:
|
|
add_voice.append(u)
|
|
if add_voice:
|
|
self.grant_voice(*add_voice)
|
|
if remove_voice:
|
|
self.revoke_voice(*remove_voice)
|
|
|
|
|
|
|
|
def mode(self, target: str, *modeinfo: str) -> None:
|
|
"""Send mode change request."""
|
|
for modes in chunk.chunk(modeinfo, self._maxmodes):
|
|
self.send(f"MODE {target} {' '.join(modes)}")
|
|
|
|
|
|
def notice(self, target: str, text: str) -> None:
|
|
"""Send notice text to target."""
|
|
for line in textwrap.wrap(text, width=conf.get("message_wrap_len")):
|
|
self.send(f"NOTICE {target} :{line}")
|
|
|
|
|
|
def chanmsg(self, text: str) -> None:
|
|
"""Send message text to bot channel."""
|
|
for line in textwrap.wrap(text, width=conf.get("message_wrap_len")):
|
|
self.send(f"PRIVMSG {conf.get('botchan')} :{line}")
|
|
|
|
|
|
def quit(self, text: str) -> None:
|
|
"""Send quit request to server."""
|
|
self.quitting = True
|
|
if text:
|
|
self.sendnow(f"QUIT :{text}")
|
|
else:
|
|
self.sendnow("QUIT")
|