250 lines
9.6 KiB
Python
250 lines
9.6 KiB
Python
# coding=utf-8
|
|
|
|
# This file provides methods for connecting to and sending message
|
|
# to an IRC server. Additionally it keeps track of every vital
|
|
# runtime variable the bot needs for its connection to the IRC server
|
|
# and the management of its features.
|
|
|
|
'''
|
|
Copyright (C) 2017-2019 drastik.org
|
|
|
|
This file is part of drastikbot.
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as published
|
|
by the Free Software Foundation, version 3 only.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
'''
|
|
|
|
import socket
|
|
import ssl
|
|
import time
|
|
import traceback
|
|
|
|
import dbot_tools
|
|
|
|
|
|
class Settings:
|
|
def __init__(self, conf_dir):
|
|
self.cd = conf_dir
|
|
self.proj_path = '' # Project root.
|
|
self.log = None # Runtime Logging
|
|
self.version = "2.1 (alpha)"
|
|
self.reconnect_delay = 0
|
|
self.sigint = 0
|
|
# Message length used by irc.send
|
|
# (TODO: The bot should dynamically change it based on the bot's
|
|
# nickname and hostmask length)
|
|
self.msg_len = 400
|
|
|
|
# Runtime Variables
|
|
self.curr_nickname = '' # Nickname currently used
|
|
self.bot_hostmask = '' # Hostmask issued by the server
|
|
self.alt_nickname = False # Alternative nickname used
|
|
self.connected_ip = '' # IP of the connected IRC server
|
|
self.connected_host = '' # Hostname of the connected server
|
|
self.ircv3_ver = '301' # IRCv3 version supported by the bot
|
|
self.ircv3_cap_req = ('sasl') # IRCv3 Bot Capability Requirements
|
|
self.ircv3_serv = False # True: IRCv3 supported by the server
|
|
self.ircv3_cap_ls = [] # IRCv3 Server Capabilities
|
|
self.ircv3_cap_ack = [] # IRCv3 Server Capabilities Acknowledged
|
|
# sasl_state = 0: Not tried | 1: Success | 2: Fail | 3: In progress
|
|
self.sasl_state = 0
|
|
self.conn_state = 0 # 0: Disconnected | 1: Registering | 2: Connected
|
|
self.namesdict = {} # {channel1: [["=","S"], {nick1: ["@"], , ...}]}
|
|
self.botmodes = [] # [x,I] Modes returned after registration
|
|
|
|
def config_load(self):
|
|
# (Re)loads the configuration file and sets the bot's variables.
|
|
c = dbot_tools.Config(self.cd).read()
|
|
c_conn = c['irc']['connection']
|
|
self.owners = c['irc']['owners']
|
|
self.host = c_conn['network']
|
|
self.port = c_conn['port']
|
|
self.ssl = c_conn.get('ssl', False)
|
|
self.nickname = c_conn['nickname']
|
|
self.username = c_conn['username']
|
|
self.realname = c_conn['realname']
|
|
self.authentication = c_conn.get('authentication', '')
|
|
self.auth_password = c_conn['auth_password']
|
|
self.net_password = c_conn.get('net_password', '')
|
|
self.quitmsg = c_conn.get('quitmsg', f'drastikbot {self.version}')
|
|
self.msg_delay = c_conn.get('msg_delay', 1)
|
|
self.channels = c['irc']['channels']
|
|
self.modules_obj = c['irc']['modules']
|
|
self.modules_load = self.modules_obj['load']
|
|
self.mod_glb_prefix = self.modules_obj['global_prefix']
|
|
self.mod_chn_prefix = {} # {#channel: cmd_prefix}
|
|
# User Access List
|
|
try:
|
|
self.user_acl = tuple(c['irc']['user_acl'])
|
|
except KeyError:
|
|
self.user_acl = ()
|
|
# Channel Prefixes
|
|
for chan in self.channels:
|
|
try:
|
|
mpref = c['irc']['modules']['channel_prefix'][chan]
|
|
except KeyError:
|
|
mpref = self.mod_glb_prefix
|
|
self.mod_chn_prefix[chan] = mpref
|
|
|
|
|
|
class Drastikbot():
|
|
def __init__(self, conf_dir):
|
|
self.cd = conf_dir
|
|
self.log = dbot_tools.Logger(self.cd, 'runtime.log')
|
|
self.var = Settings(self.cd)
|
|
|
|
def set_msg_len(self, nick_ls):
|
|
u = f"{nick_ls[0]}!{nick_ls[1]}@{nick_ls[2]} "
|
|
c = len(u.encode('utf-8'))
|
|
self.var.msg_len = 512 - c
|
|
|
|
def send(self, cmds, text=None):
|
|
m_len = self.var.msg_len
|
|
cmds = [dbot_tools.text_fix(cmd) for cmd in cmds]
|
|
if text:
|
|
text = dbot_tools.text_fix(text)
|
|
# https://tools.ietf.org/html/rfc2812.html#section-2.3
|
|
# NOTE: 2) IRC messages are limited to 512 characters in length.
|
|
# With CR-LF we are left with 510 characters to use
|
|
tosend = f"{' '.join(cmds)} :{text}"
|
|
else:
|
|
tosend = ' '.join(cmds) # for commands
|
|
try:
|
|
tosend = tosend.encode('utf-8')
|
|
multipart = False
|
|
remainder = 0
|
|
if len(tosend) + 2 > m_len:
|
|
# Handle messages that are too long to fit in one message.
|
|
# Truncate messages at the last space found to avoid breaking
|
|
# utf-8.
|
|
tosend = tosend[:m_len].rsplit(b' ', 1)
|
|
remainder = len(tosend[1])
|
|
multipart = True
|
|
tosend = tosend[0]
|
|
|
|
tosend = tosend + b'\r\n'
|
|
self.irc_socket.send(tosend)
|
|
except Exception:
|
|
self.log.debug(f'Exception on send() @ irc.py:'
|
|
f'\n{traceback.format_exc()}')
|
|
return self.irc_socket.close()
|
|
# If the input text is longer than 510 send the rest.
|
|
# "limit" decrements after every message and is used to control
|
|
# the amount of messages the bot is allowed to send. The value
|
|
# is set to -1 by default, which means it can send an infinite
|
|
# amount of messages, since zero will never be met in the if
|
|
# statement below.
|
|
if multipart:
|
|
time.sleep(self.var.msg_delay)
|
|
tr = m_len - 2 - len(' '.join(cmds).encode('utf-8')) - remainder
|
|
t = text.encode('utf-8')[tr:]
|
|
self.send(cmds, t)
|
|
|
|
def privmsg(self, target, msg):
|
|
self.send(('PRIVMSG', target), msg)
|
|
|
|
def notice(self, target, msg):
|
|
self.send(('NOTICE', target), msg)
|
|
|
|
def join(self, channels):
|
|
print("\n")
|
|
for key, value in channels.items():
|
|
self.send(('JOIN', key, value))
|
|
self.log.info(f"Joined {key}")
|
|
|
|
def part(self, channel, msg):
|
|
self.send(('PART', channel), msg)
|
|
|
|
def invite(self, nick, channel): # untested
|
|
self.send(('INVITE', nick, channel))
|
|
|
|
def kick(self, channel, nick, msg): # untested
|
|
self.send(('KICK', channel, nick, msg))
|
|
|
|
def nick(self, nick):
|
|
self.send(('NICK', '{}'.format(nick)))
|
|
|
|
def quit(self, msg=''):
|
|
if not msg:
|
|
msg = self.var.quitmsg
|
|
self.send(('QUIT',), msg)
|
|
|
|
def away(self, msg=''):
|
|
self.send('AWAY', msg)
|
|
|
|
def reconn_wait(self):
|
|
'''
|
|
Incrementally Wait before reconnection to the server. The initial delay
|
|
is zero, then it is set to ten seconds and keeps doubling after each
|
|
attempt until it reaches ten minutes.
|
|
'''
|
|
time.sleep(self.var.reconnect_delay)
|
|
if self.var.reconnect_delay == 0:
|
|
self.var.reconnect_delay = 10 # 10 sec.
|
|
elif self.var.reconnect_delay < 60 * 10: # 10 mins.
|
|
self.var.reconnect_delay *= 2
|
|
# Because the previous statement will end up with 640 seconds,
|
|
# we set it to 600 seconds and keep it there until we connect:
|
|
if self.var.reconnect_delay > 60 * 10: # 10 mins.
|
|
self.var.reconnect_delay = 60 * 10
|
|
|
|
def connect(self):
|
|
self.var.config_load()
|
|
try:
|
|
# Timeout on socket.create_connection should be above the irc
|
|
# server's ping timeout setting
|
|
self.irc_socket = socket.create_connection(
|
|
(self.var.host, self.var.port), 300)
|
|
if self.var.ssl:
|
|
self.irc_socket = ssl.wrap_socket(self.irc_socket)
|
|
except OSError:
|
|
if self.var.sigint:
|
|
return
|
|
self.log.debug('Exception on connect() @ irc_sock.connect()'
|
|
f'\n{traceback.format_exc()}')
|
|
self.log.info(' - No route to host. Retrying in {} seconds'.format(
|
|
self.var.reconnect_delay))
|
|
try:
|
|
self.irc_socket.close()
|
|
except Exception:
|
|
pass
|
|
self.reconn_wait() # Wait before next reconnection attempt.
|
|
return self.connect()
|
|
except IOError:
|
|
try:
|
|
self.irc_socket.close()
|
|
except Exception:
|
|
pass
|
|
self.log.debug(f'Exception on connect() @ irc_sock.connect():'
|
|
f'\n{traceback.format_exc()}')
|
|
return self.connect()
|
|
|
|
# SOCKET OPTIONS
|
|
# self.irc_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
|
|
if self.var.net_password:
|
|
# Authenticate if the server is password protected
|
|
self.send(('PASS', self.var.net_password))
|
|
|
|
# Set the connected variables, to inform the bot where exactly we are
|
|
# connected
|
|
try:
|
|
# There is a possibility that an Exception is raised here.
|
|
# Because they are not vital to the bot's operation we will just
|
|
# ignore them.
|
|
# Consider adding them to dbot_tools as functions.
|
|
self.var.connected_ip = self.irc_socket.getpeername()[0]
|
|
self.var.connected_host = socket.gethostbyaddr(
|
|
self.var.connected_ip)[0]
|
|
except Exception:
|
|
pass
|