skeleton

- bot skeleton for irc
git clone git://git.acid.vegas/skeleton.git
Log | Files | Refs | Archive | README | LICENSE

skeleton.py (10051B)

      1 #!/usr/bin/env python
      2 # irc bot skeleton - developed by acidvegas in python (https://git.acid.vegas/skeleton)
      3 
      4 import argparse
      5 import asyncio
      6 import logging
      7 import logging.handlers
      8 import ssl
      9 import time
     10 
     11 # Settings
     12 cmd_flood = 3 # Delay between bot command usage in seconds (In this case, anything prefixed with a ! is a command)
     13 
     14 # Formatting Control Characters / Color Codes
     15 bold        = '\x02'
     16 italic      = '\x1D'
     17 underline   = '\x1F'
     18 reverse     = '\x16'
     19 reset       = '\x0f'
     20 white       = '00'
     21 black       = '01'
     22 blue        = '02'
     23 green       = '03'
     24 red         = '04'
     25 brown       = '05'
     26 purple      = '06'
     27 orange      = '07'
     28 yellow      = '08'
     29 light_green = '09'
     30 cyan        = '10'
     31 light_cyan  = '11'
     32 light_blue  = '12'
     33 pink        = '13'
     34 grey        = '14'
     35 light_grey  = '15'
     36 
     37 
     38 def color(msg: str, foreground: str, background: str = None) -> str:
     39 	'''
     40 	Color a string with the specified foreground and background colors.
     41 
     42 	:param msg: The string to color.
     43 	:param foreground: The foreground color to use.
     44 	:param background: The background color to use.
     45 	'''
     46 	return f'\x03{foreground},{background}{msg}{reset}' if background else f'\x03{foreground}{msg}{reset}'
     47 
     48 
     49 def ssl_ctx(verify: bool = False, cert_path: str = None, cert_pass: str = None) -> ssl.SSLContext:
     50 	'''
     51 	Create a SSL context for the connection.
     52 
     53 	:param verify: Verify the SSL certificate.
     54 	:param cert_path: The path to the SSL certificate.
     55 	:param cert_pass: The password for the SSL certificate.
     56 	'''
     57 	ctx = ssl.create_default_context() if verify else ssl._create_unverified_context()
     58 	if cert_path:
     59 		ctx.load_cert_chain(cert_path) if not cert_pass else ctx.load_cert_chain(cert_path, cert_pass)
     60 	return ctx
     61 
     62 
     63 class Bot():
     64 	def __init__(self):
     65 		self.nickname = 'skeleton'
     66 		self.username = 'skelly'
     67 		self.realname = 'Developement Bot'
     68 		self.reader   = None
     69 		self.writer   = None
     70 		self.last     = time.time()
     71 
     72 
     73 	async def action(self, chan: str, msg: str):
     74 		'''
     75 		Send an ACTION to the IRC server.
     76 
     77 		:param chan: The channel to send the ACTION to.
     78 		:param msg: The message to send to the channel.
     79 		'''
     80 		await self.sendmsg(chan, f'\x01ACTION {msg}\x01')
     81 
     82 
     83 	async def raw(self, data: str):
     84 		'''
     85 		Send raw data to the IRC server.
     86 
     87 		:param data: The raw data to send to the IRC server. (512 bytes max including crlf)
     88 		'''
     89 		self.writer.write(data[:510].encode('utf-8') + b'\r\n')
     90 
     91 
     92 	async def sendmsg(self, target: str, msg: str):
     93 		'''
     94 		Send a PRIVMSG to the IRC server.
     95 
     96 		:param target: The target to send the PRIVMSG to. (channel or user)
     97 		:param msg: The message to send to the target.
     98 		'''
     99 		await self.raw(f'PRIVMSG {target} :{msg}')
    100 
    101 
    102 	async def connect(self):
    103 		'''Connect to the IRC server.'''
    104 		while True:
    105 			try:
    106 				options = {
    107 					'host'       : args.server,
    108 					'port'       : args.port if args.port else 6697 if args.ssl else 6667,
    109 					'limit'      : 1024, # Buffer size in bytes (don't change this unless you know what you're doing)
    110 					'ssl'        : ssl_ctx() if args.ssl else None,
    111 					'family'     : 10 if args.v6 else 2, # 10 = AF_INET6 (IPv6), 2 = AF_INET (IPv4)
    112 					'local_addr' : args.vhost if args.vhost else None # Can we just leave this as args.vhost?
    113 				}
    114 				self.reader, self.writer = await asyncio.wait_for(asyncio.open_connection(**options), 15) # 15 second timeout
    115 				if args.password:
    116 					await self.raw('PASS ' + args.password) # Rarely used, but IRCds may require this
    117 				await self.raw(f'USER {self.username} 0 * :{self.realname}') # These lines must be sent upon connection
    118 				await self.raw('NICK ' + self.nickname)                      # They are to identify the bot to the server
    119 				while not self.reader.at_eof():
    120 					data = await asyncio.wait_for(self.reader.readuntil(b'\r\n'), 300) # 5 minute ping timeout
    121 					await self.handle(data.decode('utf-8').strip()) # Handle the data received from the IRC server
    122 			except Exception as ex:
    123 				logging.error(f'failed to connect to {args.server} ({str(ex)})')
    124 			finally:
    125 				await asyncio.sleep(30) # Wait 30 seconds before reconnecting
    126 
    127 
    128 	async def eventPRIVMSG(self, data: str):
    129 		'''
    130 		Handle the PRIVMSG event.
    131 
    132 		:param data: The data received from the IRC server.
    133 		'''
    134 		parts  = data.split()
    135 		ident  = parts[0][1:] # nick!user@host
    136 		nick   = parts[0].split('!')[0][1:] # Nickname of the user who sent the message
    137 		target = parts[2] # Channel or user (us) the message was sent to
    138 		msg	   = ' '.join(parts[3:])[1:]
    139 		if target == self.nickname: # Handle private messages
    140 			if ident == 'acidvegas!stillfree@big.dick.acid.vegas': # Admin only command based on ident
    141 				if msg.startswith('!raw') and len(msg.split()) > 1: # Only allow !raw if there is some data
    142 					option = ' '.join(msg.split()[1:]) # Everything after !raw is stored here
    143 					await self.raw(option) # Send raw data to the server FROM the bot
    144 			else:
    145 				await self.sendmsg(nick, 'Do NOT message me!') # Let's ignore anyone PM'ing the bot that isn't the admin
    146 		if target.startswith('#'): # Handle channel messages
    147 			if msg.startswith('!'):
    148 				if time.time() - self.last < cmd_flood: # Prevent command flooding
    149 					if not self.slow: # The self.slow variable is used so that a warning is only issued one time
    150 						self.slow = True
    151 						await self.sendmsg(target, color('Slow down nerd!', red))
    152 				else: # Once we confirm the user isn't command flooding, we can handle the commands
    153 					self.slow = False
    154 					if msg == '!help':
    155 						await self.action(target, 'explodes')
    156 					elif msg == '!ping':
    157 						await self.sendmsg(target, 'Pong!')
    158 					elif msg.startswith('!say') and len(msg.split()) > 1: # Only allow !say if there is something to say
    159 						option = ' '.join(msg.split()[1:]) # Everything after !say is stored here
    160 						await self.sendmsg(target, option)
    161 					self.last = time.time() # Update the last command time if it starts with ! character to prevent command flooding
    162 
    163 
    164 	async def handle(self, data: str):
    165 		'''
    166 		Handle the data received from the IRC server.
    167 
    168 		:param data: The data received from the IRC server.
    169 		'''
    170 		logging.info(data)
    171 		try:
    172 			parts = data.split()
    173 			if data.startswith('ERROR :Closing Link:'):
    174 				raise Exception('BANNED')
    175 			if parts[0] == 'PING':
    176 				await self.raw('PONG ' + parts[1]) # Respond to the server's PING request with a PONG to prevent ping timeout
    177 			elif parts[1] == '001': # RPL_WELCOME
    178 				await self.raw(f'MODE {self.nickname} +B') # Set user mode +B (Bot)
    179 				await self.sendmsg('NickServ', f'IDENTIFY {self.nickname} simps0nsfan420') # Identify to NickServ
    180 				await self.raw('OPER MrSysadmin fartsimps0n1337') # Oper up
    181 				await asyncio.sleep(10) # Wait 10 seconds before joining the channel (required by some IRCds to wait before JOIN)
    182 				if parts.key:
    183 					await self.raw(f'JOIN {args.channel} {args.key}') # Join the channel with the key
    184 				else:
    185 					await self.raw(f'JOIN {args.channel}')
    186 			elif parts[1] == '433': # ERR_NICKNAMEINUSE
    187 				self.nickname += '_' # If the nickname is already in use, append an underscore to the end of it
    188 				await self.raw('NICK ' + self.nickname) # Send the new nickname to the server
    189 			elif parts[1] == 'INVITE':
    190 				target = parts[2]
    191 				chan = parts[3][1:]
    192 				if target == self.nickname: # If we were invited to a channel, join it
    193 					await self.raw(f'JOIN {chan}')
    194 			elif parts[1] == 'KICK':
    195 				chan   = parts[2]
    196 				kicked = parts[3]
    197 				if kicked == self.nickname: # If we were kicked from the channel, rejoin it after 3 seconds
    198 					await asyncio.sleep(3)
    199 					await self.raw(f'JOIN {chan}')
    200 			elif parts[1] == 'PRIVMSG':
    201 				await self.eventPRIVMSG(data) # We put this in a separate function since it will likely be the most used/handled event
    202 		except (UnicodeDecodeError, UnicodeEncodeError):
    203 			pass # Some IRCds allow invalid UTF-8 characters, this is a very important exception to catch
    204 		except Exception as ex:
    205 			logging.exception(f'Unknown error has occured! ({ex})')
    206 
    207 
    208 def setup_logger(log_filename: str, to_file: bool = False):
    209 	'''
    210 	Set up logging to console & optionally to file.
    211 
    212 	:param log_filename: The filename of the log file
    213 	:param to_file: Whether or not to log to a file
    214 	'''
    215 	sh = logging.StreamHandler()
    216 	sh.setFormatter(logging.Formatter('%(asctime)s | %(levelname)9s | %(message)s', '%I:%M %p'))
    217 	if to_file:
    218 		fh = logging.handlers.RotatingFileHandler(log_filename+'.log', maxBytes=250000, backupCount=3, encoding='utf-8') # Max size of 250KB, 3 backups
    219 		fh.setFormatter(logging.Formatter('%(asctime)s | %(levelname)9s | %(filename)s.%(funcName)s.%(lineno)d | %(message)s', '%Y-%m-%d %I:%M %p')) # We can be more verbose in the log file
    220 		logging.basicConfig(level=logging.NOTSET, handlers=(sh,fh))
    221 	else:
    222 		logging.basicConfig(level=logging.NOTSET, handlers=(sh,))
    223 
    224 
    225 
    226 if __name__ == '__main__':
    227 	parser = argparse.ArgumentParser(description="Connect to an IRC server.") # The arguments without -- are required arguments.
    228 	parser.add_argument("server", help="The IRC server address.")
    229 	parser.add_argument("channel", help="The IRC channel to join.")
    230 	parser.add_argument("--password", help="The password for the IRC server.")
    231 	parser.add_argument("--port", type=int, help="The port number for the IRC server.") # Port is optional, will default to 6667/6697 depending on SSL.
    232 	parser.add_argument("--ssl", action="store_true", help="Use SSL for the connection.")
    233 	parser.add_argument("--v4", action="store_true", help="Use IPv4 for the connection.")
    234 	parser.add_argument("--v6", action="store_true", help="Use IPv6 for the connection.")
    235 	parser.add_argument("--key",  default="", help="The key (password) for the IRC channel, if required.")
    236 	parser.add_argument("--vhost", help="The VHOST to use for connection.")
    237 	args = parser.parse_args()
    238 
    239 	print(f"Connecting to {args.server}:{args.port} (SSL: {args.ssl}) and joining {args.channel} (Key: {args.key or 'None'})")
    240 
    241 	setup_logger('skeleton', to_file=True) # Optionally, you can log to a file, change to_file to False to disable this.
    242 
    243 	bot = Bot() # We define this here as an object so we can call it from an outside function if we need to.
    244 
    245 	asyncio.run(bot.connect())