From befb55fff3d33a74068c7fe30271b66bb384d77b Mon Sep 17 00:00:00 2001 From: Zuckerberg Date: Thu, 21 Apr 2022 14:35:31 -0400 Subject: [PATCH] Vendor drastikbot/drastikbot+modules v2.1 --- src/dbot_tools.py | 142 ++++++ src/drastikbot.py | 102 ++++ src/irc/irc.py | 249 +++++++++ src/irc/message.py | 109 ++++ src/irc/modules.py | 394 +++++++++++++++ src/irc/modules/COPYING | 674 +++++++++++++++++++++++++ src/irc/modules/README.md | 28 ++ src/irc/modules/admin.py | 834 +++++++++++++++++++++++++++++++ src/irc/modules/clock.py | 88 ++++ src/irc/modules/coinmarketcap.py | 158 ++++++ src/irc/modules/coronachan.py | 39 ++ src/irc/modules/dice.py | 68 +++ src/irc/modules/events.py | 181 +++++++ src/irc/modules/help.py | 203 ++++++++ src/irc/modules/ignore.py | 211 ++++++++ src/irc/modules/information.py | 43 ++ src/irc/modules/kernel.py | 298 +++++++++++ src/irc/modules/lainstream.py | 488 ++++++++++++++++++ src/irc/modules/lastfm.py | 210 ++++++++ src/irc/modules/points.py | 87 ++++ src/irc/modules/quote.py | 303 +++++++++++ src/irc/modules/search.py | 215 ++++++++ src/irc/modules/sed.py | 161 ++++++ src/irc/modules/seen.py | 136 +++++ src/irc/modules/tarot.py | 76 +++ src/irc/modules/tell.py | 98 ++++ src/irc/modules/text.py | 152 ++++++ src/irc/modules/theo.py | 160 ++++++ src/irc/modules/urbandict.py | 98 ++++ src/irc/modules/url.py | 355 +++++++++++++ src/irc/modules/user_auth.py | 64 +++ src/irc/modules/weather.py | 400 +++++++++++++++ src/irc/modules/wikipedia.py | 310 ++++++++++++ src/irc/modules/wiktionary.py | 173 +++++++ src/irc/modules/wolframalpha.py | 63 +++ src/irc/modules/yn.py | 46 ++ src/irc/modules/youtube.py | 190 +++++++ src/irc/worker.py | 293 +++++++++++ src/toolbox/config_check.py | 213 ++++++++ src/toolbox/user_acl.py | 118 +++++ 40 files changed, 8230 insertions(+) create mode 100644 src/dbot_tools.py create mode 100755 src/drastikbot.py create mode 100644 src/irc/irc.py create mode 100644 src/irc/message.py create mode 100644 src/irc/modules.py create mode 100644 src/irc/modules/COPYING create mode 100644 src/irc/modules/README.md create mode 100644 src/irc/modules/admin.py create mode 100644 src/irc/modules/clock.py create mode 100644 src/irc/modules/coinmarketcap.py create mode 100644 src/irc/modules/coronachan.py create mode 100644 src/irc/modules/dice.py create mode 100644 src/irc/modules/events.py create mode 100644 src/irc/modules/help.py create mode 100644 src/irc/modules/ignore.py create mode 100644 src/irc/modules/information.py create mode 100644 src/irc/modules/kernel.py create mode 100644 src/irc/modules/lainstream.py create mode 100644 src/irc/modules/lastfm.py create mode 100644 src/irc/modules/points.py create mode 100644 src/irc/modules/quote.py create mode 100644 src/irc/modules/search.py create mode 100644 src/irc/modules/sed.py create mode 100644 src/irc/modules/seen.py create mode 100644 src/irc/modules/tarot.py create mode 100644 src/irc/modules/tell.py create mode 100644 src/irc/modules/text.py create mode 100644 src/irc/modules/theo.py create mode 100644 src/irc/modules/urbandict.py create mode 100644 src/irc/modules/url.py create mode 100644 src/irc/modules/user_auth.py create mode 100644 src/irc/modules/weather.py create mode 100644 src/irc/modules/wikipedia.py create mode 100644 src/irc/modules/wiktionary.py create mode 100644 src/irc/modules/wolframalpha.py create mode 100644 src/irc/modules/yn.py create mode 100644 src/irc/modules/youtube.py create mode 100644 src/irc/worker.py create mode 100644 src/toolbox/config_check.py create mode 100644 src/toolbox/user_acl.py diff --git a/src/dbot_tools.py b/src/dbot_tools.py new file mode 100644 index 0000000..48157d4 --- /dev/null +++ b/src/dbot_tools.py @@ -0,0 +1,142 @@ +# coding=utf-8 + +# Common tools used by the bot and it's modules. +# Tools: - text_fix: decode message and remove whitespace +# - Config : config file reader and writer +# - Logger : Logger functions + +''' +Copyright (C) 2018-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 . +''' + +import datetime +import json +import sys +from pathlib import Path + + +def text_fix(line): + if not isinstance(line, str): + line = line.decode('utf8', errors='ignore') + + # Remove "\r\n" and all whitespace. + # line = line.replace("\\r", "").replace("\\n", "") + # line = line.replace("\r", "").replace("\n", "") + line = ''.join(line.splitlines()) + return line + + +def p_truncate(text, whole, percent, ellipsis=False): + if not isinstance(text, str): + raise TypeError("'text' must be str, not bytes") + return + t = text.encode('utf-8') + lim = int((whole * percent) / 100) + if not len(t) > lim: + return t.decode('utf8', errors='ignore') + e = b'...' + if ellipsis: + t = t[:lim + len(e)].rsplit(b' ', 1)[0] + e + else: + t = t[:lim].rsplit(b' ', 1)[0] + return t.decode('utf8', errors='ignore') + + +class Config: + """ + The Config class provides easy reading and writing to the + bot's configuration file. + conf_dir : should be the configuration directory. + """ + def __init__(self, conf_dir): + self.config_file = '{}/config.json'.format(conf_dir) + + def read(self): + with open(self.config_file, 'r') as f: + return json.load(f) + + def write(self, value): + """ + value : must be the whole configuration and not just a + setting, because the config file's contents are being + replaced with 'value' + """ + with open(self.config_file, 'w') as f: + json.dump(value, f, indent=4) + + +class Logger: + """ + This class provides minimal logging functionality. + It supports log rotation and two logging states: INFO, DEBUG. + - Todo: + - add gzip compression of rotated logs + - IF PROBLEMS OCCURE: make it thread safe + """ + def __init__(self, conf_dir, log_filename): + config = Config(conf_dir).read() + try: + log_dir = config['sys']['log_dir'] + except KeyError: + log_dir = conf_dir + '/logs/' + self.log_dir = log_dir + if not Path(log_dir).exists(): + Path(log_dir).mkdir(parents=True, exist_ok=True) + self.log_file = Path('{}/{}'.format(log_dir, log_filename)) + try: + self.log_mode = config['sys']['log_level'].lower() + except KeyError: + self.log_mode = 'info' + try: + self.log_size = config['sys']['log_size'].lower() + except KeyError: + self.log_size = 5 * 1000000 + + def log_rotate(self): + current_log_size = self.log_file.stat().st_size + if current_log_size > self.log_size: + while True: + n = 1 + np = (self.log_dir + self.log_file.stem + + str(n) + "".join(self.log_file.suffixes)) + rotate_p = Path(np) + if rotate_p.exists(): + n += 1 + else: + self.log_file.rename(rotate_p) + break + + def log_write(self, msg, line, debug=False): + with open(str(self.log_file), 'a+') as log: + log.write(line + '\n') + if not debug: + print(msg) + else: + print(line) + self.log_rotate() + + def info(self, msg): + if self.log_mode == 'info' or self.log_mode == 'debug': + line = '{} - INFO - {}'.format( + datetime.datetime.now(), msg) + self.log_write(msg, line) + + def debug(self, msg): + if self.log_mode == 'debug': + caller_name = sys._getframe(1).f_code.co_name + line = f'{datetime.datetime.now()} - DEBUG - {caller_name} - {msg}' + self.log_write(msg, line, debug=True) diff --git a/src/drastikbot.py b/src/drastikbot.py new file mode 100755 index 0000000..31278f8 --- /dev/null +++ b/src/drastikbot.py @@ -0,0 +1,102 @@ + +#!/usr/bin/env python3 +# coding=utf-8 + +# This is the initialization file used to start the bot. +# It parses command line arguments, verifies the state of the configuration. +# file and calls the main bot functions. + +''' +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 . +''' + +import os +import sys +import signal +import argparse +import traceback +from pathlib import Path + +from toolbox import config_check +from dbot_tools import Logger +from irc.worker import Main + +# Get the project's root directory +proj_path = os.path.dirname(os.path.abspath(__file__)) + + +def print_banner(): + banner = [ + "---------------------------------------------------------------", + " Drastikbot 2.1", + " An IRC bot focused on its extensibility and personalization", + "", + " License: GNU AGPLv3 only", + " Drastikbot 2.1 comes WITHOUT ANY WARRANTY", + "" + " Welcome!", + "---------------------------------------------------------------" + ] + for i in banner: + print(i) + + +def parser(): + parser = argparse.ArgumentParser() + parser.add_argument('-c', '--conf', nargs='?', + type=str, help='Specify the configuration directory') + args = parser.parse_args() + + # Check if a configuration directory is given or use the default one + # "~/.drastikbot" + if args.conf: + path = Path(args.conf) + if not path.is_dir(): + try: + path.mkdir(parents=True, exist_ok=False) + except FileExistsError: + sys.exit("[Error] Making configuration directory at" + f" '{args.conf}' failed. Another file with that name" + " already exists.") + conf_dir = str(path.expanduser().resolve()) + else: + path = Path('~/.drastikbot').expanduser() + if not path.is_dir(): + try: + path.mkdir(parents=True, exist_ok=False) + except FileExistsError: + sys.exit("[Error] Making configuration directory at" + " '~/.drastikbot' failed. Another file with that name" + " already exists.") + conf_dir = str(path) + + config_check.config_check(conf_dir) + logger = Logger(conf_dir, 'runtime.log') + logger.info('\nStarting up...\n') + return conf_dir + + +if __name__ == "__main__": + print_banner() + conf_dir = parser() + c = Main(conf_dir, proj_path) + try: + signal.signal(signal.SIGINT, c.sigint_hdl) + c.main() + except Exception as e: + logger = Logger(conf_dir, 'runtime.log') + logger.debug(f'Exception on startIRC(): {e} {traceback.print_exc()}') diff --git a/src/irc/irc.py b/src/irc/irc.py new file mode 100644 index 0000000..d2bf428 --- /dev/null +++ b/src/irc/irc.py @@ -0,0 +1,249 @@ +# 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 . +''' + +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 diff --git a/src/irc/message.py b/src/irc/message.py new file mode 100644 index 0000000..9136048 --- /dev/null +++ b/src/irc/message.py @@ -0,0 +1,109 @@ +# coding=utf-8 + +# Parse messages recieved by the IRC server and pack them in +# variables suitable for usage by the bot's functions. + +''' +Copyright (C) 2018-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 . +''' + + +class Message: + """ + Class used to parse IRC messages sent by the server and turn them into + objects useable by the bot and its modules. + """ + def __init__(self, msg_raw): + self.msg_raw = msg_raw + # Decode utf-8 and remove CR and LR from 'self.msg_raw'. + # self.msg = text_fix(self.msg_raw) # Prefer this if errors happen. + self.msg = self.msg_raw.decode('utf8', errors='ignore') + self.msg = ''.join(self.msg.splitlines()) + # Split the message in a list + self.msg_ls = self.msg.split() + # Split the message in [Prefix Command] and [Params] + msg_sp = self.msg.split(" :", 1) + # Split [Prefix Command] in [Prefix] [Command] + prefcmd_sp = msg_sp[0].split(" ", 1) + # Remove ":" from the prefix + self.prefix = prefcmd_sp[0][1:] + # Split the irc commands in a list + try: + self.cmd_ls = prefcmd_sp[1].split() + except IndexError: + self.cmd_ls = prefcmd_sp[0].split() + # Get the params + try: + self.params = msg_sp[1] + except IndexError: + self.params = '' + # Get the msgtype (PRIVMSG, NOTICE, JOIN) + self.msgtype = self.cmd_ls[0] + # Get user information. + try: + prefix_list = self.prefix.split('!', 1) + self.nickname = prefix_list[0] + self.username = prefix_list[1].split('@', 1)[0] + self.hostname = prefix_list[1].split('@', 1)[1] + except IndexError: + # self.nickname = prefix_list[0] # Should be set in the try: + self.username = '' + self.hostname = '' + + def channel_prep(self, irc): + """ + The channel is placed in different positions depending on the IRC + command sent. For parsing we use a dictionary with the various + commands and we call the matching sub-function to parse the channel. + """ + def _join(): + return self.msg_ls[2].lstrip(":") + + def _353(): + return self.cmd_ls[3] + + def _privmsg(): + return self.cmd_ls[1] + + def __rest(): + ''' + This is used for msgtypes not specified in the 'self.channel' + dict below. + ''' + try: + c = self.cmd_ls[1] + except IndexError: + c = "" + return c + + self.channel = { + 'JOIN': _join, + '353': _353, + 'PRIVMSG': _privmsg + }.get(self.msgtype, __rest)() + + if self.channel == irc.var.curr_nickname: + self.channel = self.nickname + + try: + self.params_nocmd = self.params.split(' ', 1)[1].strip() + except IndexError: + self.params_nocmd = '' + try: + self.chn_prefix = irc.var.mod_chn_prefix[self.channel] + except KeyError: + self.chn_prefix = irc.var.mod_glb_prefix diff --git a/src/irc/modules.py b/src/irc/modules.py new file mode 100644 index 0000000..1f4d7d0 --- /dev/null +++ b/src/irc/modules.py @@ -0,0 +1,394 @@ +# coding=utf-8 + +# Methods for importing, reloading and calling drastikbot modules. +# Features for modules such as Variable Memory, SQLite databases, +# channel blacklist and whitelist checks and user access list checks +# are defined here. + +''' +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 . +''' + +import sys +from pathlib import Path, PurePath +import importlib +import traceback +import inspect +import sqlite3 +from dbot_tools import Config, Logger +from toolbox import user_acl + + +class VariableMemory: + def varset(self, name, value): + """ + Set a variable to be kept in the bot's memory. + + 'name' is the name of the variable. + 'value' is the variable's value. + + The name of the actual saved variable is not the actual name given, but + _<'name'>. e.g sed_msgdict. + This is to allow different modules have any variable and make accessing + those variables easier. + """ + # Get the caller module's name: + frm = inspect.stack()[1] + mod = inspect.getmodule(frm[0]).__name__ + # Check if it's a call from this module. (modules.py) + if mod == __loader__.name: + return + else: + name = f"{mod}_{name}" + + setattr(self, name, value) + + def varget(self, name, defval=False, raw=False): + """ + Get a variable from the bot's memory. + 'name' is the name of the variable. + 'defval' is the default to set if no variable is already set. + 'raw' if True will not append the module's name in frond of 'name'. + This is to access variables from other modules. + + If both 'defval' and 'raw' are non False but the variable cannon be + found an AttributeError is raised. + """ + if not raw: + # Get the caller module's name: + frm = inspect.stack()[1] + mod = inspect.getmodule(frm[0]).__name__ + # Check if it's a call from this module. (modules.py) + if mod == __loader__.name: + return + name = f"{mod}_{name}" + + try: + return getattr(self, name) + except AttributeError: + if defval and not raw: + self.varset(name, defval) + return defval + else: + raise AttributeError(f"'{name}' has no value set. " + "Try passing a default value" + " or set 'raw=False'") + + +class Info: + """ + This class is used for setting up message and runtime variables by + Modules.info_prep() and is passed to the modules. + """ + __slots__ = ['cmd', 'channel', 'nickname', 'username', 'hostname', 'msg', + 'msg_nocmd', 'cmd_prefix', 'msgtype', 'is_pm', 'msg_raw', + 'db', 'msg_ls', 'msg_prefix', 'cmd_ls', 'msg_full', 'modules', + 'command_dict', 'auto_list', 'mod_import', 'blacklist', + 'whitelist', 'msg_params', 'varget', 'varset'] + + +class Modules: + def __init__(self, irc): + self.irc = irc + self.cd = self.irc.var.cd + self.log = Logger(self.cd, 'modules.log') + self.varmem = VariableMemory() + + self.modules = {} # {Module Name : Module Callable} + + self.msgtype_dict = {} # {msgtype: [module1, ...]} + self.auto_list = [] # [module1, ...] + self.startup_list = [] # [module1, ...] + self.command_dict = {} # {command1: module} + # Databases # + self.dbmem = sqlite3.connect(':memory:', check_same_thread=False) + self.dbdisk = sqlite3.connect('{}/drastikbot.db'.format(self.cd), + check_same_thread=False) + self.mod_settings = {} # {Module Name : {setting : value}} + + def mod_imp_prep(self, module_dir, auto=False): + ''' + Search a directory for modules, check if they are listed in the config + file and return a list of the modules. + If 'auto' is True load all the modules without checking the config + file (used for core modules needed for the bot's operation). + ''' + path = Path(module_dir) + load = Config(self.cd).read()['irc']['modules']['load'] + if not path.is_dir(): + # Check if the module directory exists under the + # configuration directory and make it otherwise. + path.mkdir(exist_ok=True) + self.log.info(' - Module directory created at: {}' + .format(module_dir)) + # Append the module directory in the sys.path variable + sys.path.append(module_dir) + files = [f for f in path.iterdir() if Path( + PurePath(module_dir).joinpath(f)).is_file()] + modimp_list = [] + for f in files: + suffix = PurePath(f).suffix + prefix = PurePath(f).stem + if suffix == '.py': + if auto: + modimp_list.append(prefix) + elif prefix in load: + modimp_list.append(prefix) + return modimp_list + + def mod_import(self): + """ + Import modules specified in the configuration file. + Check for values specified in the Module() class of every module and + set the variables declared in __init__() for later use. + """ + self.log.info('\n> Loading Modules:\n') + + importlib.invalidate_caches() + + modimp_list = [] + module_dir = self.cd + '/modules' + modimp_list.extend(self.mod_imp_prep(module_dir)) + module_dir = self.irc.var.proj_path + '/irc/modules' + modimp_list.extend(self.mod_imp_prep(module_dir, auto=True)) + + # Empty variabled from previous import: + self.modules = {} # {Module Name : Module Callable} + self.msgtype_dict = {} # {msgtype: [module1, ...]} + self.auto_list = [] # [module1, ...] + self.startup_list = [] # [module1, ...] + self.command_dict = {} # {command1: module} + + for m in modimp_list: + try: + modimp = importlib.import_module(m) + # Dictionary with the module name and it's callable + self.modules[m] = modimp + # Read the module's "Module()" class to + # get the required runtime information, + # such as: commands, sysmode + try: + mod = modimp.Module() + except AttributeError: + self.log.info(f' Module "{m}" does not have a Module() ' + 'class and was not loaded.') + + commands = [c for c in getattr(mod, 'commands', [])] + for c in commands: + if c in self.command_dict: + self.log.info(f' Command "{c}" is already used by ' + f'"{self.command_dict[c]}.py", but is ' + f'also requested by "{m}.py".') + sys.exit(1) + self.command_dict[c] = m + msgtypes = [m.upper() for m in getattr( + mod, 'msgtypes', ['PRIVMSG'])] + for i in msgtypes: + self.msgtype_dict.setdefault(i, []).append(m) + if getattr(mod, 'auto', False): + self.auto_list.append(m) + if getattr(mod, 'startup', False): + self.startup_list.append(m) + + self.log.info('> Loaded module: {}'.format(m)) + except Exception: + self.log.debug(f' Module "{m}" failed to load: ' + f'\n{traceback.format_exc()}') + + def mod_reload(self): + ''' + Reload the already imported modules. + WARNING: Changes in the Module() class are not reloaded using this + method. Reimport the modules (with self.mod_import()) to do that. + ''' + for value in self.modules.values(): + importlib.reload(value) + + def blacklist(self, module, channel): + ''' + Read the configuration file and get the blacklist for the given module. + [returns] + True : if the channel is in the blacklist + False: if the channel is not in the blacklist or if the blacklist + is empty + ''' + try: + blacklist = self.irc.var.modules_obj['blacklist'][module] + except KeyError as e: + if e.args[0] == module: + self.irc.var.modules_obj['blacklist'].update({module: []}) + return False + if not blacklist: + return False + elif channel in blacklist: + return True + else: + return False + + def whitelist(self, module, channel): + ''' + Read the configuration file and check if the channel is in the + blacklist of the given module. + [returns] + True : if the channel is in the module's whitelist or if the + whitelist is empty and + False: if the whitelist is not empty and the channel is not in it. + ''' + try: + whitelist = self.irc.var.modules_obj['whitelist'][module] + except KeyError as e: + if e.args[0] == module: + self.irc.var.modules_obj['whitelist'].update({module: []}) + return True + if not whitelist: + return True + elif channel in whitelist: + return True + else: + return False + + def info_prep(self, msg): + """ + Set values in the Info() class and return that class. + + --Notes: + i.cmd :: The module command without the prefix. + Values are set by mod_main() before calling the module. + In "Auto" modules this will remain as an empty string. + i.msg :: Instead of setting the msg.msg value, we set msg.params, + to achieve a better looking API. Instead "i.msg_full" is + set to msg.msg + i.msg_params :: It is the same as i.msg, we use this to match the + RFC's terminology. + """ + i = Info() + i.cmd = '' + i.channel = msg.channel + i.nickname = msg.nickname + i.username = msg.username + i.hostname = msg.hostname + i.msg_raw = msg.msg_raw + i.msg_full = msg.msg + i.msg = msg.params + i.msg_nocmd = msg.params_nocmd + i.msg_ls = msg.msg_ls + i.msg_prefix = msg.prefix + i.msg_params = msg.params + i.cmd_ls = msg.cmd_ls + i.cmd_prefix = msg.chn_prefix + i.msgtype = msg.msgtype + i.is_pm = i.channel == i.nickname + i.db = [self.dbmem, self.dbdisk] + i.varset = self.varmem.varset + i.varget = self.varmem.varget + i.modules = self.modules + i.command_dict = self.command_dict + i.auto_list = self.auto_list + i.blacklist = self.blacklist + i.whitelist = self.whitelist + i.mod_import = self.mod_import + return i + + def mod_main(self, irc, msg, command): + def cmd_modules(): + try: + module = self.command_dict[command[1:]] + except KeyError: + return + if self.blacklist(module, i.channel): + return + if not self.whitelist(module, i.channel): + return + if user_acl.is_banned(self.irc.var.user_acl, i.channel, i.nickname, + i.username, i.hostname, module): + return + if module in md: + # We set i.cmd to the command's name. + i.cmd = command[1:] + try: + self.modules[module].main(*args) + except Exception: + self.log.debug(f' Module "{module}" exitted with error:' + f'\n{traceback.format_exc()}') + + self.mod_reload() # Reload the bot's modules. + i = self.info_prep(msg) + args = (i, irc) + + try: + md = self.msgtype_dict[msg.msgtype] + except KeyError: + # No modules use this message type, return. + return + + if command[:1] == i.cmd_prefix: + cmd_modules() + + for m in list(set(self.auto_list).intersection(md)): + if self.blacklist(m, i.channel): + continue + if not self.whitelist(m, i.channel): + continue + if user_acl.is_banned(self.irc.var.user_acl, i.channel, i.nickname, + i.username, i.hostname, m): + continue + try: + # We set i.cmd to False to indicate that it's an auto call. + i.cmd = "" + self.modules[m].main(*args) + except Exception: + self.log.debug(f' Module "{m}" exitted with error: ' + f'\n{traceback.format_exc()}') + + def mod_startup(self, irc): + ''' + Run modules configured with the "self.startup = True" option. + The bot doesn't manage the modules after they get started. + This could be problematic for modules that are blocking and so they + should periodically check the bot's connection state (e.g. by checking + the value of "irc.var.conn_state") and handle a possible disconnect. + The bot's whitelist/blacklist is not being taken into account. + The "msgtype" and "cmd" passed to the modules is "STARTUP". + The "info" tuple is perfectly matched by blank strings. + ''' + def info_prep_startup(): + ''' + Special use of the Info class that includes only the variables + available at startup. + ''' + i = Info() + i.cmd = '' + i.msgtype = "STARTUP" # Indicate that this is a startup call. + i.db = [self.dbmem, self.dbdisk] + i.varget = self.varmem.varget + i.varset = self.varmem.varset + i.modules = self.modules + i.mod_import = self.mod_import + return i + + self.mod_reload() # Reload the bot's modules. + i = info_prep_startup() + args = (i, irc) + + for m in self.startup_list: + try: + # We set i.cmd to False to indicate that it's an auto call. + i.cmd = "" + self.modules[m].main(*args) + except Exception: + self.log.debug(f' Module "{m}" exitted with error: ' + f'\n{traceback.format_exc()}') diff --git a/src/irc/modules/COPYING b/src/irc/modules/COPYING new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/src/irc/modules/COPYING @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/src/irc/modules/README.md b/src/irc/modules/README.md new file mode 100644 index 0000000..e0b494f --- /dev/null +++ b/src/irc/modules/README.md @@ -0,0 +1,28 @@ +# drastikbot_modules + +Modules for drastikbot. +Visit https://drastik.org/drastikbot/ for more documentation. + +## Getting Started + +### Prerequisites + +* The newest version of [drastikbot](https://github.com/olagood/drastikbot) +* Python 3 +* GNU/Linux or any UNIX-like OS + +### Installing + +Check https://drastik.org/drastikbot/quickguide.html#install_modules for module installation and configuration instructions. + +## Contributing + +All code contributions must follow the PEP 8 styling guidelines. Use of flake8 is recommended. The code should be fully tested to ensure it does not break drastikbot or any of its modules. + +## Authors + +* **drastik** - [olagood](https://github.com/olagood) | [drastik.org](https://drastik.org) + +## License + +This project is licensed under the GNU General Public License Version 3 - see the [COPYING](COPYING) file for details. diff --git a/src/irc/modules/admin.py b/src/irc/modules/admin.py new file mode 100644 index 0000000..f1a4d19 --- /dev/null +++ b/src/irc/modules/admin.py @@ -0,0 +1,834 @@ +# coding=utf-8 + +# Module that provides an interface for managing the bot over IRC. + +''' +Copyright (C) 2018-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 . +''' + +import datetime + +from dbot_tools import Config +from user_auth import user_auth + + +class Module: + def __init__(self): + self.commands = ["join", "part", "privmsg", "notice", + "acl_add", "acl_del", "acl_list", + "mod_import", + "mod_whitelist_add", "mod_whitelist_del", + "mod_blacklist_add", "mod_blacklist_del", + "mod_list", + "mod_global_prefix_set", "mod_channel_prefix_set", + "admin_help"] + + +# --- Settings --- # +user_modes = ['~', '&', '@', '%'] +#################### + + +# +# Permission Checks +# +def is_bot_owner(irc, nickname): + if nickname in irc.var.owners: + return True + else: + return False + + +def is_channel_mod(irc, nickname, channel): + try: + for m in irc.var.namesdict[channel][1][nickname]: + if m in user_modes: + return True + return False + except Exception: + return False + + +def is_allowed(i, irc, nickname, channel=""): + if is_bot_owner(irc, nickname): + if user_auth(i, irc, i.nickname): + return True + elif channel and is_channel_mod(irc, nickname, channel): + return True + else: + return False + elif channel and is_channel_mod(irc, nickname, channel): + return True + else: + return False + + +# +# Channel Management +# +def _join(irc, channel, password=""): + chan_dict = {channel: password} + conf_r = Config(irc.cd).read() + conf_r['irc']['channels'][channel] = password + Config(irc.cd).write(conf_r) + irc.var.config_load() + irc.join(chan_dict) + + +def join(i, irc): + if not i.msg_nocmd: + m = f"Usage: {i.cmd_prefix}join [password]" + return irc.notice(i.nickname, m) + + if not is_allowed(i, irc, i.nickname): + m = "\x0304You are not authorized. Are you logged in?" + return irc.notice(i.nickname, m) + + args = i.msg_nocmd.split(" ", 1) + channel = args[0] + try: + password = args[1] + except IndexError: + password = "" + + if channel in irc.var.channels: + m = f"\x0303The bot has already joined the channel: {channel}" + return irc.notice(i.nickname, m) + + _join(irc, channel, password) + irc.notice(i.nickname, f"\x0303Joined {channel}") + + +def _part(irc, channel, message=""): + conf_r = Config(irc.cd).read() + if channel not in conf_r['irc']['channels']: + return False + del conf_r['irc']['channels'][channel] + Config(irc.cd).write(conf_r) + irc.var.config_load() + irc.part(channel, message) + return True + + +def part(i, irc): + if not i.msg_nocmd: + m = f"Usage: {i.cmd_prefix}part [message]" + return irc.notice(i.nickname, m) + + args = i.msg_nocmd.split(" ", 1) + channel = args[0] + try: + message = args[1] + except IndexError: + message = "" + + if channel not in irc.var.channels: + m = f"\x0304The bot has not joined the channel: {channel}" + return irc.notice(i.nickname, m) + + if not is_allowed(i, irc, i.nickname, channel): + m = f"\x0304You are not authorized. Are you an operator of {channel}?" + return irc.notice(i.nickname, m) + + if _part(irc, channel, message): + irc.notice(i.nickname, f"\x0303Left {channel}") + else: + irc.notice(i.nickname, f"\x0304{channel} not joined") + + +def privmsg(i, irc): + m = f"Usage: {i.cmd_prefix}privmsg " + if not i.msg_nocmd: + return irc.notice(i.nickname, m) + + args = i.msg_nocmd.split(" ", 1) + if len(args) < 2: + return irc.notice(i.nickname, m) + + channel = args[0] + message = args[1] + + if channel not in irc.var.channels: + m = f"\x0304The bot has not joined the channel: {channel}" + return irc.notice(i.nickname, m) + + if not is_allowed(i, irc, i.nickname, channel): + m = f"\x0304You are not authorized. Are you an operator of {channel}?" + return irc.notice(i.nickname, m) + + irc.privmsg(channel, message) + irc.notice(i.nickname, "\x0303Message sent") + + +def notice(i, irc): + m = f"Usage: {i.cmd_prefix}notice " + if not i.msg_nocmd: + return irc.notice(i.nickname, m) + + args = i.msg_nocmd.split(" ", 1) + if len(args) < 2: + return irc.notice(i.nickname, m) + + channel = args[0] + message = args[1] + + if channel not in irc.var.channels: + m = f"\x0304The bot has not joined the channel: {channel}" + return irc.notice(i.nickname, m) + + if not is_allowed(i, irc, i.nickname, channel): + m = f"\x0304You are not authorized. Are you an operator of {channel}?" + return irc.notice(i.nickname, m) + + irc.notice(channel, message) + irc.notice(i.nickname, "\x0303Message sent") + + +# +# User ACL +# +def _get_future_unix_timestamp_from_str(duration_str): + seconds = 0 + + tmp = 0 + for i in duration_str: + if i.isdigit(): + tmp *= 10 + tmp += int(i) + elif i == 'y': + seconds += 31536000 * tmp # 365days * 24hours * 60mins * 60secs + tmp = 0 + elif i == 'M': + seconds += 2592000 * tmp # 30days * 24hours * 60mins * 60secs + tmp = 0 + elif i == 'w': + seconds += 604800 * tmp # 7days * 24hours * 60mins * 60secs + tmp = 0 + elif i == 'd': + seconds += 86400 * tmp # 24hours * 60mins * 60secs + tmp = 0 + elif i == 'h': + seconds += 3600 * tmp # 60mins * 60secs + tmp = 0 + elif i == 'm': + seconds += 60 * tmp # 60secs + tmp = 0 + elif i == 's': + seconds += tmp + tmp = 0 + else: + return False + + now = datetime.datetime.now(datetime.timezone.utc).timestamp() + return now + seconds + + +def _check_usermask(usermask): + try: + t = usermask.split("!", 1) + t[0] + t = t[1].split("@", 1) + t[0] + t[1] + return True + except Exception: + return False + + +def _acl_add(irc, mask): + c = Config(irc.cd).read() + if mask in c['irc']['user_acl']: + return False # The mask already exists + c['irc']['user_acl'].append(mask) + Config(irc.cd).write(c) + irc.var.config_load() + return len(c['irc']['user_acl']) - 1 + + +def acl_add(i, irc): + m = (f"Usage: {i.cmd_prefix}acl_add " + " !@ " + f" | See {i.cmd_prefix}admin_help for details.") + if not i.msg_nocmd: + return irc.notice(i.nickname, m) + + args = i.msg_nocmd.split(" ", 3) + if len(args) < 4: + return irc.notice(i.nickname, m) + channel = args[0] + usermask = args[1] + duration = args[2] + modules = args[3].replace(" ", "") + + if channel not in irc.var.channels and channel != '*': + m = f"\x0304The bot has not joined the channel: {channel}" + return irc.notice(i.nickname, m) + + if not _check_usermask(usermask): + m = f"\x0304Invalid usermask: '{usermask}' given" + return irc.notice(i.nickname, m) + + if duration != '0': + duration = _get_future_unix_timestamp_from_str(duration) + if not duration: + m = "\x0304Error while parsing the duration string" + return irc.notice(i.nickname, m) + + if modules != '*': + module_list = modules.split(",") + for mod in module_list: + if mod not in i.modules: + m = f"\x0304Error: Module {mod} is not loaded" + + if channel == '*': + if not is_allowed(i, irc, i.nickname): + m = f"\x0304You are not authorized. Are you logged in?" + return irc.notice(i.nickname, m) + else: + if not is_allowed(i, irc, i.nickname, channel): + m = ("\x0304You are not authorized. " + f"Are you an operator of {channel}?") + return irc.notice(i.nickname, m) + + _acl_add(irc, i.msg_nocmd) + irc.notice(i.nickname, f"\x0303User mask added in the ACL") + + +def _acl_del(irc, idx): + c = Config(irc.cd).read() + if len(c['irc']['user_acl']) - 1 >= idx: + del c['irc']['user_acl'][idx] + Config(irc.cd).write(c) + irc.var.config_load() + return True + else: + return False # Index out of range + + +def acl_del(i, irc): + m = f"Usage: {i.cmd_prefix}acl_del " + if not i.msg_nocmd: + return irc.notice(i.nickname, m) + + if len(i.msg_nocmd.split()) > 1: + return irc.notice(i.nickname, m) + + idx = int(i.msg_nocmd) + + c = Config(irc.cd).read() + try: + mask = c['irc']['user_acl'][idx] + except IndexError: + m = "\x0304 This mask does not exist" + irc.notice(i.nickname, m) + return + channel = mask.split(" ", 1)[0] + if channel == '*': + if not is_allowed(i, irc, i.nickname): + m = f"\x0304You are not authorized. Are you logged in?" + return irc.notice(i.nickname, m) + else: + if not is_allowed(i, irc, i.nickname, channel): + m = ("\x0304You are not authorized. " + f"Are you an operator of {channel}?") + return irc.notice(i.nickname, m) + + if _acl_del(irc, idx): + m = f"\x0303Deleted mask: '{mask}' from the ACL" + irc.notice(i.nickname, m) + else: + m = "\x0304 This mask does not exist" + irc.notice(i.nickname, m) + + +def acl_list(i, irc): + for idx, mask in enumerate(irc.var.user_acl): + irc.privmsg(i.nickname, f"{idx}: {mask}") + + +# +# Module Management +# +def mod_import(i, irc): + if not is_allowed(i, irc, i.nickname): + m = f"\x0304You are not authorized. Are you logged in?" + return irc.notice(i.nickname, m) + i.mod_import() + irc.notice(i.nickname, '\x0303New module were imported.') + + +def _module_wb_list_add(i, irc, module, channel, mode): + if mode == "whitelist": + edom = "blacklist" + elif mode == "blacklist": + edom = "whitelist" + else: + raise ValueError("'mode' can only be 'whitelist' or 'blacklist'.") + + c = Config(irc.cd).read() + ls = c["irc"]["modules"][mode] + + if module not in i.modules: + return 1 # This module is not loaded + elif (module in c["irc"]["modules"][edom] + and channel in c["irc"]["modules"][edom][module]): + return 2 # This module has a {edom}list set + elif module not in ls: + ls.update({module: []}) + elif channel in ls[module]: + return 3 # This channel has already been added. + + ls[module].append(channel) + Config(irc.cd).write(c) + irc.var.config_load() + return 0 + + +def mod_whitelist_add(i, irc): + m = f"Usage: {i.cmd_prefix}mod_whitelist_add " + if not i.msg_nocmd: + return irc.notice(i.nickname, m) + + args = i.msg_nocmd.split(" ", 1) + if len(args) < 2: + return irc.notice(i.nickname, m) + + module = args[0] + channel = args[1] + + if channel not in irc.var.channels: + m = f"\x0304The bot has not joined the channel: {channel}" + return irc.notice(i.nickname, m) + + if not is_allowed(i, irc, i.nickname, channel): + m = f"\x0304You are not authorized. Are you an operator of {channel}?" + return irc.notice(i.nickname, m) + + ret = _module_wb_list_add(i, irc, module, channel, "whitelist") + if ret == 1: + irc.notice(i.nickname, f"\x0304The module: {module} is not loaded") + elif ret == 2: + m = (f"\x0304The module: {module} has a blacklist set. " + "Clear the blacklist and try again.") + irc.notice(i.nickname, m) + elif ret == 3: + m = f"\x0304{channel} has already been added in {module}'s whitelist" + irc.notice(i.nickname, m) + else: + m = f"\x0303{channel} added in {module}'s whitelist" + irc.notice(i.nickname, m) + + +def mod_blacklist_add(i, irc): + m = f"Usage: {i.cmd_prefix}mod_blacklist_add " + if not i.msg_nocmd: + return irc.notice(i.nickname, m) + + args = i.msg_nocmd.split(" ", 1) + if len(args) < 2: + return irc.notice(i.nickname, m) + + module = args[0] + channel = args[1] + + if channel not in irc.var.channels: + m = f"\x0304The bot has not joined the channel: {channel}" + return irc.notice(i.nickname, m) + + if not is_allowed(i, irc, i.nickname, channel): + m = f"\x0304You are not authorized. Are you an operator of {channel}?" + return irc.notice(i.nickname, m) + + ret = _module_wb_list_add(i, irc, module, channel, "blacklist") + if ret == 1: + irc.notice(i.nickname, f"\x0304The module: {module} is not loaded") + elif ret == 2: + m = (f"\x0304The module: {module} has a whitelist set. " + "Clear the whitelist and try again.") + irc.notice(i.nickname, m) + elif ret == 3: + m = f"\x0304{channel} has already been added in {module}'s blacklist" + irc.notice(i.nickname, m) + else: + m = f"\x0303{channel} added in {module}'s blacklist" + irc.notice(i.nickname, m) + + +def _module_wb_list_del(irc, module, channel, mode): + if mode != "whitelist" and mode != "blacklist": + raise ValueError("'mode' can only be 'whitelist' or 'blacklist'.") + + c = Config(irc.cd).read() + ls = c["irc"]["modules"][mode] + if module in ls and channel in ls[module]: + ls[module].remove(channel) + Config(irc.cd).write(c) + irc.var.config_load() + return True + else: + return False # This channel has not been added. + + +def mod_whitelist_del(i, irc): + m = f"Usage: {i.cmd_prefix}mod_whitelist_del " + if not i.msg_nocmd: + return irc.notice(i.nickname, m) + + args = i.msg_nocmd.split(" ", 1) + if len(args) < 2: + return irc.notice(i.nickname, m) + + module = args[0] + channel = args[1] + + if channel not in irc.var.channels: + m = f"\x0304The bot has not joined the channel: {channel}" + return irc.notice(i.nickname, m) + + if not is_allowed(i, irc, i.nickname, channel): + m = f"\x0304You are not authorized. Are you an operator of {channel}?" + return irc.notice(i.nickname, m) + + if _module_wb_list_del(irc, module, channel, "whitelist"): + m = f"\x0303{channel} removed from {module}'s whitelist" + return irc.notice(i.nickname, m) + else: + m = f"\x0304This channel has not been added in {module}'s whitelist" + return irc.notice(i.nickname, m) + + +def mod_blacklist_del(i, irc): + m = f"Usage: {i.cmd_prefix}mod_blacklist_del " + if not i.msg_nocmd: + return irc.notice(i.nickname, m) + + args = i.msg_nocmd.split(" ", 1) + if len(args) < 2: + return irc.notice(i.nickname, m) + + module = args[0] + channel = args[1] + + if channel not in irc.var.channels: + m = f"\x0304The bot has not joined the channel: {channel}" + return irc.notice(i.nickname, m) + + if not is_allowed(i, irc, i.nickname, channel): + m = f"\x0304You are not authorized. Are you an operator of {channel}?" + return irc.notice(i.nickname, m) + + if _module_wb_list_del(irc, module, channel, "blacklist"): + m = f"\x0303{channel} removed from {module}'s blacklist" + return irc.notice(i.nickname, m) + else: + m = f"\x0304This channel has not been added in {module}'s blacklist" + return irc.notice(i.nickname, m) + + +def _module_wb_list_list(i, irc, channel=""): + c = Config(irc.cd).read() + wl = c["irc"]["modules"]["whitelist"] + bl = c["irc"]["modules"]["blacklist"] + + wl_message = "\x0301,00WHITELIST\x0F :" + if channel: + wl_message += f" {channel} :" + for module in wl: + if not channel: + wl_message += f" {module}: {wl[module]} /" + else: + if channel in wl[module]: + wl_message += F" {module} /" + + bl_message = "\x0300,01BLACKLIST\x0F :" + if channel: + bl_message += f" {channel} :" + for module in bl: + if not channel: + bl_message += f" {module}: {bl[module]} /" + else: + if channel in wl[module]: + bl_message += F" {module} /" + + irc.privmsg(i.nickname, wl_message) + irc.privmsg(i.nickname, bl_message) + + +def mod_list(i, irc): + if not i.msg_nocmd: + if not is_allowed(i, irc, i.nickname): + m = (f"\x0304You are not authorized.\x0F " + f"Usage: {i.cmd_prefix}mod_list | " + "Bot owners can ommit the argument.") + return irc.notice(i.nickname, m) + else: + return _module_wb_list_list(i, irc) + + if len(i.msg_nocmd.split(" ")) > 1: + m = (f"Usage: {i.cmd_prefix}mod_list | " + "Bot owners can ommit the argument.") + if not is_allowed(i, irc, i.nickname): + m = (f"\x0304You are not authorized. " + f"Are you an operator of {i.msg_nocmd}?") + return irc.notice(i.nickname, m) + else: + return _module_wb_list_list(i, irc, i.msg_nocmd) + + +def _mod_global_prefix_set(irc, prefix): + c = Config(irc.cd).read() + c['irc']['modules']['global_prefix'] = prefix + Config(irc.cd).write(c) + irc.var.config_load() + + +def mod_global_prefix_set(i, irc): + if not is_allowed(i, irc, i.nickname): + m = f"\x0304You are not authorized. Are you logged in?" + return irc.notice(i.nickname, m) + + m = f"Usage: {i.cmd_prefix}mod_global_prefix_set " + if not i.msg_nocmd: + irc.notice(i.nickname, m) + elif len(i.msg_nocmd.split(" ")) > 1: + irc.notice(i.nickname, m) + else: + _mod_global_prefix_set(irc, i.msg_nocmd) + m = f"\x0303Successfully changed the global_prefix to {i.msg_nocmd}" + irc.notice(i.nickname, m) + + +def _mod_channel_prefix_set(irc, channel, prefix): + c = Config(irc.cd).read() + c['irc']['modules']['channel_prefix'][channel] = prefix + Config(irc.cd).write(c) + irc.var.config_load() + + +def mod_channel_prefix_set(i, irc): + m = f"Usage: {i.cmd_prefix}mod_channel_prefix_set " + if not i.msg_nocmd: + return irc.notice(i.nickname, m) + + args = i.msg_nocmd.split(" ", 1) + if len(args) != 2: + return irc.notice(i.nickname, m) + + channel = args[0] + prefix = args[1] + + if channel not in irc.var.channels: + m = f"\x0304The bot has not joined the channel: {channel}" + return irc.notice(i.nickname, m) + + if not is_allowed(i, irc, i.nickname, channel): + m = f"\x0304You are not authorized. Are you an operator of {channel}?" + return irc.notice(i.nickname, m) + + _mod_channel_prefix_set(irc, channel, prefix) + m = ("\x0303Successfully changed the channel_prefix for " + f"{channel} to {prefix}") + irc.notice(i.nickname, m) + + +# +# Help +# +def admin_help(i, irc): + join = [ + f"Usage: {i.cmd_prefix}join [password]", + " Permission: Owners", + "Join a channel." + ] + part = [ + f"Usage: {i.cmd_prefix}part [message]", + " Permission: Channel Operators", + "Leave a channel. Channel Operators can only use this command on" + " channels where they have operator privilages." + ] + privmsg = [ + f"Usage: {i.cmd_prefix}privmsg ", + " Permission: Channel Operators", + "Have the bot send a private message to a channel or a person. You" + " must be an operator of the channel you want to send a private" + " message to. Only bot owners can use this command to send messages to" + " other users." + ] + notice = [ + f"Usage: {i.cmd_prefix}notice ", + " Permission: Channel Operators", + "Have the bot send a notice to a channel or a person. You must be an" + " operator of the channel you want to send a notice to. Only bot" + " owners can use this command to send notices to other users." + ] + acl_add = [ + f"Usage: {i.cmd_prefix}acl_add " + "!@ ", + " Permission: Channel Operators", + "Add a user access list rule.", + "Explanation:", + " : This can be a single channel or '*'. Users can only set" + " this to a channel where they have operator privilages. '*' means all" + " channels and can only be used by bot owners.", + ": This can be the exact nickname of an IRC user or '*'." + " '*' means any nickname.", + ": This can be the exact username of an IRC user or '*' or" + " or '*'. '*' means match any username. '*' would match" + " everything that has in the end. Note that using the *" + " character in the middle or in the end of would exactly" + " match * and won't expand to other usernames.", + ": It can be the exact hostname of an IRC user, '*'," + " '*' or '*'. '*' means match any hostname. '*' and" + " '*' would match everything that has in the end or in" + " the beginning. Every other placement of the * character would be an" + " exact match.", + ": Duration is used to specify for how long the rule should" + " be in effect. The syntax used is yMwdhms. No spaces should be used." + " To have the rule in effect forever set this to '0'." + " Example: 1y2M3m would mean 1 year, 2 Months, 3 minutes.", + ": This is a list of modules that the rule will apply to." + " The list is comma seperated. Use '*' to have the rule apply for all" + " modules.", + "Examples:", + "#bots nick!*@* 0 * : Rule to block the user with the nickname 'nick'" + " from using any module in the channel #bots forever.", + "#bots *!*@*isp.IP 1M1w3h tell : This rule will block any user with" + " the domain 'isp.IP' from using the tell module for 1 Month 1 week" + " and 3 hours in the channel #bots." + ] + acl_del = [ + f"Usage: {i.cmd_prefix}acl_del ", + " Permission: Channel Operators", + "Remove a rule from the user access list using its ID. The ID can be" + f" retrieved using the command: {i.cmd_prefix}acl_list. Note that the" + " IDs might change everytime a rule is deleted. Users can only delete" + " delete rules for channels where they have Operator rights. Rules" + " that have their channel part set to '*' can only be deleted by the" + " bot's owners." + ] + acl_list = [ + f"Usage: {i.cmd_prefix}acl_list", + " Permission: Everyone", + "Get a list of every rule set. Rules are numbered. This number is" + " their current ID and can be used in the acl_del command." + ] + mod_import = [ + f"Usage: {i.cmd_prefix}mod_import", + " Permission: Owners", + "Import any new modules specified in the configuration file and reload" + " the currently imported ones." + ] + mod_whitelist_add = [ + f"Usage: {i.cmd_prefix}mod_whitelist_add ", + " Permission: Channel Operators", + "Add a channel to a module's whitelist. This will make the module only" + " respond to the channels added in this whitelist. You cannot add a" + " channel to a modules whitelist if a blacklist is already in place." + ] + mod_blacklist_add = [ + f"Usage: {i.cmd_prefix}mod_blacklist_add ", + " Permission: Channel Operators", + "Add a channel to a module's blacklist. This will make the module not" + " respond to the channels added in this blacklist. You cannot add a" + " channel to a modules blacklist if a whitelist is already in place." + ] + mod_whitelist_del = [ + f"Usage: {i.cmd_prefix}mod_whitelist_del ", + " Permission: Channel Operators", + "Delete a channel from a module's whitelist." + ] + mod_blacklist_del = [ + f"Usage: {i.cmd_prefix}mod_blacklist_del ", + " Permission: Channel Operators", + "Delete a channel from a module's blacklist." + ] + mod_list = [ + f"Usage: {i.cmd_prefix}mod_list ", + " Permission: Channel Operators", + "Get a list of every module that has in its whitelist or" + " blacklist. Bot owners can omit the and get a list" + " of all channels that have been in a whitelist or a blacklist." + ] + mod_global_prefix_set = [ + f"Usage: {i.cmd_prefix}mod_global_prefix_set ", + " Permission: Owners", + "Set the default prefix used for commands. This is the character used" + " before a command name. E.g.: .command '.' is the prefix here." + ] + mod_channel_prefix_set = [ + f"Usage: {i.cmd_prefix}mod_channel_prefix_set ", + " Permission: Channel Operators", + "Set the default prefix used for commands in a specific channel. This" + " is the character used before a command name. E.g.: .command '.' is" + " the prefix here. This prefix overrides the default value." + ] + + help_d = { + "join": join, + "part": part, + "privmsg": privmsg, + "notice": notice, + "acl_add": acl_add, + "acl_del": acl_del, + "acl_list": acl_list, + "mod_import": mod_import, + "mod_whitelist_add": mod_whitelist_add, + "mod_blacklist_add": mod_blacklist_add, + "mod_whitelist_del": mod_whitelist_del, + "mod_blacklist_del": mod_blacklist_del, + "mod_list": mod_list, + "mod_global_prefix_set": mod_global_prefix_set, + "mod_channel_prefix_set": mod_channel_prefix_set, + "admin_help": admin_help + } + try: + cmd_help_list = help_d[i.msg_nocmd] + except KeyError: + avail_cmd = "" + for cmd in help_d: + avail_cmd += f"{cmd}, " + avail_cmd = avail_cmd[:-3] + m = f"Usage: {i.cmd_prefix}admin_help " + irc.notice(i.nickname, m) + m = f"Available commands are: {avail_cmd}" + irc.notice(i.nickname, m) + return + + for line in cmd_help_list: + irc.notice(i.nickname, line) + + +def main(i, irc): + func_d = { + "join": join, + "part": part, + "privmsg": privmsg, + "notice": notice, + "acl_add": acl_add, + "acl_del": acl_del, + "acl_list": acl_list, + "mod_import": mod_import, + "mod_whitelist_add": mod_whitelist_add, + "mod_blacklist_add": mod_blacklist_add, + "mod_whitelist_del": mod_whitelist_del, + "mod_blacklist_del": mod_blacklist_del, + "mod_list": mod_list, + "mod_global_prefix_set": mod_global_prefix_set, + "mod_channel_prefix_set": mod_channel_prefix_set, + "admin_help": admin_help + } + func_d[i.cmd](i, irc) diff --git a/src/irc/modules/clock.py b/src/irc/modules/clock.py new file mode 100644 index 0000000..78d85b0 --- /dev/null +++ b/src/irc/modules/clock.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# Location module for drastikbot. +# +# It uses the geonames.org API to get time information about a +# country/city/etc. +# +# Issues: geonames.org is slow and not very reliable, consider +# rewriting it to use another API or an offline method. +# +# You may need to provide your own geonames.org username if the one +# provided doesn't work. + +''' +Copyright (C) 2018 drastik (https://github.com/olagood) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +any later version. + +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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +''' + +import urllib.parse + +import requests + + +class Module(): + def __init__(self): + self.commands = ["time"] + self.manual = { + "desc": "Get time information about a country, a city, or a state", + "bot_commands": { + "time": {"usage": lambda x: f"{x}time "} + } + } + + +username = "bugmenotuser" + + +def location_info_from_name(query): + api_url = ("http://api.geonames.org/searchJSON?" + f"q={query}&maxRows=1&username={username}") + r = requests.get(api_url, timeout=30) + try: + return r.json()["geonames"][0] + except IndexError: + return False + + +def get_timezone_from_name(query): + j = location_info_from_name(query) + if not j: + return f'Time: "{query}" is not a valid location' + + lng = j['lng'] + lat = j['lat'] + name = j['name'] + + api_url = ("http://api.geonames.org/timezoneJSON?" + f"lat={lat}&lng={lng}&username={username}") + r = requests.get(api_url, timeout=30) + j = r.json() + + try: + gmtOffset = j['gmtOffset'] + countryName = j['countryName'] + time = j['time'] + except KeyError: + return f'Time: "{query}" is not a valid location' + + ret = f"Time in {name}, {countryName}: {time} GMT {gmtOffset}" + return ret + + +def main(i, irc): + query = urllib.parse.quote_plus(i.msg_nocmd) + irc.privmsg(i.channel, get_timezone_from_name(query)) diff --git a/src/irc/modules/coinmarketcap.py b/src/irc/modules/coinmarketcap.py new file mode 100644 index 0000000..0d08133 --- /dev/null +++ b/src/irc/modules/coinmarketcap.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# Cryptocurrency price fetcher for Drastikbot that uses +# https://coinmarketcap.com/api/ + +''' +Copyright (C) 2018 drastik.org + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +any later version. + +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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +''' + +import requests + + +class Module: + def __init__(self): + self.commands = ['coin', 'cmc'] + self.helpmsg = [ + "Usage: .coin [-p, --pair ]", + " ", + "Get cryptocurrency prices from https://coinmarketcap.com", + "The --pair option allows you to also get a Fiat price of the", + "specified cryptocurrency.", + "Examples: .coin BTC", + " .cmc LTC", + " .coin ETH -p EUR"] + + +def fiat2usd_fetch(fiat): + f = fiat.upper() + u = f"https://api.fixer.io/latest?base={f}&symbols=USD" + r = requests.get(u, timeout=5) + try: + j = r.json()["rates"]["USD"] + except KeyError: + return False + return j + + +def cmc_fetch(c, p): + ''' + Use coinmarketcap.com API to get the required values. + + Returns: + 1 : Cryptocurrency requested not found. + 2 : Fiat requested not found. + Tuple : Tuple with the needed values. + ''' + coin = c.upper() + url = f"https://api.coinmarketcap.com/v1/ticker/?convert={p}&limit=0" + r = requests.get(url, timeout=5) + for i in r.json(): + if not (coin == i["symbol"] or coin == i["name"].upper()): + continue + name = i["name"] + sym = i["symbol"] + try: + fiat = i[f'price_{p.lower()}'] + except KeyError: + return 2 + btc = i["price_btc"] + c24 = i["percent_change_24h"] + if "-" in c24: + c24 = f"\x0304{c24} %\x0F" + else: + c24 = f"\x0303{c24} %\x0F" + c7d = i["percent_change_7d"] + if "-" in c7d: + c7d = f"\x0304{c7d} %\x0F" + else: + c7d = f"\x0303{c7d} %\x0F" + cap = "{:,}".format(float(i[f"market_cap_{p.lower()}"])) + cap = f"{cap} {p.upper()}" + t_sup = i["total_supply"] + p_usd = i["price_usd"] + return (name, sym, fiat, btc, cap, c24, c7d, t_sup, p_usd) + else: + return 1 + + +def query(args): + # Get the args list and the commands + # Join the list to a string and return + _args = args[:] + cmds_args = ['--pair', '-p'] + if '--pair' in args or '-p' in args: + try: + idx = args.index('--pair') + except ValueError: + idx = args.index('-p') + f = args[idx + 1] + else: + f = 'usd' + for i in cmds_args: + try: + idx = _args.index(i) + del _args[idx] + del _args[idx] + except ValueError: + pass + _args = ' '.join(_args) + return (_args, f) + + +def main(i, irc): + args = i.msg_nocmd.split() + q = query(args) + coin_ls = q[0].split() + if len(coin_ls) != 1: + # If no coin is given, send a help message. + return irc.privmsg(i.channel, + f"Usage: {i.cmd_prefix}{i.cmd} " + "[-p, --pair ]" + f" | e.g. {i.cmd_prefix}{i.cmd} BTC") + coin = coin_ls[0] + pair = q[1] + res = cmc_fetch(coin, pair) + # Invalid input error handling. + if res == 1: + return irc.privmsg(i.channel, f"\x0311{coin}\x0F is not an available " + "cryptocurrency.") + elif res == 2: + return irc.privmsg(i.channel, f"Pair \x0311{pair}\x0F not found. " + "Available Pairs: [All cryptocurrencies] + AUD, " + "BRL, CAD, CHF, CLP, CNY, CZK, DKK, EUR, GBP, HKD, " + "HUF, IDR, ILS, INR, JPY, KRW, MXN, MYR, NOK, NZD, " + "PHP, PKR, PLN, RUB, SEK, SGD, THB, TRY, TWD, ZAR") + if pair.lower() != 'usd': + f2u = fiat2usd_fetch(pair) + if not f2u: + f2u = cmc_fetch(pair, 'usd')[2] + rel_p = "{:,}".format(float(f2u) * float(res[2])) + rel_p = f"\x02Relative price to USD:\x0F \x0311${rel_p} USD\x0F | " + else: + rel_p = "" + # Main flow. + p1po = "{:,}".format(float(res[7]) * 0.01 * float(res[8])) + p1po = f"\x02Price of 1% ownership:\x0F \x0311${p1po} USD\x0F | " + price = "{:,}".format(float(res[2])) + irc.privmsg(i.channel, f"\x0311{res[0]} ({res[1]})\x0F: " + f"\x02Price\x0F: \x0311{price} {pair.upper()}\x0F" + f" , \x0311{res[3]} BTC\x0F" + f" | {rel_p} {p1po}" + f"\x02Market Cap\x0F: \x0311${res[4]}\x0F" + f" | \x02Change (24h)\x0F: {res[5]}" + f" | \x02Change (7d)\x0F: {res[6]}") diff --git a/src/irc/modules/coronachan.py b/src/irc/modules/coronachan.py new file mode 100644 index 0000000..0938131 --- /dev/null +++ b/src/irc/modules/coronachan.py @@ -0,0 +1,39 @@ +import requests +from bs4 import BeautifulSoup + +url = 'https://www.worldometers.info/coronavirus/' + + +class Module: + def __init__(self): + self.commands = ['corona', 'coronachan'] + self.manual = { + "desc": "Useful stats related to 2019-nCov", + "bot_commands": { + "corona": {"usage": ".corona", + "alias": ["coronachan"]}, + "coronachan": {"usage": ".coronachan", + "alias": ["corona"]} + } + } + + +def main(i, irc): + page = requests.get(url) + soup = BeautifulSoup(page.text, "html.parser") + cases = extract_from_page("Coronavirus Cases:", soup) + deaths = extract_from_page("Deaths:", soup) + recovered = extract_from_page("Recovered:", soup) + + irc.privmsg( + i.channel, + (f"\x02Total cases:\x0F {cases}" + f" | \x02Dead:\x0F \x0304{deaths}\x0F" + f" | \x02Recovered:\x0F \x0303{recovered}\x0F" + f" | \x02Source:\x0F {url}") + ) + + +def extract_from_page(text, soup): + return soup.find(text=text).parent.parent.find( + "div", {"class": "maincounter-number"}).find("span").text diff --git a/src/irc/modules/dice.py b/src/irc/modules/dice.py new file mode 100644 index 0000000..09ea72c --- /dev/null +++ b/src/irc/modules/dice.py @@ -0,0 +1,68 @@ +# coding=utf-8 + +# Virtual D&D style dice rolling + +''' +Copyright (c) 2020 Tekdude +Copyright (c) 2021 drastik + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +''' + +import random + +class Module: + def __init__(self): + self.commands = ['roll'] + self.manual = { + "desc": "Virtual D&D style dice rolling.", + "bot_commands": { + "roll": { + "usage": lambda x: f"{x}roll d", + "info": ("Rolls number of virtual dice, each with" + " number of sides and returns the result" + ". Example: \".roll 2d6\" rolls two six sided" + " dice.")} + } + } + + +def main(i, irc): + values = i.msg_nocmd.split('d') + if len(values) != 2: + irc.notice(i.channel, f"Usage: {i.cmd_prefix}roll d") + return + + n_dice = int(values[0]) + n_sides = int(values[1]) + + if n_dice > 1000 or n_sides > 1000: + irc.notice(i.channel, f"{i.nickname}: Too many dice or sides given.") + return + + results = [random.randint(1, n_sides) for i in range(n_dice)] + + limit = 15 # ToDo: Let chanops choose the limit + if len(results) > limit: + results_p = ", ".join(map(str, results[:limit])) + "..." + else: + results_p = ", ".join(map(str, results)) + + m = (f"{i.nickname} rolled {n_dice} {'die' if n_dice == 1 else 'dice'}" + f" with {n_sides} sides: {results_p} (Total: {sum(results)})") + + irc.privmsg(i.channel, m) diff --git a/src/irc/modules/events.py b/src/irc/modules/events.py new file mode 100644 index 0000000..e703839 --- /dev/null +++ b/src/irc/modules/events.py @@ -0,0 +1,181 @@ +# coding=utf-8 + +# This is core module for Drastikbot. +# It handles events such as JOIN, PART, QUIT, NICK, MODE and updates irc.py's +# variables for use by the other modules. + +''' +Copyright (C) 2018-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 . +''' + + +class Module: + def __init__(self): + self.msgtypes = ['JOIN', 'QUIT', 'PART', 'MODE', '353', + '324'] + self.auto = True + + +def dict_prep(irc, msg): + ''' + Prepare 'irc.var.namesdict' + This function inserts a new key with the channels name in + 'irc.var.namesdict' for the other functions to use. + ''' + # Verify that this is indeed a channel before adding it to 'namesdict'. + # This is so that we can avoid adding the users (or services) that send + # privmsges with to bot. + chan_prefix_ls = ['#', '&', '+', '!'] + if msg.channel[0] not in chan_prefix_ls: + return + + if msg.channel not in irc.var.namesdict: + irc.var.namesdict[msg.channel] = [[], {}] + + +def _rpl_namreply_353(irc, msg): + dict_prep(irc, msg) + namesdict = irc.var.namesdict[msg.channel] + namesdict[0] = [msg.cmd_ls[2]] + modes = ['~', '&', '@', '%', '+'] + for i in msg.msg_params.split(): + if i[:1] in modes: + namesdict[1][i[1:]] = [i[:1]] + else: + namesdict[1][i] = [] + irc.send(('MODE', msg.channel)) # Reply handled by rpl_channelmodeis + + +def _rpl_channelmodeis_324(irc, msg): + '''Handle reply to: "MODE #channel" to save the channel modes''' + channel = msg.cmd_ls[2] + m = list(msg.cmd_ls[3][1:]) + for idx, mode in reversed(list(enumerate(m))): + irc.var.namesdict[channel][0].append(mode) + + +def _join(irc, msg): + try: + dict_prep(irc, msg) + irc.var.namesdict[msg.channel][1][msg.nickname] = [] + except KeyError: + # This occures when first joining a channel for the first time. + # We take advantage of this to efficiently: + # Get the hostmask and call a function to calculate and set + # the irc.var.msg_len variable. + if msg.nickname == irc.var.curr_nickname: + nick_ls = (msg.nickname, msg.username, msg.hostname) + irc.var.bot_hostmask = msg.hostname + irc.set_msg_len(nick_ls) + + +def _part(irc, msg): + try: + del irc.var.namesdict[msg.channel][1][msg.nickname] + except KeyError: + # This should not be needed now that @rpl_namreply() + # is fixed, but the exception will be monitored for + # possible future reoccurance, before it is removed. + irc.var.log.debug('KeyError @Events.irc_part(). Err: 01') + + +def _quit(irc, msg): + for chan in irc.var.namesdict: + if msg.nickname in irc.var.namesdict[chan][1]: + del irc.var.namesdict[chan][1][msg.nickname] + + +def _nick(irc, msg): + for chan in irc.var.namesdict: + try: + k = irc.var.namesdict[chan][1] + k[msg.params] = k.pop(msg.nickname) + except KeyError: + # This should not be needed now that @rpl_namreply() + # is fixed, but the exception will be monitored for + # possible future reoccurance, before it is removed. + irc.var.log.debug('KeyError @Events.irc_part(). Err: 01') + + +def user_mode(irc, msg): + # MODE used on a user + m_dict = {'q': '~', 'a': '&', 'o': '@', 'h': '%', 'v': '+'} + channel = msg.cmd_ls[1] + m = msg.cmd_ls[2] # '+ooo' or '-vvv' + modes = list(m[1:]) # [o,o,o,o] + if m[:1] == '+': # Add (+) modes + for idx, mode in reversed(list(enumerate(modes))): + nick = msg.cmd_ls[3+idx] + try: + irc.var.namesdict[channel][1][nick].append( + m_dict[mode]) + except KeyError: + # This should not be needed now that @rpl_namreply() + # is fixed, but the exception will be monitored for + # possible future reoccurance, before it is removed. + irc.var.log.debug('KeyError @Events.irc_mode(). Err: 01') + irc.var.namesdict[channel][1].update({nick: modes[idx]}) + elif m[:1] == '-': # Remove (-) modes + for i, e in reversed(list(enumerate(modes))): + try: + irc.var.namesdict[channel][1][msg.cmd_ls[3+i]].remove( + m_dict[modes[i]]) + except Exception: + irc.var.log.debug('AttributeError @Events.irc_mode(). ' + 'Err: 02') + # Quick hack for to avoid crashes in cases where a mode + # doesnt use a nickname. (For instance setting +b on a + # hostmask). Should be properly handled, after this + # method gets broken into smaller parts. + + +def channel_mode(irc, msg): + # MODE used on a channel + channel = msg.cmd_ls[1] + m = msg.cmd_ls[2] # '+ooo' or '-vvv' + modes = list(m[1:]) # [o,o,o,o] + if m[:1] == '+': # Add (+) modes + for idx, mode in reversed(list(enumerate(modes))): + irc.var.namesdict[channel][0].append(mode) + elif m[:1] == '-': # Remove (-) modes + for idx, mode in reversed(list(enumerate(modes))): + irc.var.namesdict[channel][0].remove(mode) + + +def _mode(irc, msg): + dict_prep(irc, msg) + if len(msg.cmd_ls) > 3: + user_mode(irc, msg) + elif msg.cmd_ls[1] == irc.var.curr_nickname: + # Set the bot's server modes. + irc.var.botmodes.extend( + list(msg.msg_ls[3].replace("+", "").replace(":", ""))) + else: + channel_mode(irc, msg) + + +def main(i, irc): + d = { + '353': _rpl_namreply_353, + '324': _rpl_channelmodeis_324, + 'JOIN': _join, + 'PART': _part, + 'QUIT': _quit, + 'NICK': _nick, + 'MODE': _mode + } + d[i.msgtype](irc, i) diff --git a/src/irc/modules/help.py b/src/irc/modules/help.py new file mode 100644 index 0000000..2d3d39c --- /dev/null +++ b/src/irc/modules/help.py @@ -0,0 +1,203 @@ +# coding=utf-8 + +# Help Module for drastikbot modules. +# +# This module provides help messages for the loaded drastikbot modules. +# +# Usage +# ----- +# Calling the command without arguments returns a list of all the +# loaded modules with help information. Example: .help +# +# Giving the name of a module as an argument returns the available +# bot_commands of that module. Example: .help text +# +# Giving the name of a module followed by one of its bot_commands +# returns a help message of that command: Example: .help text ae +# +# API +# --- +# To provide help messages through this module other modules must +# include the manual variable in their Module class. If no such +# variable is provided the module will be unlisted. + + +# Copyright (C) 2021 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 . + + +import itertools + + +class Module: + def __init__(self): + self.commands = ["help"] + + +def get_module_object(i, module_name): + # drastikbot v2.2 + if hasattr(i, "mod"): + for mod_object, mod_path in i.mod["modules_d"].values(): + if mod_path.stem == module_name: + return mod_object + + # drastikbot v2.1 + if hasattr(i, "modules") and module_name in i.modules: + return i.modules[module_name] + + return None + + +def hidden_status(i, module_name: str) -> bool: + """Is ``module'' a hidden module?""" + + module_object = get_module_object(i, module_name) + if module_object is None: + return True # Module not found + + if not hasattr(module_object, "Module"): + return True # No Module class, it's hidden + + if hasattr(module_object.Module(), "manual"): + return module_object.Module().manual + elif hasattr(module_object.Module(), "helpmsg"): # Old method + if "__hidden__" in module_object.Module().helpmsg: + return "hidden" + + +def is_hidden(i, module_name): + return hidden_status(i, module_name) == "hidden" + + +def module_checks(i, irc, module): + if module not in i.modules.keys(): + irc.notice(i.channel, f"Help: `{module}' is not an imported module.") + return False + + try: + module_bl = irc.var.modules_obj["blacklist"][module] + if module_bl and i.channel in module_bl: + irc.notice(i.channel, f"Help: This module has been disabled.") + return False + except KeyError: + pass # No blacklist, move on + + try: + module_wl = irc.var.modules_obj["whitelist"][module] + if module_wl and i.channel not in module_wl: + irc.notice(i.channel, f"Help: This module has been disabled.") + return False + except KeyError: + pass # No whitelist, move on + + module_c = i.modules[module].Module() + if not hasattr(module_c, "manual"): + irc.notice(i.channel, "Help: This module does not have a manual.") + return False + + return True + +def module_help(i, irc, module): + if not module_checks(i, irc, module): + return + + module_c = i.modules[module].Module() + + commands = "" + if hasattr(module_c, "commands"): + commands = ", ".join(module_c.commands) + commands = f"Commands: {commands} | " + + info = "" + if "desc" in module_c.manual: + info = module_c.manual["desc"] + info = f"Info: {info}" + + t = f"\x0311{module}\x0F: {commands}{info}" + t += f" | Use: {i.cmd_prefix}help for command info." + irc.notice(i.channel, t) + + +def command_help(i, irc, module, command): + if not module_checks(i, irc, module): + return + + module_c = i.modules[module].Module() + + if not hasattr(module_c, "commands"): + irc.notice(i.channel, "Help: This module does not provide commands.") + return + + if "bot_commands" not in module_c.manual: + irc.notice(i.channel, "Help: No manual entry for this command ") + return + + command_manual = module_c.manual["bot_commands"] + + if command not in command_manual: + irc.notice(i.channel, "Help: No manual entry for this command.") + return + + command_entry = command_manual[command] + + t = [] + + if "usage" in command_entry: + usage = command_entry["usage"](i.cmd_prefix) + usage = f"Usage: {usage}" + t.append(usage) + + if "info" in command_entry: + info = command_entry["info"] + info = f"Info: {info}" + t.append(info) + + if "alias" in command_entry: + alias = ", ".join(command_entry["alias"]) + alias = f"Aliases: {alias}" + t.append(alias) + + t = " | ".join(t) + t = f"{command}: {t}" + irc.notice(i.channel, t) + + +def module_list(i, irc): + m1 = filter(lambda x: not is_hidden(i, x) and i.whitelist(x, i.channel) \ + and not i.blacklist(x, i.channel), + set(i.command_dict.values())) + m2 = filter(lambda x: not is_hidden(i, x) and i.whitelist(x, i.channel) \ + and not i.blacklist(x, i.channel) and not x in m1, + i.auto_list) + m = itertools.chain(m1, m2) + t = "Help: " + ", ".join(sorted(m)) + t += f" | Use: {i.cmd_prefix}help for module info." + irc.notice(i.channel, t) + + +def main(i, irc): + if i.msg_nocmd: + argv = i.msg_nocmd.split() + argc = len(argv) + if argc == 1: + module_help(i, irc, argv[0]) + elif argc == 2: + command_help(i, irc, argv[0], argv[1]) + else: + m = f"Usage: {i.cmd_prefix}help [module] [command]" + irc.notice(i.channel, m) + else: + module_list(i, irc) diff --git a/src/irc/modules/ignore.py b/src/irc/modules/ignore.py new file mode 100644 index 0000000..bfee136 --- /dev/null +++ b/src/irc/modules/ignore.py @@ -0,0 +1,211 @@ +# coding=utf-8 + +# This is core module for Drastikbot. +# It provides an ignore list to other drastikbot modules and +# an interface that allows the users to add or remove other users +# from the ignore list. + +''' +Copyright (C) 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 . +''' + +import json +from user_auth import user_auth + + +class Module: + def __init__(self): + self.commands = ["ignore", "unignore", "ignored", "ignore_mode"] + self.helpmsg = [ + "Usage: .ignore ", + " .unignore ", + " .ignored", + " .ignore_mode ", + " ", + "Ignore a list of users. The will not be able to use your", + "nickname in the supported modules/commands.", + " ", + ".ignored : The bot will PM you a list of your ignored users.", + ".ignore_mode : :", + " ignore_all: to ignore everyone.", + " registered_only: to ignore unidentified users.", + " : True / False", + "Example: .ignore_mode registered_only true" + ] + + +logo = "\x02ignore\x0F" + + +def is_ignored(i, irc, user_nick, ignore_nick): + dbc = i.db[1].cursor() + try: + dbc.execute("SELECT settings FROM ignore WHERE user = ?;", + (user_nick,)) + j = dbc.fetchone()[0] + o = json.loads(j) + if o["ignore_all"]: + return True + elif o["registered_only"] and not user_auth(i, irc, ignore_nick): + return True + elif ignore_nick in o["ignored"]: + return True + else: + return False + except Exception: + return False + + +def ignore(dbc, user_nick, ignore_nick): + try: + dbc.execute("SELECT settings FROM ignore WHERE user = ?;", + (user_nick,)) + j = dbc.fetchone()[0] + o = json.loads(j) + if ignore_nick not in o["ignored"]: + o["ignored"].append(ignore_nick) + else: + return (f"{logo}: \x0311{ignore_nick}\x0F" + " is already in your ignore list.") + j = json.dumps(o) + dbc.execute("UPDATE ignore SET settings = ? WHERE user = ?;", + (j, user_nick)) + return (f"{logo}: \x0311{ignore_nick}\x0F " + "was added to your ignore list.") + except Exception: + return (f"{logo}: An error occured while adding the user {ignore_nick}" + " to your ignore list") + + +def unignore(dbc, user_nick, unignore_nick): + try: + dbc.execute("SELECT settings FROM ignore WHERE user = ?;", + (user_nick,)) + j = dbc.fetchone()[0] + o = json.loads(j) + o["ignored"].remove(unignore_nick) + j = json.dumps(o) + dbc.execute("UPDATE ignore SET settings = ? WHERE user = ?;", + (j, user_nick)) + return (f"{logo}: \x0311{unignore_nick}\x0F" + " was removed from your ignore list.") + except Exception: + return (f"{logo}: \x0311{unignore_nick}\x0F" + " does not exist in your ignore list.") + + +def ignored(dbc, user_nick): + try: + dbc.execute("SELECT settings FROM ignore WHERE user = ?;", + (user_nick,)) + j = dbc.fetchone()[0] + o = json.loads(j) + ret = "" + for i in o["ignored"]: + ret = f"{ret}{i}, " + return f'{logo}: {len(o["ignored"])} : {ret[:-2]}' + except Exception: + return f"{logo}: There are no ignored users." + + +def registered_only(dbc, user_nick, value): + try: + dbc.execute("SELECT settings FROM ignore WHERE user = ?;", + (user_nick,)) + j = dbc.fetchone()[0] + o = json.loads(j) + o["registered_only"] = value + j = json.dumps(o) + dbc.execute("UPDATE ignore SET settings = ? WHERE user = ?;", + (j, user_nick)) + return f'{logo}: registered_only mode set to "{value}".' + except Exception: + return ("{logo}: An error occured while " + "changing the registered_only mode.") + + +def ignore_all(dbc, user_nick, value): + try: + dbc.execute("SELECT settings FROM ignore WHERE user = ?;", + (user_nick,)) + j = dbc.fetchone()[0] + o = json.loads(j) + o["ignore_all"] = value + j = json.dumps(o) + dbc.execute("UPDATE ignore SET settings = ? WHERE user = ?;", + (j, user_nick)) + return f'{logo}: ignore_all mode set to "{value}".' + except Exception: + return "{logo}: An error occured while changing the ignore_all mode." + + +def main(i, irc): + dbc = i.db[1].cursor() + dbc.execute("CREATE TABLE IF NOT EXISTS ignore " + "(user TEXT COLLATE NOCASE PRIMARY KEY, " + "settings TEXT COLLATE NOCASE);") + dbc.execute( + "INSERT OR IGNORE INTO ignore VALUES (?, ?);", + (i.nickname, + '{"ignored": [], "registered_only": false, "ignore_all": false}')) + + args = i.msg_nocmd.split() + + if i.cmd == "ignore" and len(args) == 1: + if i.msg_nocmd.lower() == i.nickname.lower(): + return irc.privmsg(i.channel, + f"{logo}: You cannot ignore yourself.") + elif i.msg_nocmd.lower() == irc.var.curr_nickname.lower(): + return irc.privmsg(i.channel, + f"{logo}: Ignoring the bot has no effect.") + return irc.privmsg(i.channel, ignore(dbc, i.nickname, i.msg_nocmd)) + elif i.cmd == "unignore" and len(args) == 1: + return irc.privmsg(i.channel, unignore(dbc, i.nickname, i.msg_nocmd)) + elif i.cmd == "ignored" and not i.msg_nocmd: + return irc.privmsg(i.nickname, ignored(dbc, i.nickname)) + elif i.cmd == "ignore_mode" and i.msg_nocmd: + if args[0] == "registered_only": + if args[1].lower() == "true": + return irc.privmsg(i.channel, + registered_only(dbc, i.nickname, True)) + elif args[1].lower() == "false": + return irc.privmsg(i.channel, + registered_only(dbc, i.nickname, False)) + else: + return irc.privmsg( + i.channel, + "Usage: .ignore_mode registered_only < true || false >") + elif args[0] == "ignore_all": + if args[1].lower() == "true": + return irc.privmsg(i.channel, + ignore_all(dbc, i.nickname, True)) + elif args[1].lower() == "false": + return irc.privmsg(i.channel, + ignore_all(dbc, i.nickname, False)) + else: + return irc.privmsg( + i.channel, + "Usage: .ignore_mode ignore_all < true || false >") + else: + return irc.privmsg( + i.channel, + 'Use ".help ignore" to learn how to use this command.') + else: + return irc.privmsg( + i.channel, + 'Use ".help ignore" to learn how to use this command.') + i.db[1].commit() diff --git a/src/irc/modules/information.py b/src/irc/modules/information.py new file mode 100644 index 0000000..13f84e3 --- /dev/null +++ b/src/irc/modules/information.py @@ -0,0 +1,43 @@ +# coding=utf-8 + +# This is a core module for Drastikbot. +# It returns information about the bot to it's users. + +''' +Copyright (C) 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 . +''' + + +class Module: + def __init__(self): + self.commands = ['bots', 'source'] + + +def main(i, irc): + if i.cmd == "bots": + m = (f"\x0305,01drastikbot {irc.var.version}\x0F" + " | \x0305Python 3.6\x0F" + " | \x0305GNU AGPLv3 ONLY\x0F" + " | \x0311http://drastik.org/drastikbot") + irc.privmsg(i.channel, m) + elif i.cmd == "source": + if not i.msg_nocmd or i.msg_nocmd == irc.var.curr_nickname: + m = ("\x0305,01drastikbot\x0F" + " : \x0311https://github.com/olagood/drastikbot\x0F" + " | \x0305,01Modules\x0F" + " : \x0311https://github.com/olagood/drastikbot_modules\x0F") + irc.privmsg(i.channel, m) diff --git a/src/irc/modules/kernel.py b/src/irc/modules/kernel.py new file mode 100644 index 0000000..826a5dd --- /dev/null +++ b/src/irc/modules/kernel.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# Quotes from various historical versions of the Linux kernel + +''' +Copyright (C) 2021 Flisk +Copyright (C) 2021 drastik.org + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +any later version. + +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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +''' + +import random + + +class Module: + def __init__(self): + self.commands = ['kernel'] + self.manual = { + "desc": ( + "Post quotes from various historical versions of the" + " Linux kernel http://www.schwarzvogel.de/software/misc.html" + ), + "bot_commands": { + "usage": lambda x: f"{x}kernel" + } + } + + +# Taken from the Kernelcookies fortune file curated by Tobias +# Klausmann. These are quotes from various historical versions of the +# Linux kernel. +talk = [ + "/* This card is _fucking_ hot... */", + # linux-2.6.6/drivers/net/sunhme.c + + "If you don't see why, please stay the fuck away from my code.", + # Rusty, in linux-2.6.6/Documentation/DocBook/kernel-locking.tmpl + + "/* Fuck, we are miserable poor guys... */", + # linux-2.6.6/net/xfrm/xfrm_algo.c + + "/* Ugly, ugly fucker. */", + # linux-2.6.6/include/linux/netfilter_ipv4/ipt_limit.h + + "/* Remember: \"Different name, same old buggy as shit hardware.\" */", + # linux-2.6.6/drivers/net/sunhme.c + + "/* This is total bullshit: */", + # linux-2.6.6/drivers/video/sis/init301.c + + "/* The HME is the biggest piece of shit I have ever seen. */", + # linux-2.6.6/drivers/scsi/esp.h + + "printk(\"WE HAVE A BUG HERE!!! stk=0x%p\\n\", stk);", + # linux-2.6.6/drivers/block/cciss_scsi.c + + "printk(\"%s: huh ? Who issued this format command ?\\n\")", + # linux-2.6.6/drivers/block/ps2esdi.c + + "printk(\"whoops, seeking 0\\n\");", + # linux-2.6.6/drivers/block/swim3.c + + "printk(\"GSCD: magic ...\\n\");", + # linux-2.6.6/drivers/cdrom/gscd.c + + "printk(\" (Read error)\"); /* Bitch about the problem. */", + # linux-2.6.6/drivers/cdrom/mcd.c + + "printk(\" Speed now 1x\"); /* Pull my finger! */", + # linux-2.6.6/drivers/cdrom/mcd.c + + "panic(\"Alas, I survived.\\n\");", + # linux-2.6.6/arch/ppc64/kernel/rtas.c + + "panic(\"smp_callin() AAAAaaaaahhhh....\\n\");", + # linux-2.6.6/arch/parisc/kernel/smp.c + + "panic(\"Yeee, unsupported cache architecture.\");", + # linux-2.6.6/arch/mips/mm/cache.c + + "panic(\"\\n\");", + # linux-2.6.6/arch/mips/tx4927/common/tx4927_irq.c + + "panic(\"%s called. This Does Not Happen (TM).\", __FUNCTION__);", + # linux-2.6.6/arch/mips/mm-64/tlb-dbg-r4k.c + + "printk (KERN_ERR \"%s: Oops - your private data area is hosed!\\n\", ...)", + # linux-2.6.6/drivers/net/ewrk3.c + + "rio_dprintk (RIO_DEBUG_ROUTE, \"LIES! DAMN LIES! %d LIES!\\n\",Lies);", + # linux-2.6.6/drivers/char/rio/rioroute.c + + "printk(\"NONONONOO!!!!\\n\");", + # linux-2.6.6/drivers/atm/zatm.c + + "printk(\"@#*$!!!! (%08x)\\n\", ...)", + # linux-2.6.6/drivers/atm/zatm.c + + "fs_dprintk (FS_DEBUG_INIT, \"Ha! Initialized OK!\\n\");", + # linux-2.6.6/drivers/atm/firestream.c + + "DPRINTK(\"strange things happen ...\\n\");", + # linux-2.6.6/drivers/atm/eni.c + + "printk(KERN_WARNING \"Hey who turned the DMA off?\\n\");", + # linux-2.6.6/drivers/net/wan/z85230.c + + "printk(KERN_DEBUG \"%s: BUG... transmitter died. Kicking it.\\n\",...)", + # linux-2.6.6/drivers/net/acenic.c + + "printk(KERN_ERR \"%s: Something Wicked happened! %4.4x.\\n\",...);", + # linux-2.6.6/drivers/net/sundance.c + + "DPRINTK(\"FAILURE, CAPUT\\n\");", + # linux-2.6.6/drivers/net/tokenring/ibmtr.c + + "Dprintk(\"oh dear, we are idle\\n\");", + # linux-2.6.6/drivers/net/ns83820.c + + "printk(KERN_DEBUG \"%s: burped during tx load.\\n\", dev->name)", + # linux-2.6.6/drivers/net/3c501.c + + "printk(KERN_DEBUG \"%s: I'm broken.\\n\", dev->name);", + # linux-2.6.6/drivers/net/plip.c + + "printk(\"%s: TDR is ga-ga (status %04x)\\n\", ...);", + # linux-2.6.6/drivers/net/eexpress.c + + "printk(\"3c505 is sulking\\n\");", + # linux-2.6.6/drivers/net/3c505.c + + "printk(KERN_ERR \"happy meal: Transceiver and a coke please.\");", + # linux-2.6.6/drivers/net/sunhme.c + + "printk(\"Churning and Burning -\");", + # linux-2.6.6/drivers/char/lcd.c + + "printk (KERN_DEBUG \"Somebody wants the port\\n\");", + # linux-2.6.6/drivers/parport/parport_pc.c + + "printk(KERN_WARNING MYNAM \": (time to go bang on somebodies door)\\n\");", + # linux-2.6.6/drivers/message/fusion/mptctl.c + + "printk(\"NULL POINTER IDIOT\\n\");", + # linux-2.6.6/drivers/media/dvb/dvb-core/dvb_filter.c + + "dprintk(5, KERN_DEBUG \"Jotti is een held!\\n\");", + # linux-2.6.6/drivers/media/video/zoran_card.c + + "printk(KERN_CRIT \"Whee.. Swapped out page in kernel page table\\n\");", + # linux-2.6.6/mm/vmalloc.c + + "printk(\"----------- [cut here ] --------- [please bite here ] ---------\\n\");", + # linux-2.6.6/arch/x86_64/kernel/traps. + + "printk (KERN_ALERT \"You are screwed! \" ...);", + # linux-2.6.6/arch/i386/kernel/efi.c + + "printk(\"you lose buddy boy...\\n\");", + # linux-2.6.6/arch/sparc/kernel/traps.c + + "printk (\"Barf\\n\");", + # linux-2.6.6/arch/v850/kernel/module.c + + "printk(KERN_EMERG \"PCI: Tell willy he's wrong\\n\");", + # linux-2.6.6/arch/parisc/kernel/pci.c + + "printk(KERN_ERR \"Danger Will Robinson: failed to re-trigger IRQ%d\\n\", irq);", + # linux-2.6.6/arch/arm/common/sa1111.c + + "printk(\"; crashing the system because you wanted it\\n\");", + # linux-2.6.6/fs/hpfs/super.c + + "panic(\"sun_82072_fd_inb: How did I get here?\");", + # linux-2.2.16/include/asm-sparc/floppy.h + + "printk(KERN_ERR \"msp3400: chip reset failed, penguin on i2c bus?\\n\");", + # linux-2.2.16/drivers/char/msp3400.c + + "die_if_kernel(\"Whee... Hello Mr. Penguin\", current->tss.kregs);", + # linux-2.2.16/arch/sparc/kernel/traps.c + + "die_if_kernel(\"Penguin instruction from Penguin mode??!?!\", regs);", + # linux-2.2.16/arch/sparc/kernel/traps.c + + "printk(\"Entering UltraSMPenguin Mode...\\n\");", + # linux-2.2.16/arch/sparc64/kernel/smp.c + + "panic(\"mother...\");", + # linux-2.2.16/drivers/block/cpqarray.c + + "panic(\"Foooooooood fight!\");", + # linux-2.2.16/drivers/scsi/aha1542.c + + "panic(\"huh?\\n\");", + # linux-2.2.16/arch/i386/kernel/smp.c + + "panic(\"Oh boy, that early out of memory?\");", + # linux-2.2.16/arch/mips/mm/init.c + + "panic(\"CPU too expensive - making holiday in the ANDES!\");", + # linux-2.2.16/arch/mips/kernel/traps.c + + "printk(\"Illegal format on cdrom. Pester manufacturer.\\n\"); ", + # linux-2.2.16/fs/isofs/inode.c + + "/* Fuck me gently with a chainsaw... */", + # linux-2.0.38/arch/sparc/kernel/ptrace.c + + "/* Binary compatibility is good American knowhow fuckin' up. */", + # linux-2.2.16/arch/sparc/kernel/sunos_ioctl.c + + "/* Am I fucking pedantic or what? */", + # linux-2.2.16/drivers/scsi/qlogicpti.h + + "panic(\"Aarggh: attempting to free lock with active wait queue - shoot Andy\");", + # linux-2.0.38/fs/locks.c + + "panic(\"bad_user_access_length executed (not cool, dude)\");", + # linux-2.0.38/kernel/panic.c + + "printk(\"ufs_read_super: fucking Sun blows me\\n\");", + # linux-2.0.38/fs/ufs/ufs_super.c + + "printk(\"autofs: Out of inode numbers -- what the heck did you do??\\n\"); ", + # linux-2.0.38/fs/autofs/root.c + + "HARDFAIL(\"Not enough magic.\");", + # linux-2.4.0-test2/drivers/block/nbd.c + + "#ifdef STUPIDLY_TRUST_BROKEN_PCMD_ENA_BIT", + # linux-2.4.0-test2/drivers/ide/cmd640.c + + "/* Fuck. The f-word is here so you can grep for it :-) */", + # linux-2.4.3/include/asm-mips/mmu_context.h + + "panic (\"No CPUs found. System halted.\\n\");", + # linux-2.4.3/arch/parisc/kernel/setup.c + + "printk(\"What? oldfid != cii->c_fid. Call 911.\\n\");", + # linux-2.4.3/fs/coda/cnode.c + + "printk(\"Cool stuff's happening!\\n\")", + # linux-2.4.3/fs/jffs/intrep.c + + "printk(\"MASQUERADE: No route: Rusty's brain broke!\\n\");", + # linux-2.4.3/net/ipv4/netfilter/ipt_MASQUERADE.c + + "printk(\"CPU[%d]: Sending penguins to jail...\",smp_processor_id());", + # linux-2.4.8/arch/sparc64/kernel/smp.c + + "printk(\"CPU[%d]: Giving pardon to imprisoned penguins\\n\", smp_processor_id());", + # linux-2.4.8/arch/sparc64/kernel/smp.c + + "printk (KERN_INFO \"NM256: Congratulations. You're not running Eunice.\\n\");", + # linux-2.6.19/sound/oss/nm256_audio.c + + "printk (KERN_ERR \"NM256: Fire in the hole! Unknown status 0x%x\\n\", ...);", + # linux-2.6.19/sound/oss/nm256_audio.c + + "printk(\"Pretending it's a 3/80, but very afraid...\\n\");", + # linux-2.6.19/arch/m68k/sun3x/prom.c + + "printk(\"IOP: oh my god, they killed the ISM IOP!\\n\");", + # linux-2.6.19/arch/m68k/mac/iop.c + + "printk(\"starfire_translate: Are you kidding me?\\n\");", + # linux-2.6.19/arch/sparc64/kernel/starfire.c + + "raw_printk(\"Oops: bitten by watchdog\\n\");", + # linux-2.6.19/arch/cris/arch-v32/kernel/time.c + + "prom_printf(\"No VAC. Get some bucks and buy a real computer.\");", + # linux-2.6.19/arch/sparc/mm/sun4c.c + + "printk(KERN_ERR \"happy meal: Eieee, rx config register gets greasy fries.\\n\");", + # linux-2.6.19/drivers/net/sunhme.c + + "dprintk(\"NFSD: laundromat service - starting\\n\");" + # linux-2.6.19/fs/nfsd/nfs4state.c +] + + +def main(i, irc): + msg = f"{i.nickname}: {random.SystemRandom().choice(talk)}" + irc.privmsg(i.channel, msg) diff --git a/src/irc/modules/lainstream.py b/src/irc/modules/lainstream.py new file mode 100644 index 0000000..4c5344c --- /dev/null +++ b/src/irc/modules/lainstream.py @@ -0,0 +1,488 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# Lainstream for Drastikbot +# +# Update and get information about lainchan radio and video streams. +# +# Depends: +# - requests :: $ pip3 install requests + +# © 2018-2021 All Rights Reserved olagood (drastik) + +import pickle +import xml.etree.ElementTree as ET +# from bs4 import BeautifulSoup +import requests + + +lainchan_org_logo = "\x0304Lainstream\x0F" +lainchan_org_logo_rtmp = "\x0304Lainstream\x0F RTMP" +lainchan_org_logo_ogv = "\x0304Lainstream\x0F OGV" + +lain_la_logo = "lain.la" + + +class Module: + def __init__(self): + self.commands = ["stream", + "radio", + # lainchan.org + "streamset", "streamset-rtmp", "streamset-ogv", + "streamerset", "streamerset-rtmp", "streamerset-ogv", + "streamunset", "streamunset-rtmp", "streamunset-ogv", + "streamurls", + # lain.la + "streamset-la", "streamerset-la", "streamunset-la"] + self.manual = { + "desc": "Stream and radio information for lainchan.org.", + "bot_commands": { + "stream": {"usage": lambda x: f"{x}stream", + "info": "Display the currently active streams."}, + "radio": {"usage": lambda x: f"{x}radio", + "info": "Station information for lainchan radio."}, + "streamset": { + "usage": lambda x: f"{x}streamset [title]", + "info": ("Set the stream title for lainchan's RTMP" + " stream. The streamer is also set to the" + " nickname of the caller. If no title is" + " provided, it unsets the stream."), + "alias": ["streamset-rtmp"]}, + "streamset-rtmp": { + "usage": lambda x: f"{x}streamset-rtmp [title]", + "info": ("Set the stream title for lainchan's RTMP" + " stream. The streamer is also set to the" + " nickname of the caller. If no title is" + " provided, it unsets the stream."), + "alias": ["streamset"]}, + "streamset-ogv": { + "usage": lambda x: f"{x}streamset-ogv [title]", + "info": ("Set the stream title for lainchan's OGV" + " stream. The streamer is also set to the" + " nickname of the caller. If no title is" + " provided, it unsets the stream.")}, + "streamerset": { + "usage": lambda x: f"{x}streamerset ", + "info": "Set the streamer for lainchan's RTMP stream", + "alias": ["streamerset-rtmp"]}, + "streamerset-rtmp": { + "usage": lambda x: f"{x}streamerset-rtmp ", + "info": "Set the streamer for lainchan's RTMP stream", + "alias": ["streamerset"]}, + "streamerset-ogv": { + "usage": lambda x: f"{x}streamerset-ogv ", + "info": "Set the streamer for lainchan's OGV stream"}, + "streamunset": { + "usage": lambda x: f"{x}streamunset", + "info": ("Unset the title and the streamer for lainchan's" + " RTMP stream."), + "alias": ["streamunset-rtmp"]}, + "streamunset-rtmp": { + "usage": lambda x: f"{x}streamunset-rtmp", + "info": ("Unset the title and the streamer for lainchan's" + " RTMP stream."), + "alias": ["streamunset"]}, + "streamunset-ogv": { + "usage": lambda x: f"{x}streamunset-ogv", + "info": ("Unset the title and the streamer for lainchan's" + " OGV stream.")}, + "streamurls": { + "usage": lambda x: f"{x}streamurls", + "info": "Show lainchan's RTMP, HLS and OGV stream urls."}, + "streamset-la": { + "usage": lambda x: f"{x}streamset-la [title]", + "info": ("Set the stream title for the lain.la" + " stream. The streamer is also set to the" + " nickname of the caller. If no title is" + " provided, it unsets the stream.")}, + "streamerset-la": { + "usage": lambda x: f"{x}streamerset ", + "info": "Set the streamer for lain.la's stream"}, + "streamunset-la": { + "usage": lambda x: f"{x}streamunset-la", + "info": ("Unset the title and the streamer for lain.la's" + " stream.")} + } + } + + +# ==================================================================== +# IRC: Arisu bot control +# ==================================================================== + +def arisu_streamerset_rtmp(irc, streamer): + irc.privmsg("Arisu", f"!streamerset {streamer}") + + +def arisu_streamset_rtmp(irc, title, streamer): + irc.privmsg("Arisu", f"!streamset {title}") + irc.privmsg("Arisu", f"!streamerset {streamer}") + + +def arisu_streamunset_rtmp(irc): + irc.privmsg("Arisu", "!streamunset") + irc.privmsg("Arisu", "!streamerunset") + + +def arisu_streamerset_ogv(irc, streamer): + irc.privmsg("Arisu", f"!streamerset --ogv {streamer}") + + +def arisu_streamset_ogv(irc, title, streamer): + irc.privmsg("Arisu", f"!streamset --ogv {title}") + irc.privmsg("Arisu", f"!streamerset --ogv {streamer}") + + +def arisu_streamunset_ogv(irc): + irc.privmsg("Arisu", "!streamunset --ogv") + irc.privmsg("Arisu", "!streamerunset --ogv") + + +# ==================================================================== +# Radio: lainon.life +# ==================================================================== + +# IRC callbacks + +def radio(state, i, irc): + m = "\x0306Lainchan Radio\x0F: " + urls = ("https://lainon.life/playlist/cyberia.json", + "https://lainon.life/playlist/cafe.json", + "https://lainon.life/playlist/everything.json", + "https://lainon.life/playlist/swing.json") + for url in urls: + channel = url.rsplit("/")[-1][:-5] + r = requests.get(url, timeout=10) + j = r.json() + # live = j['stream_data']['live'] + c_artist = j['current']['artist'] + c_title = j['current']['title'] + listeners = j['listeners']['current'] + m += (f"\x0305{channel} {listeners}\x0F:" + f" \x0302{c_artist} - {c_title}\x0F | https://lainon.life/") + irc.privmsg(i.channel, m) + + +# ==================================================================== +# Video: lainchan.org +# ==================================================================== + +# State functions + +def lainchan_org_rtmp_viewers(): + url = "https://lainchan.org:8080/stat" + r = requests.get(url, timeout=10) + xml_root = ET.fromstring(r.text) + viewers = 0 + count = 0 + for i in xml_root.iter("live"): + nclients = i.find("nclients") + viewers += int(nclients.text) + if viewers >= 2 and count == 0: + viewers -= 2 + count += 1 + return viewers + + +def lainchan_org_rtmp_status(state): + title = state["lainchan.org"]["rtmp"]["title"] + if not title: + return "" + viewers = lainchan_org_rtmp_viewers() + streamer = state["lainchan.org"]["rtmp"]["streamer"] + url = state["lainchan.org"]["url"] + rtmp_url = state["lainchan.org"]["rtmp"]["url"] + return (f"{lainchan_org_logo_rtmp}:" + f" \x0311{title}\x0F by \x0302{streamer}\x0F" + f" | Viewers: {viewers} | Watch at: {url} | RTMP: {rtmp_url}") + + +def lainchan_org_ogv_status(state): + title = state["lainchan.org"]["ogv"]["title"] + if not title: + return "" + streamer = state["lainchan.org"]["ogv"]["streamer"] + url = state["lainchan.org"]["url"] + ogv_url = state["lainchan.org"]["ogv"]["url"] + return (f"{lainchan_org_logo_ogv}:" + f" \x0311{title}\x0F by \x0302{streamer}\x0F" + f" | Watch at: {url} | OGV: {ogv_url}") + + +def lainchan_org_rtmp_set(state, title, streamer): + state["lainchan.org"]["rtmp"]["title"] = title + state["lainchan.org"]["rtmp"]["streamer"] = streamer + return state + + +def lainchan_org_ogv_set(state, title, streamer): + state["lainchan.org"]["ogv"]["title"] = title + state["lainchan.org"]["ogv"]["streamer"] = streamer + return state + + +# IRC callbacks + +def lainchan_org_stream_unset_rtmp(state, i, irc): + state = lainchan_org_rtmp_set(state, "", "") + m = f"{lainchan_org_logo_rtmp}: Stream information was unset!" + irc.privmsg(i.channel, m) + arisu_streamunset_rtmp(irc) + return state + + +def lainchan_org_stream_set_rtmp(state, i, irc): + streamer = i.nickname + title = i.msg_nocmd + + if not title: + return lainchan_org_stream_unset_rtmp(state, i, irc) + + state = lainchan_org_rtmp_set(state, title, streamer) + m = f"{lainchan_org_logo_rtmp}: Stream information updated!" + irc.privmsg(i.channel, m) + arisu_streamset_rtmp(irc, title, streamer) + return state + + +def lainchan_org_streamer_set_rtmp(state, i, irc): + streamer = i.msg_nocmd + title = state["lainchan.org"]["rtmp"]["title"] + + if not streamer: + m = (f"{lainchan_org_logo_rtmp}: Usage: " + f"{i.cmd_prefix}{i.cmd} ") + irc.privmsg(i.channel, m) + return + + if not title: + m = (f"{lainchan_org_logo_rtmp}: Set a title using " + f"`{i.cmd_prefix}{i.cmd}' first.") + irc.privmsg(i.channel, m) + return + + state = lainchan_org_rtmp_set(state, title, streamer) + m = f"{lainchan_org_logo_rtmp}: Streamer updated!" + irc.privmsg(i.channel, m) + arisu_streamerset_rtmp(irc, streamer) + return state + + +def lainchan_org_stream_unset_ogv(state, i, irc): + state = lainchan_org_ogv_set(state, "", "") + m = f"{lainchan_org_logo_ogv}: Stream information was unset!" + irc.privmsg(i.channel, m) + arisu_streamunset_ogv(irc) + return state + + +def lainchan_org_stream_set_ogv(state, i, irc): + streamer = i.nickname + title = i.msg_nocmd + + if not title: + return lainchan_org_stream_unset_ogv(state, i, irc) + + state = lainchan_org_ogv_set(state, title, streamer) + m = f"{lainchan_org_logo_ogv}: Stream information updated!" + irc.privmsg(i.channel, m) + arisu_streamset_ogv(irc, title, streamer) + return state + + +def lainchan_org_streamer_set_ogv(state, i, irc): + streamer = i.msg_nocmd + title = state["lainchan.org"]["ogv"]["title"] + + if not streamer: + m = (f"{lainchan_org_logo_ogv}: Usage: " + f"{i.cmd_prefix}{i.cmd} ") + irc.privmsg(i.channel, m) + return + + if not title: + m = (f"{lainchan_org_logo_ogv}: Set a title using " + f"`{i.cmd_prefix}{i.cmd}' first.") + irc.privmsg(i.channel, m) + return + + state = lainchan_org_ogv_set(state, title, streamer) + m = f"{lainchan_org_logo_ogv}: Streamer updated!" + irc.privmsg(i.channel, m) + arisu_streamerset_ogv(irc, streamer) + return state + + +def lainchan_org_stream_urls(state, i, irc): + rtmp = state["lainchan.org"]["rtmp"]["url"] + hls = state["lainchan.org"]["rtmp"]["url-hls"] + ogv = state["lainchan.org"]["ogv"]["url"] + m = (f"{lainchan_org_logo_rtmp}: {rtmp} / {hls}" + f" | {lainchan_org_logo_ogv}: {ogv}") + irc.privmsg(i.channel, m) + + +# ==================================================================== +# Video: lain.la +# ==================================================================== + +# State functions + +def lain_la_status(state): + title = state["lain.la"]["title"] + if not title: + return "" + streamer = state["lain.la"]["streamer"] + url = state["lain.la"]["url"] + rtmp_url = state["lain.la"]["url-rtmp"] + return (f"{lain_la_logo}:" + f" \x0311{title}\x0F by \x0302{streamer}\x0F" + f" | Watch at: {url} | RTMP: {rtmp_url}") + + +def lain_la_set(state, title, streamer): + state["lain.la"]["title"] = title + state["lain.la"]["streamer"] = streamer + return state + + +# IRC callbacks + +def lain_la_stream_unset(state, i, irc): + state = lain_la_set(state, "", "") + m = f"{lain_la_logo}: Stream information was unset!" + irc.privmsg(i.channel, m) + return state + + +def lain_la_stream_set(state, i, irc): + streamer = i.nickname + title = i.msg_nocmd + + if not title: + return lain_la_stream_unset(state, i, irc) + + state = lain_la_set(state, title, streamer) + m = f"{lain_la_logo}: Stream information updated!" + irc.privmsg(i.channel, m) + return state + + +def lain_la_streamer_set(state, i, irc): + streamer = i.msg_nocmd + title = state["lain.la"]["title"] + + if not streamer: + m = (f"{lain_la_logo}: Usage: " + f"{i.cmd_prefix}{i.cmd} ") + irc.privmsg(i.channel, m) + return + + if not title: + m = (f"{lain_la_logo}: Set a title using " + f"`{i.cmd_prefix}{i.cmd}' first.") + irc.privmsg(i.channel, m) + return + + state = lain_la_set(state, title, streamer) + m = f"{lain_la_logo}: Streamer updated!" + irc.privmsg(i.channel, m) + return state + + +# ==================================================================== +# IRC: Generic callbacks +# ==================================================================== + +def stream(state, i, irc): + stream_status_list = [ + lainchan_org_rtmp_status(state), + lainchan_org_ogv_status(state), + lain_la_status(state) + ] + + if all([not i for i in stream_status_list]): + m = (f"{lainchan_org_logo}: Nothing is streaming right now." + " Learn how you can stream at: " + "https://lainchan.org/stream.html ") + irc.privmsg(i.channel, m) + + return + + for s in stream_status_list: + if s: + irc.privmsg(i.channel, s) + + +def stream_how(state, i, irc): + pass + + +# ==================================================================== +# Main +# ==================================================================== + +callbacks = { + "stream": stream, + # lainon.life / lainchan radio + "radio": radio, + # lainchan.org + "streamset": lainchan_org_stream_set_rtmp, + "streamset-rtmp": lainchan_org_stream_set_rtmp, + "streamset-ogv": lainchan_org_stream_set_ogv, + "streamerset": lainchan_org_streamer_set_rtmp, + "streamerset-rtmp": lainchan_org_streamer_set_rtmp, + "streamerset-ogv": lainchan_org_streamer_set_ogv, + "streamunset": lainchan_org_stream_unset_rtmp, + "streamunset-rtmp": lainchan_org_stream_unset_rtmp, + "streamunset-ogv": lainchan_org_stream_unset_ogv, + "streamurls": lainchan_org_stream_urls, + # lain.la + "streamset-la": lain_la_stream_set, + "streamerset-la": lain_la_streamer_set, + "streamunset-la": lain_la_stream_unset +} + + +def main(i, irc): + state = { + "lainchan.org": { + "url": "https://lainchan.org/stream.html", + "ogv": { + "url": "https://lainchan.org/icecast/lainstream.ogg", + "title": "", + "streamer": "" + }, + "rtmp": { + "url": "rtmp://lainchan.org/show/stream", + "url-hls": "https://lainchan.org:8080/hls/stream.m3u8", + "title": "", + "streamer": "" + } + }, + "lain.la": { + "url": "https://stream.lain.la", + "url-rtmp": "rtmp://lain.la", + "title": "", + "streamer": "" + } + } + + db = i.db[1] + dbc = db.cursor() + try: + dbc.execute('''SELECT value FROM stream;''') + fetch = dbc.fetchone() + state = pickle.loads(fetch[0]) + except Exception: + dbc.execute('''CREATE TABLE IF NOT EXISTS stream (value BLOB);''') + + callbacks[i.cmd](state, i, irc) + + v = pickle.dumps(state) + try: + dbc.execute('''INSERT INTO stream (value) VALUES (?);''', (v,)) + except Exception: + pass + dbc.execute('''UPDATE stream SET value = ?''', (v,)) + db.commit() diff --git a/src/irc/modules/lastfm.py b/src/irc/modules/lastfm.py new file mode 100644 index 0000000..60e5174 --- /dev/null +++ b/src/irc/modules/lastfm.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# Last.fm Now Playing Module for Drastikbot +# +# It uses the Last.fm API to get the user's currently playing song. +# You need to provide your own API key to use this. +# +# Depends: +# - requests :: $ pip3 install requests + +''' +Copyright (C) 2018, 2021 drastik.org + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +''' + +import requests +from user_auth import user_auth + + +class Module(): # Request commands to be used by the module + def __init__(self): + self.commands = ['np', 'npset', 'npunset', 'npauth'] + self.manual = { + "desc": "Display your currently playing song using last.fm.", + "irc_commands": { + "np": {"usage": lambda x: f"{x}np [nickname]", + "info": "Show the song that is playing right now."}, + "npset": {"usage": lambda x: f"{x}npset ", + "info": "Set your last.fm username."}, + "npunset": {"usage": lambda x: f"{x}npunset", + "info": "Unset your last.fm username."}, + "npauth": {"usage": lambda x: f"{x}npauth", + "info": "Toggle NickServ authentication for npset"}, + } + } + + +# ----- Constants ----- # +API_KEY = 'Add you lastfm API here' +# --------------------- # + + +def set_auth(i, irc, dbc, irc_nick): + if not user_auth(i, irc, irc_nick): + return f'{irc_nick}: You are not logged in with NickServ.' + + dbc.execute('SELECT auth FROM lastfm WHERE irc_nick=?;', + (irc_nick,)) + fetch = dbc.fetchone() + + try: + auth = fetch[0] + except TypeError: # 'NoneType' object is not subscriptable + auth = 0 + + if auth == 0: + auth = 1 + msg = f'{irc_nick}: lastfm: Enabled NickServ authentication.' + elif auth == 1: + auth = 0 + msg = f'{irc_nick}: lastfm: Disabled NickServ authentication.' + + dbc.execute("INSERT OR IGNORE INTO lastfm (irc_nick, auth) VALUES (?, ?);", + (irc_nick, auth)) + dbc.execute("UPDATE lastfm SET auth=? WHERE irc_nick=?;", (auth, irc_nick)) + return msg + + +def get_auth(i, irc, dbc, irc_nick): + dbc.execute('SELECT auth FROM lastfm WHERE irc_nick=?;', + (irc_nick,)) + fetch = dbc.fetchone() + try: + auth = fetch[0] + except TypeError: # 'NoneType' object is not subscriptable + auth = 0 + + if auth == 0: + return True + elif auth == 1 and user_auth(i, irc, irc_nick): + return True + else: + return False + + +def set_user(i, irc, dbc, irc_nick, lfm_user): + if not get_auth(i, irc, dbc, irc_nick): + return f"{irc_nick}: lastfm: NickServ authentication is required." + dbc.execute( + '''INSERT OR IGNORE INTO lastfm (irc_nick, lfm_user) + VALUES (?, ?);''', (irc_nick, lfm_user)) + dbc.execute('''UPDATE lastfm SET lfm_user=? WHERE irc_nick=?;''', + (lfm_user, irc_nick)) + return f'{irc_nick}: Your last.fm username was set to "{lfm_user}"' + + +def unset_user(i, irc, dbc, irc_nick): + if not get_auth(i, irc, dbc, irc_nick): + return f"{irc_nick}: lastfm: NickServ authentication is required." + try: + dbc.execute('''DELETE FROM lastfm WHERE irc_nick=?;''', (irc_nick,)) + except Exception: + return f"{irc_nick}: You haven't set a last.fm username." + return f'{irc_nick}: Your last.fm username was unset.' + + +def get_user(dbc, irc_nick): + try: + dbc.execute('SELECT lfm_user FROM lastfm WHERE irc_nick=?;', + (irc_nick,)) + fetch = dbc.fetchone() + lfm_user = fetch[0] + return lfm_user + except Exception: + return False + + +def now_playing(dbc, irc_nick, same=False): + lfm_user = get_user(dbc, irc_nick) + if not lfm_user: + return False + url = ('https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks' + f'&user={lfm_user}&api_key={API_KEY}&format=json&limit=1') + try: + r = requests.get(url, timeout=10) + except Exception: + return + out = r.json()['recenttracks']['track'][0] + try: + artist = out['artist']['#text'] + song = out['name'] + album = out['album']['#text'] + except KeyError: + pass + try: + np = out['@attr']['nowplaying'] + except KeyError: + np = False + + if np == 'true': + if album: + ret = (f'\x0304{irc_nick}\x0F is listening to \x0304{song}\x0F by ' + f'\x0304{artist}\x0F from the album \x0304{album}\x0F.') + else: + ret = (f'\x0304{irc_nick}\x0F is listening to \x0304{song}\x0F by ' + f'\x0304{artist}\x0F.') + else: + if same: + ret = 'You are not playing anything right now.' + else: + ret = f'\x0304{irc_nick}\x0F is not playing anything right now.' + return ret + + +def main(i, irc): + dbc = i.db[1].cursor() + args = i.msg_nocmd.split() + + try: + dbc.execute( + '''CREATE TABLE IF NOT EXISTS lastfm (irc_nick TEXT COLLATE NOCASE + PRIMARY KEY, lfm_user TEXT, auth INTEGER DEFAULT 0);''') + except Exception: + # sqlite3.OperationalError: cannot commit - no transaction is active + pass + + if 'np' == i.cmd: + if not args: + np = now_playing(dbc, i.nickname, same=True) + if not np: + help_msg = (f"You haven't set your last.fm user yet. Set it " + f'with "{i.cmd_prefix}npset ".') + return irc.privmsg(i.channel, help_msg) + else: + return irc.privmsg(i.channel, np) + elif len(args) == 1: + np = now_playing(dbc, args[0]) + return irc.privmsg(i.channel, np) + else: + help_msg = 'Usage: .np [nickname]' + return irc.privmsg(i.channel, help_msg) + elif 'npset' == i.cmd: + if not args or len(args) > 1: + help_msg = 'Usage: .npset ' + return irc.privmsg(i.channel, help_msg) + else: + ret = set_user(i, irc, dbc, i.nickname, args[0]) + i.db[1].commit() + return irc.privmsg(i.channel, ret) + elif 'npunset' == i.cmd: + ret = unset_user(i, irc, dbc, i.nickname) + i.db[1].commit() + return irc.privmsg(i.channel, ret) + elif 'npauth' == i.cmd: + ret = set_auth(i, irc, dbc, i.nickname) + i.db[1].commit() + irc.privmsg(i.channel, ret) diff --git a/src/irc/modules/points.py b/src/irc/modules/points.py new file mode 100644 index 0000000..7dba739 --- /dev/null +++ b/src/irc/modules/points.py @@ -0,0 +1,87 @@ +# coding=utf-8 + +# Point (karma) counting module for Drastikbot +# +# Give points/karma to users for performing certain actions + +''' +Copyright (C) 2019 drastik.org + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +''' + + +class Module: + def __init__(self): + self.commands = ["points"] + self.auto = True + self.manual = { + "desc": "Count a user's GNU/Linux points.", + "bot_commands": { + "points": lambda x: f"{x}points", + "info": "Show your total amount of points." + } + } + + +def set_gnu_linux_points(dbc, nickname, points): + dbc.execute("CREATE TABLE IF NOT EXISTS points_gnu_linux " + "(nickname TEXT COLLATE NOCASE, points INTEGER);") + dbc.execute("INSERT OR IGNORE INTO points_gnu_linux VALUES (?, ?);", + (nickname, points)) + dbc.execute("UPDATE points_gnu_linux SET points=? WHERE nickname=?;", + (points, nickname)) + + +def get_gnu_linux_points(dbc, nickname): + try: + dbc.execute("SELECT points FROM points_gnu_linux WHERE nickname=?;", + (nickname,)) + return dbc.fetchone()[0] + except Exception: + return 0 + + +def gnu_linux_points_handler(dbc, nickname, mode=""): + try: + p = get_gnu_linux_points(dbc, nickname) + except Exception: + p = 0 + if "gnu" == mode: + p += 1 + elif "linux" == mode: + p -= 1 + set_gnu_linux_points(dbc, nickname, p) + + +def main(i, irc): + dbc = i.db[0].cursor() + + if i.cmd == "points": + gl_p = get_gnu_linux_points(dbc, i.nickname) + irc.privmsg(i.channel, f"GNU/Linux Points for {i.nickname}: {gl_p}") + + if i.channel == i.nickname: + return + + last_nick = i.varget("last_nick", defval=irc.var.nickname) + if last_nick == i.nickname: + return + else: + i.varset("last_nick", i.nickname) + + if "gnu/linux" in i.msg.lower() or "gnu+linux" in i.msg.lower(): + gnu_linux_points_handler(dbc, i.nickname, mode="gnu") + elif "linux" in i.msg.lower() and "linux kernel" not in i.msg.lower(): + gnu_linux_points_handler(dbc, i.nickname, mode="linux") diff --git a/src/irc/modules/quote.py b/src/irc/modules/quote.py new file mode 100644 index 0000000..62a165e --- /dev/null +++ b/src/irc/modules/quote.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# Quote Module for Drastikbot +# +# Save user quotes and send them to a channel upon request. + +''' +Copyright (C) 2019-2021 drastik.org + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +any later version. + +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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +''' + +import random +import sqlite3 + +import requests + +from admin import is_allowed + +logo = "\x02quote\x0F" + + +class Module: + def __init__(self): + self.commands = ['quote', 'findquote', 'addquote', 'delquote', + 'listquotes'] + self.manual = { + "desc": "Saves user quotes and posts them when requested.", + "bot_commands": { + "quote": { + "usage": lambda x: ( + f"[channels]: {x}quote [nickname/id/text]" + f" | [queries]: {x}quote <#channel> [nickname/id/text]" + ), + "info": ("Without any arguments, a random quote will be" + " posted. If arguments are given the will try to" + " first match a nickname, then an ID and then" + " the text of a quote.") + }, + "findquote": { + "usage": lambda x: ( + f"[channels]: {x}findquote " + f" | [queries]: {x}findquote <#channel> " + ), + "info": ("Try to match the given text to a quote." + " If a quote is found, it is posted.") + }, + "addquote": { + "usage": lambda x: f"{x}addquote ", + "info": "Add a quote to the database." + }, + "delquote": { + "usage": lambda x: f"{x}delquote ", + "info": "Delete a quote using its ID" + }, + "listquotes": { + "usage": lambda x: f"{x}listquotes <#channel>", + "info": ("List all the quotes in which the the caller is" + " mentioned in. The list is sent in a query.") + } + } + } + + +def add(channel, quote, made_by, added_by, dbc): + try: + dbc.execute("INSERT INTO quote(channel, quote, made_by, added_by) " + "VALUES (?, ?, ?, ?)", (channel, quote, made_by, added_by)) + return f"{logo}: #{dbc.lastrowid} Added!" + except sqlite3.IntegrityError: + return f"{logo}: This quote has already been added." + + +def delete(quote_id, dbc): + try: + dbc.execute('''DELETE FROM quote WHERE num=?;''', (quote_id,)) + return f"{logo}: #{quote_id} deleted." + except Exception: + pass + + +def find(channel, query, dbc, export_all=False): + try: + dbc.execute( + '''SELECT num FROM quote_index + WHERE quote_index MATCH ? AND channel=?;''', + (f"quote:{query}", channel)) + f = dbc.fetchall() + num = random.choice(f)[0] + dbc.execute('''SELECT * FROM quote WHERE num=?''', (num,)) + if export_all: + return dbc.fetchall() + f = dbc.fetchone() + return f"{logo}: #{f[0]} | {f[2]} \x02-\x0F {f[3]} | Added by {f[4]}" + except Exception: + if export_all: + return False + return f"{logo}: No results." + + +def _search_by_nick(channel, text, dbc, export_all=False): + try: + if not text: + dbc.execute('SELECT * FROM quote WHERE channel=?', (channel,)) + else: + dbc.execute('SELECT * FROM quote WHERE made_by=? AND channel=?;', + (text, channel)) + f = dbc.fetchall() + if export_all: + return f + f = random.choice(f) + return f"{logo}: #{f[0]} | {f[2]} \x02-\x0F {f[3]} | Added by {f[4]}" + except Exception: + return False + + +def _search_by_id(channel, text, dbc): + if not text.isdigit(): + return False + + try: + dbc.execute('SELECT * FROM quote WHERE num=? AND channel=?;', (text, channel)) + f = dbc.fetchone() + return f"{logo}: #{f[0]} | {f[2]} \x02-\x0F {f[3]} | Added by {f[4]}" + except Exception: + return False + + +def listquotes(channel, nickname, dbc, irc): + data = "" + + sbn = _search_by_nick(channel, nickname, dbc, export_all=True) + if sbn: + for f in sbn: + m = f"#{f[0]} | {f[2]} - {f[3]} | Added by {f[4]}\n\n" + data += m + + rest = find(channel, nickname, dbc, export_all=True) + if rest: + for f in rest: + m = f"#{f[0]} | {f[2]} - {f[3]} | Added by {f[4]}\n\n" + data += m + + if not rest and not sbn: + irc.notice(nickname, f"{logo}: No results.") + else: + pomf_url = listquotes_pomf(data) + m = f"{logo}: Your quotes can be found here: {pomf_url}" + irc.notice(nickname, m) + + +def listquotes_pomf(data): + url = "https://pomf.lain.la/upload.php" + files = {"files[]": ("quotes.txt", data, "text/plain")} + r = requests.post(url, files=files) + return r.json()["files"][0]["url"] + + + +def quote(channel, text, dbc): + sbn = _search_by_nick(channel, text, dbc) + if sbn: + return sbn + sbi = _search_by_id(channel, text, dbc) + if sbi: + return sbi + + return find(channel, text, dbc) + + +def main(i, irc): + dbc = i.db[1].cursor() + dbc.execute( + ''' + CREATE TABLE IF NOT EXISTS quote + (num INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + channel TEXT, + quote TEXT COLLATE NOCASE UNIQUE, + made_by TEXT COLLATE NOCASE, + added_by TEXT COLLATE NOCASE);''') + dbc.execute( + ''' + CREATE VIRTUAL TABLE IF NOT EXISTS quote_index + USING fts5(num, quote, channel, tokenize=porter);''') + dbc.execute( + ''' + CREATE TRIGGER IF NOT EXISTS quote_after_insert AFTER INSERT ON quote + BEGIN + INSERT INTO quote_index (num, quote, channel) + VALUES (new.num, new.quote, new.channel); + END;''') + dbc.execute( + ''' + CREATE TRIGGER IF NOT EXISTS quote_after_delete AFTER DELETE ON quote + BEGIN + DELETE FROM quote_index WHERE num = old.num; + END;''') + i.db[1].commit() + + if 'quote' == i.cmd: + if i.is_pm: + if not i.msg_nocmd: + m = (f"{logo}: Usage: {i.cmd_prefix}{i.cmd} " + f"<#channel> ") + irc.privmsg(i.channel, m) + return + channel = i.msg_nocmd.split()[0] + if not channel[:1] == '#': + m = (f"{logo}: Usage: {i.cmd_prefix}{i.cmd} " + f"<#channel> ") + irc.privmsg(i.channel, m) + return + if channel not in irc.var.namesdict: + m = f"{logo}: The bot has not joined the channel: {channel}" + irc.privmsg(i.channel, m) + return + query = " ".join(i.msg_nocmd.split()[1:]) + irc.privmsg(i.channel, quote(channel, query, dbc)) + else: + irc.privmsg(i.channel, quote(i.channel, i.msg_nocmd, dbc)) + + elif 'findquote' == i.cmd: + if i.is_pm: + if not i.msg_nocmd or len(i.msg_nocmd.split()) < 2: + m = f"{logo}: Usage: {i.cmd_prefix}{i.cmd} <#channel> " + irc.privmsg(i.channel, m) + return + channel = i.msg_nocmd.split()[0] + if not channel[:1] == '#': + m = f"{logo}: Usage: {i.cmd_prefix}{i.cmd} <#channel> " + irc.privmsg(i.channel, m) + return + if channel not in irc.var.namesdict: + m = f"{logo}: The bot has not joined the channel: {channel}" + irc.privmsg(i.channel, m) + return + query = " ".join(i.msg_nocmd.split()[1:]) + irc.privmsg(i.channel, find(channel, query, dbc)) + else: + if not i.msg_nocmd: + m = f"{logo}: Usage: {i.cmd_prefix}{i.cmd} " + irc.privmsg(i.channel, m) + return + irc.privmsg(i.channel, find(i.channel, i.msg_nocmd, dbc)) + + elif 'delquote' == i.cmd: + if i.is_pm: + m = f"{logo}: This command can only be used within a channel." + irc.privmsg(i.channel, m) + return + if not i.msg_nocmd or not i.msg_nocmd.isdigit(): + m = f"{logo}: Usage: {i.cmd_prefix}{i.cmd} " + irc.privmsg(i.channel, m) + return + if is_allowed(i, irc, i.nickname, i.channel): + irc.privmsg(i.channel, delete(i.msg_nocmd, dbc)) + else: + m = f"{logo}: Only Channel Operators are allowed to delete quotes." + irc.privmsg(i.channel, m) + + elif 'addquote' == i.cmd: + if i.is_pm: + m = (f"{logo}: Please, submit the quote in the channel it was" + " posted in.") + irc.privmsg(i.channel, m) + else: + if not i.msg_nocmd or len(i.msg_nocmd.split()) < 2: + m = f"{logo}: Usage: {i.cmd_prefix}{i.cmd} " + irc.privmsg(i.channel, m) + return + made_by = i.msg_nocmd.split()[0] + quote_text = " ".join(i.msg_nocmd.split()[1:]) + irc.privmsg(i.channel, + add(i.channel, quote_text, made_by, i.nickname, dbc)) + + elif 'listquotes' == i.cmd: + if not i.msg_nocmd or len(i.msg_nocmd.split()) != 1: + m = f"{logo}: Usage: {i.cmd_prefix}{i.cmd} <#channel>" + irc.privmsg(i.channel, m) + return + channel = i.msg_nocmd.split()[0] + if not channel[:1] == '#': + m = f"{logo}: Usage: {i.cmd_prefix}{i.cmd} <#channel>" + irc.privmsg(i.channel, m) + return + if channel not in irc.var.namesdict: + m = f"{logo}: The bot has not joined the channel: {channel}" + irc.privmsg(i.channel, m) + return + listquotes(channel, i.nickname, dbc, irc) + i.db[1].commit() diff --git a/src/irc/modules/search.py b/src/irc/modules/search.py new file mode 100644 index 0000000..4a93e1a --- /dev/null +++ b/src/irc/modules/search.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# Search Module for Drastikbot +# +# Provides the results of various search engines like: +# Google, Bing, Duckduckgo, Searx, Startpage +# +# Depends: +# - requests :: $ pip3 install requests +# - beautifulsoup :: $ pip3 install beautifulsoup4 +# - url :: included with drastikbot_modules, should be loaded. + +''' +Copyright (C) 2018, 2021 drastik.org + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +any later version. + +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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +''' + +import urllib.parse +import json +import requests +import bs4 +import url # drastikbot_modules: url.py + + +class Module: + def __init__(self): + self.commands = ['g', 'bing', 'ddg', 'searx', 'sp'] + self.manual = { + "desc": ("Get search results from Duckduckgo, Google, Bing" + ", Searx and Startpage."), + "bot_commands": { + "g": {"usage": lambda x: f"{x}g "}, + "bing": {"usage": lambda x: f"{x}bing "}, + "ddg": {"usage": lambda x: f"{x}ddg "}, + "searx": {"usage": lambda x: f"{x}searx "}, + "sp": {"usage": lambda x: f"{x}sp "} + } + } + + +# ----- Constants ----- # +opt_title_tag = True +parser = 'html.parser' +lang = "en-US" + +ua_chrome_90 = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + " (KHTML, like Gecko) Chrome/90.0.4430.85 Safari/537.36") + + +# --------------------- # + + +# --- Helper Functions --- # +def url2str(query): + return urllib.parse.unquote_plus(query) + + +def url_extract(url): + u = urllib.parse.urlparse(url) + u = urllib.parse.parse_qs(u.query) + try: + u = u['v'][0] + except Exception: + u = u['video_id'][0] + return ''.join(u.split()) + + +def urlfix(url): + url = url.replace(' ', '') + if not (url.startswith('http://') or url.startswith('https://')): + url = 'http://' + url + return url + + +# ==================================================================== +# Google +# ==================================================================== + +def google(query): + search = f'https://www.google.com/search?q={query}' + return "google", "This search engine is not supported yet." + + +# ==================================================================== +# Bing +# ==================================================================== + +def bing(args): + query = urllib.parse.quote(args, safe="") + u = f"https://bing.com/search?q={query}" + h = headers={ + "Accept-Language": lang, + "user-agent": ua_chrome_90, + } + r = requests.get(u, headers=h, timeout=10) + soup = bs4.BeautifulSoup(r.text, parser) + + results_l = soup.find_all("li", {"class": "b_algo"}) + result = results_l[0].find("a").get("href") + + return "bing", result + + +# ==================================================================== +# Duckduckgo +# ==================================================================== + +def duckduckgo(args): + query = urllib.parse.quote(args, safe="") + + if args[0] == '!': + return "duckduckgo", duckduckgo_bang(query) + + return "duckduckgo", duckduckgo_search(query) + + +def duckduckgo_bang(query): + u = f"https://api.duckduckgo.com/?q={query}&format=json&no_redirect=1" + h = { + "Accept-Language": lang + } + r = requests.get(u, headers=h, timeout=10) + return r.json()["Redirect"] + + +def duckduckgo_search(query): + u = ("https://html.duckduckgo.com/html/" + f"?q={query}&kl=wt-wt&kp=-2&kaf=1&kh=1&k1=-1&kd=-1") + h = { + "user-agent": ua_chrome_90, + "Accept-Language": lang + } + r = requests.get(u, headers=h, timeout=10) + soup = bs4.BeautifulSoup(r.text, parser) + + result = soup.find("a", {"class": ["result__url"]}) + return result.get("href") + + +# ==================================================================== +# Searx +# ==================================================================== + +def searx(query): + search = f'https://searx.me/?q={query}' + return "searx", "This search engine is not supported yet." + + +# ==================================================================== +# Startpage +# ==================================================================== + +def startpage(query): + search = f'https://www.startpage.com/do/asearch?q={query}' + return "startpage", "This search engine is not supported yet." + + +# ==================================================================== +# Main +# ==================================================================== + +dispatch = { + "g": google, + "bing": bing, + "ddg": duckduckgo, + "searx": searx, + "sp": startpage +} + +logo_d = { + "google": "\x0302G\x0304o\x0308o\x0302g\x0309l\x0304e\x0F", + "bing": "\x0315Bing\x0F", + "duckduckgo": "\x0315DuckDuckGo\x0F", + "searx": "\x0315sear\x0311X\x0F", + "startpage": "\x0304start\x0302page\x0F" +} + +# err = f'{logo}: \x0308Sorry, i could not find any results for:\x0F {query}' + + +def main(i, irc): + args = i.msg_nocmd + botcmd = i.cmd + receiver = i.channel + + engine, result = dispatch[botcmd](args) + + title = None + if opt_title_tag: + title = url.get_title(result) + + logo = logo_d[engine] + + if title: + m = f"{logo}: {result} | < title: {title}" + else: + m = f"{logo}: {result}" + + # Truncate the output just in case. We can't send 512 bytes anyway. + m = m[:512] + + irc.notice(receiver, m) diff --git a/src/irc/modules/sed.py b/src/irc/modules/sed.py new file mode 100644 index 0000000..ec20f82 --- /dev/null +++ b/src/irc/modules/sed.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# Sed Module for Drastikbot +# +# Replace text using sed. +# +# This module keeps a buffer of the last posted messages +# and when the substitution command is issued it calls sed +# and sends the result. +# +# Depends: +# - sed :: default unix program, should be in the repos + +''' +Copyright (C) 2018 drastik.org + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +any later version. + +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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +''' + +import subprocess +import re +from dbot_tools import p_truncate + + +class Module: + def __init__(self): + self.auto = True + self.manual = { + "desc": ( + "Usage: s/regexp/replacement/flags" + " | Try to match a 'regexp' with one of the previous" + " messages posted and replace it with 'replacement'." + " For flags and a detailed explanation see:" + " https://www.gnu.org/software/sed/manual/html_node/" + "The-_0022s_0022-Command.html" + " | Extensions: \"s///-n\" n is the number of matches to skip" + " If the 'number' flag is used -n should be used after it.") + } + + +def write(varget, varset, channel, msg): + msgdict = varget('msgdict', defval={channel: [msg]}) + + try: + msgdict[channel].append(msg) + except KeyError: + msgdict.update({channel: [msg]}) + + if len(msgdict[channel]) > 50: + del msgdict[channel][0] + + varset('msgdict', msgdict) + + +def read(varget, channel): + return varget('msgdict')[channel] + + +def call_sed(msg, sed_args): + echo = ['echo', msg] + p = subprocess.run(echo, stdout=subprocess.PIPE) + echo_outs = p.stdout + sed = ['sed', '-r', '--sandbox', + f's/{sed_args[1]}/{sed_args[2]}/{sed_args[3]}'] + p = subprocess.run(sed, stdout=subprocess.PIPE, input=echo_outs) + return p.stdout.decode('utf-8') + + +def main(i, irc): + sed_parse = re.compile('(? 2 and s[idx + 2].isdecimal(): + goback = int(f'{s[idx + 1]}{s[idx + 2]}') + sed_args[3] = f'{s[:idx]}{s[idx + 3:]}' + elif len(s[idx:]) > 1 and s[idx + 1].isdecimal(): + goback = int(s[idx + 1]) + sed_args[3] = f'{s[:idx]}{s[idx + 2:]}' + + n = 1 + while n <= 50: + if 'i' in sed_args[3]: + db_search = re.search(sed_args[1], msglist[-n], re.I) + else: + db_search = re.search(sed_args[1], msglist[-n]) + if db_search: + if goback: + # Check if the goback command was issued. + goback -= 1 + n = n + 1 + continue + a = n + break + else: + a = False + n = n + 1 + + if a: + if '\x01ACTION' in msglist[-a][:7]: + msg_len = irc.var.msg_len - 9 - len(i.channel) - 10 - 2 + sed_out = call_sed(msglist[-a][7:], sed_args) + sed_out = sed_out.rstrip('\n').replace('\x01', "").replace('\ca', "") + sed_out = p_truncate(sed_out, msg_len, 98, True) + irc.privmsg(i.channel, f'\x01ACTION {sed_out}') + else: + msg_len = irc.var.msg_len - 9 - len(i.channel) - 2 + sed_out = call_sed(msglist[-a], sed_args).strip() + sed_out = sed_out.rstrip('\n').replace('\x01', "").replace('\ca', "") + sed_out = p_truncate(sed_out, msg_len, 98, True) + irc.privmsg(i.channel, sed_out) + + if sed_out: + # We try to limit the string saved in the queue to avoid: + # OSError: [Errno 7] Argument list too long: 'echo' + # when calling 'echo' in @call_sed . + # 512 chars are more than enough, since the bot will + # never be able to send a message with that many. + write(i.varget, i.varset, i.channel, sed_out) + # write(dbc, i.channel, i.msg) # save commands diff --git a/src/irc/modules/seen.py b/src/irc/modules/seen.py new file mode 100644 index 0000000..d033a6e --- /dev/null +++ b/src/irc/modules/seen.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# Seen Module for Drastikbot +# +# It logs the last activity of every user posting and returns it upon request. +# It logs time, post, nickname, channel. +# +# user = The one used the bot (or posted a random line) +# nick = The nickname requested by the user + +''' +Seen module for drastikbot. +Copyright (C) 2018, 2021 drastik.org + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +any later version. + +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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +''' + +import datetime + + +class Module: + def __init__(self): + self.auto = True + self.commands = ["seen"] + self.manual = { + "desc": ("See when a user was last seen posting in a channel the" + " bot has joined. If the user posted in channel other" + " than the one the command was given from, then the bot" + " will show that channel. Private messages with the bot" + " are NOT saved."), + "bot_commands": { + "seen": {"usage": lambda x: f"{x}seen "}, + "info": ("Example: : .seen Bob / :" + " Bob was last seen 0:21:09 ago" + " [2018-06-25 13:36:42 UTC], saying .help seen") + } + } + + +def update(channel, nick, msg, time, dbc): + try: + dbc.execute( + '''CREATE TABLE IF NOT EXISTS seen (nick TEXT COLLATE NOCASE + PRIMARY KEY, msg TEXT, time TEXT, channel TEXT);''') + except Exception: + # sqlite3.OperationalError: cannot commit - no transaction is active + pass + dbc.execute( + '''INSERT OR IGNORE INTO seen (nick, msg, time, channel) + VALUES (?, ?, ?, ?);''', (nick, msg, str(time), channel)) + dbc.execute('''UPDATE seen SET msg=?, time=?, channel=? WHERE nick=?;''', + (msg, str(time), channel, nick)) + + +def fetch(nick, dbc): + try: + dbc.execute('SELECT nick, msg, time, channel ' + 'FROM seen WHERE nick=?;''', (nick,)) + fetch = dbc.fetchone() + nickFnd = fetch[0] + msgFnd = fetch[1] + timeFnd = datetime.datetime.strptime(fetch[2], "%Y-%m-%d %H:%M:%S") + channelFnd = fetch[3] + return (msgFnd, timeFnd, channelFnd, nickFnd) + except Exception: + return False + + +def main(i, irc): + dbc = i.db[1].cursor() + timestamp = datetime.datetime.utcnow().replace(microsecond=0) + args = i.msg_nocmd.split() + + if not i.cmd and not i.channel == i.nickname: + # Avoid saving privmsges with the bot. + update(i.channel, i.nickname, i.msg, timestamp, dbc) + i.db[1].commit() + return + + if i.cmd == 'seen' and ((len(args) == 1 and len(i.msg) <= 30) + or i.msg_nocmd == ''): + + if not i.msg_nocmd: + # If no nickname is given set the args[0] to the user's nickname + args = [i.nickname] + + get = fetch(args[0], dbc) + if get: + ago = timestamp - get[1] + if '\x01ACTION' in get[0][:10]: + toSend = (f'\x0312{get[3]}\x0F was last seen ' + f'\x0312{ago} ago\x0F [{get[1]} UTC], ' + f'doing \x0312{i.msg_nocmd} {get[0][8:]}\x0F') + else: + toSend = (f'\x0312{get[3]}\x0F was last seen ' + f'\x0312{ago} ago\x0F [{get[1]} UTC], ' + f'saying \x0312{get[0]}\x0F') + if args[0].lower() == i.nickname.lower(): + # Check if the requested nickname is the user's nickname + toSend = (f'\x0312You\x0F were last seen \x0312{ago} ago\x0F' + f' [{get[1]} UTC], saying \x0312{get[0]}\x0F') + else: + toSend = f"Sorry, I haven't seen \x0312{args[0]}\x0F around" + if args[0] == irc.var.nickname: + # Check if the requested nickname is the bot's nickname + toSend = "\x0304Who?\x0F" + self_nick = True + else: + self_nick = False + try: + # If 'get' check the channel the user was last seen and send a + # privmsg + if get[2] == i.channel or self_nick: + irc.privmsg(i.channel, toSend) + else: + toSend = f'{toSend} in \x0312{get[2]}\x0F' + irc.privmsg(i.channel, toSend) + except TypeError: + # if not 'get' just send a privmsg + irc.privmsg(i.channel, toSend) + # Update the database + if not i.channel == i.nickname: # Avoid saving privmsges with the bot. + update(i.channel, i.nickname, i.msg, timestamp, dbc) + i.db[1].commit() diff --git a/src/irc/modules/tarot.py b/src/irc/modules/tarot.py new file mode 100644 index 0000000..6b16e56 --- /dev/null +++ b/src/irc/modules/tarot.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# Tarot Module for drastikbot +# +# Draws three cards from the tarot deck. +# Usage: .tarot + +''' +Copyright (C) 2018 Newt Vodyanoy + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +''' + +import random + + +class Module(): + def __init__(self): + self.commands = ['tarot'] + self.manual = { + "desc": "Draws three cards from the tarot deck.", + "bot_commands": {"tarot": {"usage": lambda x: f"{x}tarot"}} + } + + +major_arcana = [ + "The Fool", + "The Magician", + "The High Priestess", + "The Empress", + "The Emperor", + "The Hierophant", + "The Lovers", + "The Chariot", + "Strength", + "The Hermit", + "Wheel of Fortune", + "Justice", + "The Hanged Man", + "Death", + "Temperance", + "Devil", + "The Tower", + "The Star", + "The Moon", + "The Sun", + "Judgement", + "The World" +] +suits = ["Swords", "Wands", "Coins", "Cups"] +suit_cards = [ + "Ace", "Two", "Three", "Four", "Five", "Six", "Seven", + "Eight", "Nine", "Ten", "Page", "Knight", "Queen", "King" +] +minor_arcana = list() +for card in ((x, y) for x in suit_cards for y in suits): + minor_arcana.append("The %s of %s" % card) +deck = major_arcana + minor_arcana + + +def main(i, irc): + cards = random.sample(deck, 3) + if 'tarot' == i.cmd: + irc.privmsg(i.channel, f'{cards[0]}, {cards[1]}, {cards[2]}') diff --git a/src/irc/modules/tell.py b/src/irc/modules/tell.py new file mode 100644 index 0000000..da1ced2 --- /dev/null +++ b/src/irc/modules/tell.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# Tell Module for Drastikbot +# +# It works "like" memoserv. +# A user tells the bot to tell a message to a nick when that nick is seen. +# .tell drastik drastikbot is down | .tell + +''' +Copyright (C) 2017 drastik.org + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +any later version. + +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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +''' + +import datetime +from ignore import is_ignored + + +class Module: + def __init__(self): + self.commands = ['tell'] + self.auto = True + self.manual = { + "desc": ( + "Send a message to a user through the bot. This is used to" + " send messages to users that are AFK or not connected to " + "the server. The bot will message the receiver as soon as " + "they post to a channel that the bot has joined." + ), + "bot_commands": { + "tell": {"usage": lambda x: f"{x}tell "} + } + } + + +def add(receiver, msg, sender, dbc): + timestamp = datetime.datetime.utcnow().replace(microsecond=0) + dbc.execute("CREATE TABLE IF NOT EXISTS tell " + "(receiver TEXT COLLATE NOCASE, msg TEXT, sender TEXT, " + "timestamp TEXT, date INTEGER);") + dbc.execute("INSERT INTO tell VALUES (?, ?, ?, ?, strftime('%s', 'now'));", + # These are wrong but handled correctly. Fix it someday. + (receiver, msg, str(timestamp), sender)) + + +def find(nick, irc, dbc): + try: + dbc.execute('SELECT sender, msg, timestamp FROM tell ' + 'WHERE receiver=?;', (nick,)) + fetch = dbc.fetchall() + for i in fetch: + irc.privmsg(nick, f'\x0302{i[2]} [{i[0]} UTC]:\x0F') + irc.privmsg(nick, i[1]) + dbc.execute('''DELETE FROM tell WHERE receiver=?;''', (nick,)) + # Delete messages older than 3600 * 24 * 30 * 3 seconds. + dbc.execute('''DELETE FROM tell WHERE + (strftime('%s', 'now') - date) > (3600 * 24 * 30 * 3);''') + except Exception: + # Nothing in the db yet, ignore errors. + pass + + +def main(i, irc): + dbc = i.db[1].cursor() + if 'tell' == i.cmd: + try: + arg_list = i.msg_nocmd.split(' ', 1) + receiver = arg_list[0] + msg = arg_list[1] + except IndexError: + help_msg = f"Usage: {i.cmd_prefix}{i.cmd} " + return irc.privmsg(i.channel, help_msg) + if i.nickname.lower() == receiver.lower(): + return irc.privmsg(i.channel, 'You can tell yourself that.') + if irc.var.curr_nickname.lower() == receiver.lower(): + return irc.privmsg(i.channel, + f'{i.nickname}: I am here now, tell me.') + if is_ignored(i, irc, receiver, i.nickname): + return # say nothing + add(receiver, msg, i.nickname, dbc) + irc.privmsg(i.channel, + f'{i.nickname}: I will tell {receiver} ' + 'when they are around.') + + find(i.nickname, irc, dbc) + i.db[1].commit() diff --git a/src/irc/modules/text.py b/src/irc/modules/text.py new file mode 100644 index 0000000..7a93b06 --- /dev/null +++ b/src/irc/modules/text.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# Text Module for Drastikbot +# +# Transform textual input to various other styles. + +''' +Copyright (C) 2019 drastik.org + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +any later version. + +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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +''' + +import random + + +class Module: + def __init__(self): + self.commands = [ + "ae", "text-c", "text-nc", "text-s", "text-ns", "flag", "cirrus", + "strike", "strikethrough" + ] + self.manual = { + "desc": "Text transformation tools", + "bot_commands": { + "ae": {"usage": lambda p: f"{p}ae ", + "info": "Example: Hello, World!"}, + "text-c": {"usage": lambda p: f"{p}text-c ", + "info": "Example: ⒽⒺⓁⓁⓄ, ⓌⓄⓇⓁⒹ!"}, + "text-nc": {"usage": lambda p: f"{p}text-nc ", + "info": "Example: 🅗🅔🅛🅛🅞, 🅦🅞🅡🅛🅓!"}, + "text-s": {"usage": lambda p: f"{p}text-s ", + "info": "Example: 🄷🄴🄻🄻🄾, 🅆🄾🅁🄻🄳!"}, + "text-ns": {"usage": lambda p: f"{p}text-ns ", + "info": "Example: 🅷🅴🅻🅻🅾, 🆆🅾🆁🅻🅳!"}, + "flag": {"usage": lambda p: f"{p}flag ", + "info": ("Transforms two letter country codes to" + " regional indicator symbols.")}, + "cirrus": {"usage": lambda p: f"{p}cirrus ", + "info": "Example: Hello, WWorld!"}, + "strike": {"usage": lambda p: f"{p}strike ", + "info": "Example: H̶e̶l̶l̶o̶,̶ ̶W̶o̶r̶l̶d̶!̶", + "alias": ["strikethrough"]}, + "strikethrough": { + "usage": lambda p: f"{p}strikethrough ", + "info": "Example: H̶e̶l̶l̶o̶,̶ ̶W̶o̶r̶l̶d̶!̶", + "alias": ["strike"] + } + } + } + + +# https://en.wikipedia.org/wiki/Halfwidth_and_fullwidth_forms_(Unicode_block) +FULLWIDTH_MAP = dict((i, i + 0xFEE0) for i in range(0x21, 0x7F)) +FULLWIDTH_MAP[0x20] = 0x3000 + +# https://en.wikipedia.org/wiki/Enclosed_Alphanumerics +_CIRCLED_NUM_MAP = dict((i, (i - 0x31) + 0x2460) for i in range(0x30, 0x3A)) +_CIRCLED_NUM_MAP[0x30] = 0x24EA # Set the actual Circled digit zero character +_CIRCLED_ALP_U_MAP = dict((i, (i - 0x41) + 0x24B6) for i in range(0x41, 0x5B)) +_CIRCLED_ALP_L_MAP = dict((i, (i - 0x61) + 0x24B6) for i in range(0x61, 0x7B)) +CIRCLED_MAP = {**_CIRCLED_NUM_MAP, **_CIRCLED_ALP_U_MAP, **_CIRCLED_ALP_L_MAP} + +# https://en.wikipedia.org/wiki/Enclosed_Alphanumeric_Supplement +_NEGATIVE_CIRCLED_ALP_U_MAP = dict( + (i, (i - 0x41) + 0x1F150) for i in range(0x41, 0x5B)) +_NEGATIVE_CIRCLED_ALP_L_MAP = dict( + (i, (i - 0x61) + 0x1F150) for i in range(0x61, 0x7B)) +NEGATIVE_CIRCLED_MAP = { + **_NEGATIVE_CIRCLED_ALP_U_MAP, + **_NEGATIVE_CIRCLED_ALP_L_MAP +} + +_SQUARED_ALP_U_MAP = dict((i, (i - 0x41) + 0x1F130) for i in range(0x41, 0x5B)) +_SQUARED_ALP_L_MAP = dict((i, (i - 0x61) + 0x1F130) for i in range(0x61, 0x7B)) +SQUARED_MAP = {**_SQUARED_ALP_U_MAP, **_SQUARED_ALP_L_MAP} + +_NEGATIVE_SQUARED_ALP_U_MAP = dict( + (i, (i - 0x41) + 0x1F170) for i in range(0x41, 0x5B)) +_NEGATIVE_SQUARED_ALP_L_MAP = dict( + (i, (i - 0x61) + 0x1F170) for i in range(0x61, 0x7B)) +NEGATIVE_SQUARED_MAP = { + **_NEGATIVE_SQUARED_ALP_U_MAP, + **_NEGATIVE_SQUARED_ALP_L_MAP +} + +# https://en.wikipedia.org/wiki/Regional_Indicator_Symbol +_REGIONAL_INDICATOR_SYMBOL_U_MAP = dict( + (i, (i - 0x41) + 0x1F1E6) for i in range(0x41, 0x5B)) +_REGIONAL_INDICATOR_SYMBOL_L_MAP = dict( + (i, (i - 0x61) + 0x1F1E6) for i in range(0x61, 0x7B)) +REGIONAL_INDICATOR_SYMBOL_MAP = { + **_REGIONAL_INDICATOR_SYMBOL_U_MAP, + **_REGIONAL_INDICATOR_SYMBOL_L_MAP +} + +command_map_d = { + "ae": FULLWIDTH_MAP, + "text-c": CIRCLED_MAP, + "text-nc": NEGATIVE_CIRCLED_MAP, + "text-s": SQUARED_MAP, + "text-ns": NEGATIVE_SQUARED_MAP, + "flag": REGIONAL_INDICATOR_SYMBOL_MAP, +} + + +def cirrus(text): + words = text.split() + wc = len(words) + cc = 0 + for i in range(wc): + if random.uniform(0, 1) < 0.38: + cc += 1 + words[i] = f"{words[i][0]}{words[i]}" + + if cc == 0: + i = random.randint(0, wc - 1) + words[i] = f"{words[i][0]}{words[i]}" + + return " ".join(words) + + +def strikethrough(text): + return "\u0336".join(text) + '\u0336' + + +def main(i, irc): + if not i.msg_nocmd: + return + s = i.msg_nocmd + + if i.cmd == "cirrus": + return irc.privmsg(i.channel, cirrus(s)) + if i.cmd == "strike" or i.cmd == "strikethrough": + return irc.privmsg(i.channel, strikethrough(s)) + + t = s.translate(command_map_d[i.cmd]) + if i.cmd == "ae" and t == s: + t = s.replace("", " ")[1: -1] + + irc.privmsg(i.channel, t) diff --git a/src/irc/modules/theo.py b/src/irc/modules/theo.py new file mode 100644 index 0000000..5459f93 --- /dev/null +++ b/src/irc/modules/theo.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# Quotes from mg's theo.c +# http://cvsweb.openbsd.org/cgi-bin/cvsweb/src/usr.bin/mg/Attic/theo.c + +''' +Copyright (C) 2018 drastik.org + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +any later version. + +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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +''' + +import random + + +class Module: + def __init__(self): + self.commands = ['theo'] + self.manual = { + "desc": ("Post quotes from OpenBSD mg's theo.c mode. This mode has" + " been removed from mg. This module includes all the" + " quotes from the last commit to theo.c before it was" + " removed: " + "http://cvsweb.openbsd.org/cgi-bin/cvsweb/src/usr.bin/" + "mg/Attic/theo.c"), + "bot_commands": {"theo": {"usage": lambda x: f"{x}theo"}} + } + + +talk = [ + "Write more code.", + "Make more commits.", + "That's because you have been slacking.", + "slacker!", + "That's what happens when you're lazy.", + "idler!", + "slackass!", + "lazy bum!", + "Stop slacking you lazy bum!", + "slacker slacker lazy bum bum bum slacker!", + "I could search... but I'm a lazy bum ;)", + "sshutup sshithead, ssharpsshooting susshi sshplats ssharking assholes.", + "Lazy bums slacking on your asses.", + "35 commits an hour? That's pathetic!", + "Fine software takes time to prepare. Give a little slack.", + "I am just stating a fact", + "you bring new meaning to the terms slackass. I will have to invent a new term.", + "if they cut you out, muddy their back yards", + "Make them want to start over, and play nice the next time.", + "It is clear that this has not been thought through.", + "avoid using abort(). it is not nice.", + "That's the most ridiculous thing I've heard in the last two or three minutes!", + "I'm not just doing this for crowd response. I need to be right.", + "I'd put a fan on my bomb.. And blinking lights...", + "I love to fight", + "No sane people allowed here. Go home.", + "you have to stop peeing on your breakfast", + "feature requests come from idiots", + "henning and darren / sitting in a tree / t o k i n g / a joint or three", + "KICK ASS. TIME FOR A JASON LOVE IN! WE CAN ALL GET LOST IN HIS HAIR!", + "shame on you for following my rules.", + "altq's parser sucks dead whale farts through the finest chemistry pipette's", + "screw this operating system shit, i just want to drive!", + "Search for fuck. Anytime you see that word, you have a paragraph to write.", + "Yes, but the ports people are into S&M.", + "Buttons are for idiots.", + "We are not hackers. We are turd polishing craftsmen.", + "who cares. style(9) can bite my ass", + "It'd be one fucking happy planet if it wasn't for what's under this fucking sticker.", + "I would explain, but I am too drunk.", + "you slackers don't deserve pictures yet", + "Vegetarian my ass", + "Wait a minute, that's a McNally's!", + "don't they recognize their moral responsibility to entertain me?", + "#ifdef is for emacs developers.", + "Many well known people become net-kooks in their later life, because they lose touch with reality.", + "You're not allowed to have an opinion.", + "tweep tweep tweep", + "Quite frankly, SSE's alignment requirement is the most utterly retarded idea since eating your own shit.", + "Holy verbose prom startup Batman.", + "Any day now, when we sell out.", + "optimism in man kind does not belong here", + "First user who tries to push this button, he pounds into the ground with a rant of death.", + "we did farts. now we do sperm. we are cutting edge.", + "the default configuration is a mixture of piss, puke, shit, and bloody entrails.", + "Stop wasting your time reading people's licenses.", + "doing it with environment variables is OH SO SYSTEM FIVE LIKE OH MY GOD PASS ME THE SPOON", + "Linux is fucking POO, not just bad, bad REALLY REALLY BAD", + "penguins are not much more than chickens that swim.", + "i am a packet sniffing fool, let me wipe my face with my own poo", + "Whiners. They scale really well.", + "in your world, you would have a checklist of 50 fucking workarounds just to make a coffee.", + "for once, I have nothing to say.", + "You have no idea how fucked we are", + "You can call it fart if you want to.", + "wavelan is a battle field", + "You are in a maze of gpio pins, all alike, all undocumented, and a few are wired to bombs.", + "And that is why humppa sucks... cause it has no cause.", + "cache aliasing is a problem that would have stopped in 1992 if someone had killed about 5 people who worked at Sun.", + "Don't spread rumours about me being gentle.", + "If municipal water filtering equipment was built by the gcc developers, the western world would be dead by now.", + "kettenis supported a new machine in my basement and all I got to do was fix a 1 character typo in his html page commit.", + "industry told us a lesson: when you're an asshole, they mail you hardware", + "I was joking, really. I think I am funny :-)", + "the kernel is a harsh mistress", + "Have I ever been subtle? If my approach ever becomes subtle, shoot me.", +"the acpi stabs you in the back. the acpi stabs you in the back. you die ...", + "My cats are more observant than you.", + "our kernels have no bugs", + "style(9) has all these fascist rules, and i have a problem with some of them because i didn't come up with them", + "I'm not very reliable", + "I don't like control", + "You aren't being conservative -- you are trying to be a caveman.", + "nfs loves everyone", + "basically, dung beetles fucking. that's what kerberosV + openssl is like", + "I would rather run Windows than use vi.", + "if you assign that responsibility to non-hikers I will walk over and cripple you now.", + "i ojbect two yoru splelng of achlhlocis.", + "We have two kinds of developers - those that deal with their own shit and those that deal with other people's shit.", + "If people keep adding such huge stuff, soon mg will be bigger than emacs.", + "this change comes down to: This year, next year, 5 years from now, 10 years from now, or Oh fuck.", + "backwards compatibility is king, and will remain king, until 2038.", + "I don't know if the Internet's safe yet.", + "Those who don't understand Unix are condemned to reinvent Multics in a browser", + "Don't tell anybody I said that.", + "Complaint forms are handled in another department.", + "You'd be safer using Windows than the code which was just deleted.", + "Shit should not be shared.", + "the randomization in this entire codebase is a grand experiment in stupid", + "My mailbox is full of shock.", + "my integer overflow spidey senses are tingling.", + "I'm just trying to improve the code...", + "It's a pleasure to work on code you can't make worse.", + "It's largely bad style to do (int)sizeof", + "When I see Makefile.in, I know that \"in\" is short for \"insane\".", + "This is the beer. And that's why we need a hackathon.", + "Kill the past with fire, and declare Duran Duran is less cool today. Await remixes of the same thing performed by new talent.", + "Where did my \"fuck backwards compat\" compatriots go?", + "I want a new vax, one that's not so slow.", + "This sausage is made from unsound meat.", + "The people who wrote this code are not on your side.", + "Well finally everyone can see that the shit is really shitty.", + "All that complexity stopped us from getting flying cars by today." +] + + +def main(i, irc): + msg = f"{i.nickname}: {random.SystemRandom().choice(talk)}" + irc.privmsg(i.channel, msg) diff --git a/src/irc/modules/urbandict.py b/src/irc/modules/urbandict.py new file mode 100644 index 0000000..3234ba6 --- /dev/null +++ b/src/irc/modules/urbandict.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# Urban Dictionary Module for Drastikbot. +# +# It uses api.urbandictionary.com/v0/. +# +# Depends: +# - requests :: $ pip3 install requests + +''' +Copyright (C) 2018 drastik.org + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +any later version. + +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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +''' + +import requests +from dbot_tools import p_truncate + + +class Module: + def __init__(self): + self.commands = ['ud'] + self.manual = { + "desc": "Search https://www.urbandictionary.com/ for definitions.", + "bot_commands": { + "ud": {"usage": lambda x: f"{x}ud [--def ]", + "info": ("The --def option allows you to select other" + " definitions. Example: .ud irc --def 2")} + } + } + + +def ud(query, res): + u = f'http://api.urbandictionary.com/v0/define?term={query}' + r = requests.get(u, timeout=5) + j = r.json()['list'][res] + word = j['word'] + definition = p_truncate(j['definition'], msg_len, 71, True) + example = p_truncate(j['example'], msg_len, 15, True) + author = j['author'] + t_up = j['thumbs_up'] + t_down = j['thumbs_down'] + pl = j['permalink'].rsplit('/', 1)[0] + return (word, definition, example, author, t_up, t_down, pl) + + +def query(args): + # Get the args list and the commands + # Join the list to a string and return + _args = args[:] + try: + idx = _args.index('--def') + del _args[idx] + del _args[idx] + except ValueError: + pass + return ' '.join(_args) + + +def main(i, irc): + logo = '\x0300,01Urban\x0F\x0308,01Dictionary\x0F' + args = i.msg_nocmd.split() + if not args: + help_msg = f"Usage: {i.cmd_prefix}{i.cmd} [--def ]" + return irc.privmsg(i.channel, help_msg) + if '--def' in args: + idx = args.index('--def') + res = int(args[idx + 1]) - 1 + else: + res = 0 + q = query(args) + try: + global msg_len + # msg_len = msg_len - "PRIVMSG :" - chars - \r\n + msg_len = irc.var.msg_len - 9 - 101 - 2 + u = ud(q, res) + rpl = (f"{logo}: \x02{u[0]}\x0F" + f" | {u[1]}" + f" | \x02E.g:\x0F {u[2]}" + f" | \x02Author:\x0F {u[3]}" + f" | \x0303+{u[4]}\x0F" + f" | \x0304-{u[5]}\x0F" + f" | \x02Link:\x0F {u[6]}") + except IndexError: + rpl = (f"{logo}: No definition was found for \x02{q}\x0F") + irc.privmsg(i.channel, rpl) diff --git a/src/irc/modules/url.py b/src/irc/modules/url.py new file mode 100644 index 0000000..f7db674 --- /dev/null +++ b/src/irc/modules/url.py @@ -0,0 +1,355 @@ +# coding=utf-8 + +# URL Module for Drastikbot +# +# Depends: +# - requests :: $ pip3 install requests +# - beautifulsoup :: $ pip3 install beautifulsoup4 + +''' +Copyright (C) 2017-2020 drastik.org + +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 . +''' + +import re +import math +import json +import urllib.parse +import requests +import bs4 + + +class Module: + def __init__(self): + self.auto = True + + +# ----- Constants ----- # +parser = 'html.parser' +user_agent = "w3m/0.52" +accept_lang = "en-US" +nsfw_tag = "\x0304[NSFW]\x0F" +data_limit = 69120 +# --------------------- # + + +def remove_formatting(msg): + '''Remove IRC String formatting codes''' + # - Regex - + # Capture "x03N,M". Should be the first called: + # (\\x03[0-9]{0,2},{1}[0-9]{1,2}) + # Capture "x03N". Catch all color codes. + # (\\x03[0-9]{0,2}) + # Capture the other formatting codes + line = re.sub(r'(\\x03[0-9]{0,2},{1}[0-9]{1,2})', '', msg) + line = re.sub(r'(\\x03[0-9]{1,2})', '', line) + line = line.replace("\\x03", "") + line = line.replace("\\x02", "") + line = line.replace("\\x1d", "") + line = line.replace("\\x1D", "") + line = line.replace("\\x1f", "") + line = line.replace("\\x1F", "") + line = line.replace("\\x16", "") + line = line.replace("\\x0f", "") + line = line.replace("\\x0F", "") + return line + + +def convert_size(size_bytes): + # https://stackoverflow.com/ + # questions/5194057/better-way-to-convert-file-sizes-in-python + if size_bytes == 0: + return "0B" + size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") + i = int(math.floor(math.log(size_bytes, 1024))) + p = math.pow(1024, i) + s = round(size_bytes / p, 2) + return "%s %s" % (s, size_name[i]) + + +def get_url(msg): + '''Search a string for urls and return a list of them.''' + str_l = msg.split() + req_l = ["http://", "https://"] # add "." for parse urls without a scheme + urls = [u for u in str_l if any(r in u for r in req_l)] + # Avoid parsing IPv4s that are not complete (IPs like: 1.1): + # Useful when a scheme is not required to parse a URL. + # urls = [u for u in urls if u.count('.') == 3 or u.upper().isupper()] + return urls + + +def default_parser(u): + ''' + Visit each url and check if there is html content + served. If there is try to get the + tag. If there is not try to read the http headers + to find 'content-type' and 'content-length'. + ''' + data = "" + output = "" + try: + r = requests.get(u, stream=True, + headers={"user-agent": user_agent, + "Accept-Language": accept_lang}, + timeout=5) + except Exception: + return False + for i in r.iter_content(chunk_size=512, decode_unicode=False): + data += i.decode('utf-8', errors='ignore') + if len(data) > data_limit or '' in data.lower(): + break + r.close() + soup = bs4.BeautifulSoup(data, parser) + try: + output += soup.head.title.text.strip() + except Exception: + try: + output += r.headers['content-type'] + except KeyError: + pass + try: + h_length = convert_size(float(r.headers['content-length'])) + if output: + output += f", Size: {h_length}" + else: + output += h_length + except KeyError: + pass + try: + if "RTA-5042-1996-1400-1577-RTA" in data: + output = f"{nsfw_tag} {output}" + elif r.headers["Rating"] == "RTA-5042-1996-1400-1577-RTA": + output = f"{nsfw_tag} {output}" + except KeyError: + pass + return output, data + + +# # +# BEGIN: Website Handling Functions (by url) # +# # +def youtube(url): + '''Visit a video and get it's information.''' + logo = "\x0300,04 ► \x0F" + u = f"https://www.youtube.com/oembed?url={url}" + r = requests.get(u, timeout=10) + if r: + j = r.json() + return (f"{logo}: {j['title']}" + f" | \x02Channel:\x0F {j['author_name']}") + else: + out = default_parser(url)[0] + return f"{logo}: {out}" + + +def lainchan(url): + logo = "\x0309lainchan\x0F" + if "/res/" in url: + board = url.split("lainchan.org/")[1].split("/", 1)[0] + board = urllib.parse.unquote(board) + u = url.replace(".html", ".json") + post_no = False + if ".html#" in url: + post_no = url.split("#")[1][1:] + r = requests.get(u, timeout=10).json() + try: + title = r["posts"][0]["sub"] + except KeyError: + title = f'{r["posts"][0]["com"][:80]}...' + replies = len(r["posts"]) - 1 + files = 0 + for i in r["posts"]: + if "filename" in i: + files += 1 + if "extra_files" in i: + files += len(i["extra_files"]) + if post_no: + for i in r["posts"]: + if int(post_no) != i["no"]: + continue + post_text = bs4.BeautifulSoup(i["com"], parser).get_text()[:50] + return (f"{logo} \x0306/{board}/\x0F {title} " + f"\x02->\x0F \x0302{post_text}...\x0F | " + f"\x02Replies:\x0F {replies} - \x02Files:\x0F {files}") + + return (f"{logo} \x0306/{board}/\x0F {title} | " + f"\x02Replies:\x0F {replies} - \x02Files:\x0F {files}") + else: + out = default_parser(url)[0] + return f"{logo}: {out}" + + +def imgur(url): + try: + up = urllib.parse.urlparse(url) + host = up.hostname + path = up.path + if host[:2] == "i.": + host = host[2:] + path = path.rsplit(".", 1)[0] + u = f"https://{host}{path}" + else: + u = url + + r = requests.get(u, timeout=10) + s = "widgetFactory.mergeConfig('gallery', " + b = r.text.index(s) + len(s) + e = r.text.index(");", b) + t = r.text[b:e] + + s = "image :" + b = t.index(s) + len(s) + e = t.index("},", b) + t = t[b:e] + "}" + + j = json.loads(t) + title = j["title"] + mimetype = j["mimetype"] + size = j["size"] + width = j["width"] + height = j["height"] + nsfw = j["nsfw"] + + output = "" + if nsfw: + output += f"{nsfw_tag} " + output += f"{title} - Imgur" + output += f" | {mimetype}, Size: {convert_size(size)}" + output += f", {width}x{height}" + return output + except Exception: + return default_parser(url)[0] + + +def nitter(url): + logo = "\x02Nitter\x0f" + output, data = default_parser(url) + try: + soup = bs4.BeautifulSoup(data, parser) + user = soup.find(attrs={"property": "og:title"})['content'] + post = soup.find(attrs={"property": "og:description"})['content'] + if post: + return f"{logo}: \x0305{user}\x0f {post}" + return output + except Exception: + return output + + +def twitter(url): + logo = "\x0311twitter\x0F" + u = f"https://publish.twitter.com/oembed?url={url}" + r = requests.get(u, timeout=10, + headers={"user-agent": user_agent, + "Accept-Language": accept_lang}) + if r: + j = r.json() + html = j["html"] + soup = bs4.BeautifulSoup(html, parser) + tweet = soup.get_text(separator=" ") + return f"{logo}: {tweet}" + else: + out = default_parser(url)[0] + return f"{logo}: {out}" +# # +# END: Website Handling Functions (by url) # +# # + + +hosts_d = { + "youtube.com": youtube, + "youtu.be": youtube, + "lainchan.org": lainchan, + "i.imgur.com": imgur, + "imgur.com": imgur, + "nitter.net": nitter, + "twitter.com": twitter +} + + +def _get_title_from_host(u): + host = urllib.parse.urlparse(u).hostname + if host[:4] == "www.": + host = host[4:] + if host not in hosts_d: + return default_parser(u) # It's a tuple + else: + return hosts_d[host](u), False + + +# # +# BEGIN: Website Handling Functions (by title) # +# # +def pleroma(data): + logo = "\x0308Pleroma\x0F" + soup = bs4.BeautifulSoup(data, parser) + t = soup.find(attrs={"property": "og:description"})['content'] + t = t.split(": ", 1) + poster = t[0] + post = t[1] + return f"{logo}: \x0305{poster}\x0F {post}" +# # +# END: Website Handling Functions (by title) # +# # + + +titles_d = { + "Pleroma": pleroma +} + + +def _get_title_from_title(title, data): + ''' + Used to get data from the when the isn't very helpful + ''' + if title in titles_d: + try: + return titles_d[title](data) + except Exception: + return title + else: + return title + + +def get_title(u): + title, data = _get_title_from_host(u) + if data: + title = _get_title_from_title(title, data) + return title + + +def main(i, irc): + # - Raw undecoded message clean up. + # Remove /r/n and whitespace + msg = i.msg_raw.strip() + # Convert the bytes to a string, + # split the irc commands from the text message, + # remove ' character from the end of the string. + msg = str(msg).split(' :', 1)[1][:-1] + # Remove all IRC formatting codes + msg = remove_formatting(msg) + # msg = info[2] + + urls = get_url(msg) + prev_u = set() # Already visited URLs, used to avoid spamming. + for u in urls: + if not (u.startswith('http://') or u.startswith('https://')): + u = f'http://{u}' + if u in prev_u: + return + title = get_title(u) + if not title: + continue + + irc.privmsg(i.channel, title) + prev_u.add(u) diff --git a/src/irc/modules/user_auth.py b/src/irc/modules/user_auth.py new file mode 100644 index 0000000..f5fe395 --- /dev/null +++ b/src/irc/modules/user_auth.py @@ -0,0 +1,64 @@ +# coding=utf-8 + +# This is a core module for Drastikbot. +# It provides functions for checking a user's authentication status. + +''' +Copyright (C) 2018-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 time + + +class Module: + def __init__(self): + self.msgtypes = ['NOTICE'] + self.commands = ['whois'] + self.auto = True + + +def user_auth(i, irc, nickname, timeout=10): + to = time.time() + timeout + irc.privmsg("NickServ", f"ACC {nickname}") + irc.privmsg("NickServ", f"STATUS {nickname}") + i.varset(nickname, "_pending") + while True: + time.sleep(.5) + auth = i.varget(nickname) + if auth != "_pending": + return auth + if time.time() > to: + return False + + +def nickserv_handler(i): + ls = i.msg_params.split() + if ls[1] == 'ACC' and i.varget(ls[0]) == "_pending": + if '3' in i.msg_params: + i.varset(ls[0], True) + else: + i.varset(ls[0], False) + elif ls[0] == 'STATUS' and i.varget(ls[1]) == "_pending": + if '3' in i.msg_params: + i.varset(ls[1], True) + else: + i.varset(ls[1], False) + + +def main(i, irc): + if i.nickname == 'NickServ': + nickserv_handler(i) diff --git a/src/irc/modules/weather.py b/src/irc/modules/weather.py new file mode 100644 index 0000000..b15624f --- /dev/null +++ b/src/irc/modules/weather.py @@ -0,0 +1,400 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# Weather Module for Drastikbot +# +# Provides weather information from http://wttr.in +# +# Depends: +# - requests :: $ pip3 install requests + +''' +Copyright (C) 2018 drastik.org + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +any later version. + +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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see <http://www.gnu.org/licenses/>. +''' + +import urllib.parse +import requests +from user_auth import user_auth + + +class Module: + def __init__(self): + self.commands = ['weather', 'weather_set', 'weather_auth'] + self.manual = { + "desc": "Show weather information from http://wttr.in", + "bot_commands": { + "weather": { + "usage": lambda x: ( + f"{x}weather <location / airport code / @domain" + " / IP address / area code / GPS coordinates>" + ), + "info": "Get weather information." + }, + "weather_set": { + "usage": lambda x: ( + f"{x}weather_set <location / airport code / @domain" + " / IP address / area code / GPS coordinates>" + ), + "info": ( + "Set your default location. If a location has been" + " set, calling the weather command without arguments" + " will return the weather for that location, otherwise" + " you will be asked to provide a location. To unset" + " your location use .weather_set without any" + " arguements." + ) + }, + "weather_auth": { + "usage": lambda x: f"{x}weather_auth", + "info": "Toggle NickServ authentication for weather_set" + } + } + } + + +# Helper functions: + +def unit_swap(unit): + """Change the unit string from °C to °F or from km/h to mph and opposite""" + unit_d = { + "°C": "°F", + "°F": "°C", + "km/h": "mph", + "mph": "km/h" + } + return unit_d.get(unit) + + +# Temperature + +def temperature_color(temperature, unit_in, unit_out): + """Colorize and convert the temperature.""" + tempcolor_d = { # celsius: color + -12: "02", -9: "12", -6: "11", 2: "10", + 10: "03", 19: "09", 28: "08", 37: "07" + } + if "°C" == unit_in: + celsius = int(temperature.split("(")[0]) + fahrenheit = celsius * 1.8 + 32 + fahrenheit = int(round(fahrenheit, 0)) + elif "°F" == unit_in: + fahrenheit = int(temperature.split("(")[0]) + celsius = (fahrenheit - 32) / 1.8 + celsius = int(round(celsius, 0)) + else: + return "invalid input unit" + + for temp, color in tempcolor_d.items(): + if celsius <= temp: + if "°C" == unit_out: + return f"\x03{color} {celsius}\x0F" + elif "°F" == unit_out: + return f"\x03{color} {fahrenheit}\x0F" + + # Fallback for when the temperature is too high. + if "°C" == unit_out: + return f"\x0304 {celsius}\x0F" + elif "°F" == unit_out: + return f"\x0304 {fahrenheit}\x0F" + + +def temp_format_range(temp_list, unit, unit_s): + ret = "Temp:" + ret += f"{temperature_color(temp_list[0], unit, unit)} -" + ret += f"{temperature_color(temp_list[1], unit, unit)} {unit} /" + ret += f"{temperature_color(temp_list[0], unit, unit_s)} -" + ret += f"{temperature_color(temp_list[1], unit, unit_s)} {unit_s}" + return ret + + +def temp_format(txt): + temperature_list = txt.split() # ['25..27', '°C'] + temperature = temperature_list[0] + # dashes = temperature.count('-') + unit = temperature_list[1] + unit_s = unit_swap(unit) + ret = "Temp:" + + if '..' in temperature: + temp_list = temperature.split('..') + ret = temp_format_range(temp_list, unit, unit_s) + else: + ret += f"{temperature_color(temperature, unit, unit)} {unit} /" + ret += f"{temperature_color(temperature, unit, unit_s)} {unit_s}" + + return ret + + +# Wind + +def wind_color(wind, unit_in, unit_out): + windcolor_d = { # km/h: color + 4: "03", 10: "09", 20: "08", 32: "07" + } + if "km/h" == unit_in: + kmh = int(wind) + mph = kmh * 0.6213711922 + mph = int(round(mph, 0)) + elif "mph" == unit_in: + mph = int(wind) + kmh = mph * 1.609344 + kmh = int(round(kmh, 0)) + # elif "m/s" == unit_in: + # ms = int(wind) + # kmh = ms * 3.6 + + for k, color in windcolor_d.items(): + if kmh < k: + if "km/h" == unit_out: + return f"\x03{color} {kmh}\x0F" + elif "mph" == unit_out: + return f"\x03{color} {mph}\x0F" + + # Fallback for when the wind speed is too high. + if "km/h" == unit_out: + return f"\x0304 {kmh}\x0F" + elif "mph" == unit_out: + return f"\x0304 {mph}\x0F" + + +def wind_format(txt): + def range_hdl(t, tempstr): + for idx, i in enumerate(t): + coltemp = wind_color(i, unit) + tempstr += f'{coltemp}' + if idx == 0: + tempstr += ' -' + return tempstr + + wind_list = txt.split() # ['↑', '23', 'km/h'] + icon = wind_list[0] + wind = wind_list[1] + unit = wind_list[2] + unit_s = unit_swap(unit) + dashes = wind.count('-') + ret = f'Wind: {icon}' + + if 1 == dashes: + # NEEDLESS? + wind_list = wind.split('-') + ret += f"{wind_color(wind_list[0], unit, unit)} -" + ret += f"{wind_color(wind_list[1], unit, unit)} {unit} /" + ret += f"{wind_color(wind_list[0], unit, unit_s)} -" + ret += f"{wind_color(wind_list[1], unit, unit_s)} {unit_s}" + else: # 17 km/h + ret += f"{wind_color(wind, unit, unit)} {unit} /" + ret += f"{wind_color(wind, unit, unit_s)} {unit_s}" + + return f'{ret}' + + +def handler(txt): + if ('°C' in txt) or ('°F' in txt): + return temp_format(txt) + elif ('km/h' in txt) or ('mph' in txt) or ('m/s' in txt): + return wind_format(txt) + elif ('km' in txt) or ('mi' in txt): + return f'Visibility: {txt}' + elif ('mm' in txt) or ('in' in txt): + return f'Rainfall: {txt}' + elif '%' in txt: + return f'Rain Prob: {txt}' + else: + return txt + + +# Ascii art set from: +# https://github.com/schachmat/wego/blob/master/frontends/ascii-art-table.go +art = ( + ' ', + ' .-. ', + ' __) ', + ' ( ', + ' `-᾿ ', + ' • ', + ' .--. ', + ' .-( ). ', + ' (___.__)__) ', + ' _ - _ - _ ', + ' _ - _ - _ - ', + ' ( ). ', + ' (___(__) ', + ' ‚ʻ‚ʻ‚ʻ‚ʻ ', + ' _`/"".-. ', + ' ,\\_( ). ', + ' /(___(__) ', + ' ‚ʻ‚ʻ‚ʻ‚ʻ ', + ' ‚’‚’‚’‚’ ', + ' ‚‘‚‘‚‘‚‘ ', + ' .-. ', + ' * * * * ', + ' * * * * ', + ' * * * * ', + ' ʻ ʻ ʻ ʻ ', + ' ʻ ʻ ʻ ʻ ', + ' ʻ ʻ ʻ ʻ ', + ' ‘ ‘ ‘ ‘ ', + ' ʻ * ʻ * ', + ' * ʻ * ʻ ', + ' ʻ * ʻ * ', + ' * ʻ * ʻ ', + ' * * * ', + ' * * * ', + ' * * * ', + ' \\ / ', + ' _ /"".-. ', + ' \\_( ). ', + ' \\ / ', + ' ‒ ( ) ‒ ', + ' / \\ ', + ' ‚ʻ⚡ʻ‚⚡‚ʻ ', + ' ‚ʻ‚ʻ⚡ʻ‚ʻ ', + ' ⚡ʻ ʻ⚡ʻ ʻ ', + ' *⚡ *⚡ * ', + ' ― ( ) ― ', + ' `-’ ', + ' ⚡‘‘⚡‘‘ ' +) + + +def wttr(irc, channel, location): + if location.lower() == 'moon' or 'moon@' in location.lower(): + irc.privmsg(channel, 'This is not supported yet ' + '(add ,+US or ,+France for these cities)') + return + + location = urllib.parse.quote_plus(location) + url = f'http://wttr.in/{location}?0Tm' + r = requests.get(url, timeout=10).text.splitlines() + text = '' + for line in r: + for i in art: + line = line.replace(i, '') + if line: + line = handler(line) + text += f'{line} | ' + + text = " ".join(text.split()) # Remove additional spaces. + text = text.lstrip("Rainfall: ") # Remove 'Rainfall: ' from the front. + if "ERROR: Unknown location:" in text: + text = f'\x0304wttr.in: Location "{location}" could not be found.' + elif "API key has reached calls per day allowed limit." in text\ + or ("Sorry, we are running out of queries to the weather service at " + "the moment.") in text: + text = "\x0304wttr.in: API call limit reached. Try again tomorrow." + + irc.privmsg(channel, text) + + +# Authentication + +def set_auth(i, irc, dbc): + if not user_auth(i, irc, i.nickname): + return f"{i.nickname}: You are not logged in with NickServ." + + dbc.execute('SELECT auth FROM weather WHERE nickname=?;', + (i.nickname,)) + fetch = dbc.fetchone() + + try: + auth = fetch[0] + except TypeError: # 'NoneType' object is not subscriptable + auth = 0 + + if auth == 0: + auth = 1 + msg = f'{i.nickname}: weather: Enabled NickServ authentication.' + elif auth == 1: + auth = 0 + msg = f'{i.nickname}: weather: Disabled NickServ authentication.' + + dbc.execute( + "INSERT OR IGNORE INTO weather (nickname, auth) VALUES (?, ?);", + (i.nickname, auth)) + dbc.execute("UPDATE weather SET auth=? WHERE nickname=?;", + (auth, i.nickname)) + return msg + + +def get_auth(i, irc, dbc): + dbc.execute('SELECT auth FROM weather WHERE nickname=?;', (i.nickname,)) + fetch = dbc.fetchone() + try: + auth = fetch[0] + except TypeError: # 'NoneType' object is not subscriptable + auth = 0 + + if auth == 0: + return True + elif auth == 1 and user_auth(i, irc, i.nickname): + return True + else: + return False + + +def set_location(i, irc, dbc, location): + if not get_auth(i, irc, dbc): + return f"{i.nickname}: weather: NickServ authentication is required." + dbc.execute( + '''INSERT OR IGNORE INTO weather (nickname, location) + VALUES (?, ?);''', (i.nickname, location)) + dbc.execute('''UPDATE weather SET location=? WHERE nickname=?;''', + (location, i.nickname)) + return f'{i.nickname}: weather: Your location was set to "{location}"' + + +def get_location(dbc, nickname): + try: + dbc.execute( + 'SELECT location FROM weather WHERE nickname=?;', (nickname,)) + return dbc.fetchone()[0] + except Exception: + return False + + +def main(i, irc): + dbc = i.db[1].cursor() + + try: + dbc.execute( + '''CREATE TABLE IF NOT EXISTS weather (nickname TEXT COLLATE NOCASE + PRIMARY KEY, location TEXT, auth INTEGER DEFAULT 0);''') + except Exception: + # sqlite3.OperationalError: cannot commit - no transaction is active + pass + + if "weather" == i.cmd: + + if not i.msg_nocmd: + location = get_location(dbc, i.nickname) + if location: + wttr(irc, i.channel, location) + else: + msg = (f'Usage: {i.cmd_prefix}{i.cmd} ' + '<Location / Airport code / @domain / ' + 'IP address / Area code / GPS coordinates>') + irc.privmsg(i.channel, msg) + else: + wttr(irc, i.channel, i.msg_nocmd) + + elif "weather_set" == i.cmd: + ret = set_location(i, irc, dbc, i.msg_nocmd) + i.db[1].commit() + irc.privmsg(i.channel, ret) + elif "weather_auth" == i.cmd: + ret = set_auth(i, irc, dbc) + i.db[1].commit() + irc.privmsg(i.channel, ret) diff --git a/src/irc/modules/wikipedia.py b/src/irc/modules/wikipedia.py new file mode 100644 index 0000000..c3436f7 --- /dev/null +++ b/src/irc/modules/wikipedia.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# Wikipedia Module for Drastikbot +# +# NOTE: This module is making use of the MediaWiki API, +# so it should work with other MediaWiki based websites. +# +# Depends: +# - requests :: $ pip3 install requests +# - beautifulsoup4 :: $ pip3 install beautifulsoup4 + +''' +Copyright (C) 2017 drastik.org + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +any later version. + +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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see <http://www.gnu.org/licenses/>. +''' + +import requests +import bs4 +import urllib.parse +from dbot_tools import Config, p_truncate + + +class Module: + def __init__(self): + self.commands = ['wikipedia', 'wiki', 'w'] + + usage = lambda x, y: (f"{x}{y} <article> [--full] [--search]" + " [--sections] [-l <lang>] [--resuult <num>]") + info = ("--info: Get the full section in a query." + " / --search: Search and get the results in a query." + " / --sections: Get all the sections of an article in a query." + " / -l: Post an article from a specific language" + " / --result: Select specific result." + " <num> is the index of the result returned by --search" + " / Use #section after the article's name to get a specific" + " section. Example: .w irc#Technical information") + self.manual = { + "desc": ("Search wikipedia and post a snippet from the resulting" + " article."), + "bot_commands": { + "wikipedia": {"usage": lambda x: usage(x, "wikipedia"), + "info": info, + "alias": ["w", "wiki"]}, + "wiki": {"usage": lambda x: usage(x, "wiki"), + "info": info, + "alias": ["w", "wikipedia"]}, + "w": {"usage": lambda x: usage(x, "w"), + "info": info, + "alias": ["wikipedia", "wiki"]} + } + } + + +# ----- Global Constants ----- # +r_timeout = 10 +bs4_parser = 'html.parser' +# ---------------------------- # + + +def language(args, config, channel): + '''Set the language used to search for wikipedia articles''' + if '-l' in args: + # Check if the language command has been used and + # use the given value instead of the configuration + index = args.index('-l') + return args[index + 1] + else: + # Try loading from the configuration + try: + # Check the configuration file for per channel language settings. + return config['irc']['modules']['wikipedia']['channels'][channel] + except KeyError: + try: + # Check the configuration file for global language settings + return config['irc']['modules']['wikipedia']['lang'] + except KeyError: + # Return English if all above fails + return 'en' + + +def mw_opensearch(query, url, max_results=1): + ''' + Uses the MediaWiki API:Opensearch + https://en.wikipedia.org/w/api.php?action=help&modules=opensearch + + Search MediaWiki for articles relevant to the search 'query' + It returns a [query,[titles],[descriptions],[urls]] of relevant + results to the search query. + + 'query' is the string to search for + 'url' is the url of the MediaWiki website + 'max_results' is the maximum amount of results to get + ''' + u = (f'{url}/w/api.php' + f'?action=opensearch&format=json&limit={max_results}&search={query}') + r = requests.get(u, timeout=r_timeout) + return r.json() + + +def mw_list_sections(page, url): + ''' + Uses the MediaWiki API:Parsing_wikitext#parse + https://www.mediawiki.org/wiki/Special:MyLanguage/API:Parsing_wikitext#parse + + Get a list of all the available sections for a given article. + Returns a tuple with the title of the article and a + list [[sections],[indexes]] + + 'page' should be the name of the MediaWiki article as returned + by mw_opensearch() + 'url' is the url of the MediaWiki website + ''' + u = f'{url}/w/api.php?action=parse&format=json&prop=sections&page={page}' + r = requests.get(u, timeout=r_timeout) + parse = r.json() + title = parse['parse']['title'] + sections_ = parse['parse']['sections'] + section_list = [[], []] + for i in sections_: + section_list[0].append(i['line']) + section_list[1].append(i['index']) + return (title, section_list) + + +def text_cleanup(soup): + try: + for sup in soup('sup'): + soup.sup.decompose() + except AttributeError: + pass + try: + for small in soup('small'): + soup.small.decompose() + except AttributeError: + pass + return soup + + +def mw_parse_intro(url, page, limit): + ''' + Uses the MediaWiki API:Parsing_wikitext#parse + https://www.mediawiki.org/wiki/Special:MyLanguage/API:Parsing_wikitext#parse + + This function calls the MediaWiki API, which returns a JSON + document containing the html of the introduction section + of an article, which is parsed by beautifulsoup4, limited + to the specified amount of characters and returned. + + 'url' is the url of the MediaWiki website + 'page' should be the name of the MediaWiki article as + returned by mw_opensearch() + 'limit' if True truncate the text + ''' + u = (f'{url}/w/api.php' + f'?action=parse&format=json&prop=text§ion=0&page={page}') + r = requests.get(u, timeout=r_timeout) + html = r.json()['parse']['text']['*'] + soup = bs4.BeautifulSoup(html, bs4_parser) + soup = text_cleanup(soup) + text = soup.find('p').text + if text == 'Redirect to:': + n_title = soup.find('a').text + n_text = mw_parse_intro(url, n_title, limit) + text = f'\x0302[Redirect to: {n_title}]\x0F {n_text}' + if limit: + text = p_truncate(text, msg_len, 85, True) + return text + + +def mw_parse_section(url, section_list, page, sect, limit): + ''' + Uses the MediaWiki API:Parsing_wikitext#parse + https://www.mediawiki.org/wiki/Special:MyLanguage/API:Parsing_wikitext#parse + + This function finds the position of the section ('sect') + requested in 'section_list' and calls the MediaWiki API, + which returns a JSON document containing the html of the + requested section which is parsed by beautifulsoup4, + limited to the specified amount of characters and returned. + + 'url' is the url of the MediaWiki website + 'section_list' is the second item returned by mw_list_sections() + 'page' should be the name of the MediaWiki article as + returned by mw_opensearch() + 'sect' is the section requested to be viewed + 'limit' if True truncate the text + ''' + id_index = section_list[0].index(sect) + u = (f'{url}/w/api.php' + '?action=parse&format=json&prop=text' + f'§ion={section_list[1][id_index]}&page={page}') + r = requests.get(u, timeout=r_timeout) + html = r.json()['parse']['text']['*'] + soup = bs4.BeautifulSoup(html, bs4_parser) + soup = text_cleanup(soup) + text = soup.find('span', id=sect) + text = text.find_next('p').text + if limit: + text = p_truncate(text, msg_len, 85, True) + return text + + +def str2url(url): + return urllib.parse.quote_plus(url) + + +def query(args): + # Get the args list and the commands + # Join the list to a string and return + _args = args[:] + cmds = ['--search', '--sections', '--full'] + cmds_args = ['--result', '-r', '-l'] + for i in cmds_args: + try: + idx = _args.index(i) + del _args[idx] + del _args[idx] + except ValueError: + pass + for i in cmds: + try: + idx = _args.index(i) + del _args[idx] + except ValueError: + pass + return ' '.join(_args) + + +def main(i, irc): + if not i.msg_nocmd: + msg = (f'Usage: {i.cmd_prefix}{i.cmd} <Article> ' + '[--full, --search, --sections -l], [--result <NUM>]') + return irc.privmsg(i.channel, msg) + + channel = i.channel + args = i.msg_nocmd.split() + config = Config(irc.cd).read() + lang = language(args, config, i.channel) + # Do not put a "/" slash at the end of the url + mediawiki_url = f'https://{lang}.wikipedia.org' + logo = '\x0301,00Wikipedia\x0F' + limit = True + search_q = query(args) + global msg_len + msg_len = irc.var.msg_len - 9 - 22 + + if '--search' in args: + opensearch = mw_opensearch(search_q, mediawiki_url, 10) + rs_string = '' + for n in opensearch[1]: + rs_string += f'[{opensearch[1].index(n) + 1}:{n}] ' + msg = (f'{logo}: \x0302[search results for: ' + f'{search_q}]\x0F: {rs_string}') + return irc.privmsg(i.nickname, msg) + + if '--full' in args: + limit = False + channel = i.nickname + + if '--result' in args or '-r' in args: + try: + r_index = args.index('--result') + except ValueError: + r_index = args.index('-r') + os_limit = int(args[r_index + 1]) + opensearch = mw_opensearch(search_q, mediawiki_url, os_limit) + try: + title = opensearch[1][os_limit - 1] + except IndexError: + msg = f'{logo}: No article was found for \x02{search_q}\x0F' + return irc.privmsg(channel, msg) + else: + opensearch = mw_opensearch(search_q, mediawiki_url) + try: + title = opensearch[1][0] + except IndexError: + msg = f'{logo}: No article was found for \x02{search_q}\x0F' + return irc.privmsg(channel, msg) + wikiurl = f'{mediawiki_url}/wiki/{title.replace(" ", "_")}' + + if '--sections' in args: + sections_out = mw_list_sections(title, mediawiki_url) + sec_out_str = ' | '.join(sections_out[1][0]) + msg = (f'{logo}: \x0302 [sections for {sections_out[0]}]\x0F: ' + f'{sec_out_str} [ {wikiurl} ]') + irc.privmsg(i.nickname, msg) + elif '#' in search_q: + ts_list = search_q.split('#') + sections_out = mw_list_sections(title, mediawiki_url) + snippet = mw_parse_section(mediawiki_url, sections_out[1], + title, ts_list[1], limit) + msg = f'{logo}: \x02{title}#{ts_list[1]}\x0F | {snippet} | {wikiurl}' + irc.privmsg(channel, msg) + else: + snippet = mw_parse_intro(mediawiki_url, title, limit) + msg = f'{logo}: \x02{title}\x0F | {snippet} | {wikiurl}' + irc.privmsg(channel, msg) diff --git a/src/irc/modules/wiktionary.py b/src/irc/modules/wiktionary.py new file mode 100644 index 0000000..fef4e60 --- /dev/null +++ b/src/irc/modules/wiktionary.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# Wiktionary Module for Drastikbot +# +# Depends: +# - requests :: $ pip3 install requests +# - beautifulsoup4 :: $ pip3 install beautifulsoup4 + +''' +Copyright (C) 2018 drastik.org + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +any later version. + +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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see <http://www.gnu.org/licenses/>. +''' + +import requests +import bs4 +import re +from dbot_tools import p_truncate + + +class Module: + def __init__(self): + self.commands = ['wiktionary', 'wt'] + + usage = lambda x, y: f"{x}{y} <word> [-e <num>]" + info = ("The -e option allows you to choose other defintions." + " The number of definitions is listed in parenthesis in the" + " result. In a query, the bot will post the full definitions" + " without truncating the text.") + self.manual = { + "desc": "Search https://en.wiktionary.org/ for word definitions.", + "bot_commands": { + "wiktionary": {"usage": lambda x: usage(x, "wiktionary"), + "info": info, + "alias": ["wt"]}, + "wt": {"usage": lambda x: usage(x, "wt"), + "info": info, + "alias": ["wiktionary"]} + } + } + + +# ----- Global Constants ----- # +r_timeout = 10 +bs4_parser = 'html.parser' +# ---------------------------- # + + +def get_text(html, etymology): + soup = bs4.BeautifulSoup(html, bs4_parser) + + result = {} + result[etymology] = {} + + s_et = soup.find('span', id=etymology) + result[etymology]["Etymology"] = s_et.find_next('p').text + + ids = ("Noun", "Verb", "Adjective", "Adverb", + "Interjection", "Particle", "Preposition") + for i in ids: + s = s_et.find_next('span', string=i) + try: + txt = s.find_next('ol').text + result[etymology][i] = txt + except Exception as e: + pass + + return result + + +def extract_etymologies(html): + result = {} + count = 1 + while(True): + if 'id="Etymology"' in html: + result.update(get_text(html, "Etymology")) + break + elif f'id="Etymology_{count}"' in html: + result.update(get_text(html, f"Etymology_{count}")) + count += 1 + else: + break + + return result + + +def wiktionary(url, res): + r = requests.get(url, timeout=r_timeout) + + # Extract the html of a single language section. + section_end = '<hr' + html = "" + for t in r.text.splitlines(): + html += t + if section_end in t: + break + + # Remove quotations so that beautifulsoup doesn't catch them. + html = re.compile(r'(?ims)<ul>.*?</ul>').sub('', html) + html = re.compile(r'(?ims)<dl>.*?</dl>').sub('', html) + html = re.compile(r'(?ims)<span class="defdate">.*?</span>').sub('', html) + return extract_etymologies(html) + + +def query(args): + # Get the args list and the commands + # Join the list to a string and return + _args = args[:] + try: + idx = _args.index('-e') + del _args[idx] + del _args[idx] + except ValueError: + pass + return ' '.join(_args) + + +def main(i, irc): + if not i.msg_nocmd: + msg = (f'Usage: {i.cmd_prefix}{i.cmd} <Word> [-e <NUM>]') + return irc.privmsg(i.channel, msg) + + args = i.msg_nocmd.split() + + if '-e' in args: + idx = args.index('-e') + res = int(args[idx + 1]) + else: + res = 1 + + q = query(args) + q_web = q.replace(" ", "_") + url = f"https://en.wiktionary.org/wiki/{q_web}" + result = wiktionary(url, res) + result_length = len(result) + + if res not in range(1, result_length + 1): + msg = f'Wiktionary: No definition was found for "{q}" | {url}' + return irc.privmsg(i.channel, msg) + + if res == 1: + try: + result = result["Etymology"] + except KeyError: + result = result["Etymology_1"] + else: + result = result[f"Etymology_{res}"] + + msg_len = (irc.var.msg_len - 9 - 8 - len(str(result_length)) + - (len(result) * 5) - len(url)) + p_tr_percent = 100 / len(result) + + txt = "" + for part, cont in result.items(): + rslt = f"{part}: {cont}" + if not i.nickname == i.channel: + rslt = p_truncate(rslt, msg_len, p_tr_percent, True) + txt += f"{rslt} | " + + rpl = f"{q} | {txt}({result_length}) | {url}" + irc.privmsg(i.channel, rpl) diff --git a/src/irc/modules/wolframalpha.py b/src/irc/modules/wolframalpha.py new file mode 100644 index 0000000..cbf9303 --- /dev/null +++ b/src/irc/modules/wolframalpha.py @@ -0,0 +1,63 @@ +# coding=utf-8 + +# WolframAlpha module for drastikbot +# +# It makes use of the Short Answers API to get answers for user +# queries. +# +# You need to use you own AppID to use this module. Find out more: +# http://products.wolframalpha.com/short-answers-api/documentation/ + +''' +Copyright (C) 2019 drastik.org + +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 urllib.parse +import requests + + +class Module: + def __init__(self): + self.commands = ['wa', 'wolfram', 'wolframalpha'] + self.manual = { + "desc": "Get results from the Wolfram|Alpha short answers API.", + "bot_commands": { + "wa": {"usage": lambda x: f"{x}wa <query>", + "alias": ["wolfram", "wolframalpha"]}, + "wolfram": {"usage": lambda x: f"{x}wolfram <query>", + "alias": ["w", "wolframalpha"]}, + "wolframalpha": {"usage": lambda x: f"{x}wolframalpha <query>", + "alias": ["wolfram", "w"]} + } + } + + +AppID = "Enter your AppID here" + + +def short_answers(query): + url = f"http://api.wolframalpha.com/v1/result?appid={AppID}&i={query}" + try: + r = requests.get(url, timeout=10) + except Exception: + return False + return r.text + + +def main(i, irc): + query = urllib.parse.quote_plus(i.msg_nocmd) + r = short_answers(query) + r = f"Wolfram|Alpha: {r}" + irc.privmsg(i.channel, r) diff --git a/src/irc/modules/yn.py b/src/irc/modules/yn.py new file mode 100644 index 0000000..abcb9bb --- /dev/null +++ b/src/irc/modules/yn.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# Virtual coin flip + +''' +Copyright (c) 2018 Flisk <flisk@fastmail.de> + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +''' + +import random + + +class Module: + def __init__(self): + self.commands = ['yn'] + self.manual = { + "desc": "Flip a virtual coin", + "bot_commands": { + "yn": {"usage": lambda x: f"{x}yn"} + } + } + + +def main(i, irc): + irc.privmsg( + i.channel, + f"{i.nickname}: {'Yes' if random.randint(0, 1) == 1 else 'No'}." + ) diff --git a/src/irc/modules/youtube.py b/src/irc/modules/youtube.py new file mode 100644 index 0000000..e0a396c --- /dev/null +++ b/src/irc/modules/youtube.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# YouTube Module for Drastikbot +# +# Search YouTube and return the resulting video. +# When a YouTube url is posted return the video's information. +# +# If you are planning to use the url module or a url bot, consider adding the +# following blacklist: ['youtu.be/', 'youtube.com/watch'] +# +# Depends: +# - requests :: $ pip3 install requests +# - beautifulsoup :: $ pip3 install beautifulsoup4 + +''' +Copyright (C) 2018-2020 drastik.org + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +any later version. + +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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see <http://www.gnu.org/licenses/>. +''' + +import urllib.parse +import json +import requests +import bs4 + +import url + + +class Module: + def __init__(self): + self.commands = ['yt'] + self.manual = { + "desc": "Search YouTube and return the resulting video url.", + "bot_commands": {"yt": {"usage": lambda x: f"{x}yt <video title>"}} + } + + +# ----- Constants ----- # +parser = 'html.parser' +lang = "en-US" +user_agent = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64)" + " AppleWebKit/537.36 (KHTML, like Gecko)" + " Chrome/83.0.4103.116 Safari/537.36") +# --------------------- # + + +# +# New Parser +# + +def yt_vid_info(v): + yt_id = v["videoId"] + short_url = f"https://youtu.be/{yt_id}" + name = v["title"]["runs"][0]["text"] + # Usually youtube's automated music uploads dont have dates. + try: + date = v["publishedTimeText"]["simpleText"] + except KeyError: + date = False + duration = v["lengthText"]["simpleText"] + views = v["viewCountText"]["simpleText"] + channel = v["ownerText"]["runs"][0]["text"] + + return { + 'short_url': short_url, 'name': name, 'date': date, 'views': views, + 'channel': channel, 'duration': duration, 'yt_id': yt_id + } + + +def yt_search(query): + search = (f'https://www.youtube.com/results?search_query={query}' + '&app=desktop') + r = requests.get(search, timeout=10, + headers={"Accept-Language": lang, + "user-agent": user_agent}) + + try: + st = 'var ytInitialData = ' + st_i = r.text.index(st) + len(st) + except ValueError: + st = 'window["ytInitialData"] = ' + st_i = r.text.index(st) + len(st) + + j_data = r.text[st_i:] + + st = '};' + st_i = j_data.index(st) + + j_data = j_data[:st_i+1] + j = json.loads(j_data) + + res = j["contents"]['twoColumnSearchResultsRenderer']['primaryContents'][ + 'sectionListRenderer']['contents'][0]['itemSectionRenderer'][ + 'contents'] # What in the world? + + for vid in res: + if "videoRenderer" in vid: + v = vid["videoRenderer"] # Video information + return yt_vid_info(v) + elif 'searchPyvRenderer' in vid: + continue # Skip promoted videos + + +def output(i): + '''Format the output message to be returned.''' + # logo_yt = "\x0301,00You\x0300,04Tube\x0F" + logo_yt = "\x0300,04 ► \x0F" + + if i["date"]: + date = f" | \x02Uploaded:\x0F {i['date']}" + else: + date = "" + + return (f"{logo_yt}: {i['short_url']} | " + f"\x02{i['name']}\x0F ({i['duration']})" + f" | \x02Views:\x0F {i['views']}" + f" | \x02Channel\x0F: {i['channel']}" + f"{date}") + + +# +# Old parser +# + +def yt_search_legacy(query): + ''' + Search YouTube for 'query' and get a video from the search results. + It tries to visit the video found to ensure that it is valid. + Returns: + - 'yt_id' : The YouTube ID of the result video. + - False : If no video is found for 'query'. + ''' + search = (f'https://m.youtube.com/results?search_query={query}') + r = requests.get(search, headers={"Accept-Language": lang}, timeout=10) + #print(r.text, file=open("output.html", "a")) + soup = bs4.BeautifulSoup(r.text, parser) + for s in soup.find_all('a', {'class': ['yt-uix-tile-link']}): + yt_id = urllib.parse.urlparse(s.get('href')).query + print(yt_id) + yt_id = urllib.parse.parse_qs(yt_id) + print(yt_id) + try: + yt_id = yt_id['v'][0] + except KeyError: + try: + yt_id = yt_id['video_id'][0] + except KeyError: + continue + yt_id = ''.join(yt_id.split()) + # Try to visit the url to make sure it's a valid one. + try: + u = f'https://m.youtube.com/watch?v={yt_id}' + requests.get(u, timeout=10) + break + except Exception: + pass + else: + return False + return yt_id + + +def output_legacy(yt_id): + '''Format the output message to be returned.''' + logo_yt = "\x0300,04 ► \x0F (legacy)" + short_url = f"https://youtu.be/{yt_id}" + title = url.youtube(short_url) + return f"{logo_yt}: {short_url} | {title}" + + +def main(i, irc): + if i.cmd: + query = urllib.parse.quote_plus(i.msg_nocmd) + try: + out = output(yt_search(query)) + except Exception as e: + print(e) + out = output_legacy(yt_search_legacy(query)) + irc.privmsg(i.channel, out) diff --git a/src/irc/worker.py b/src/irc/worker.py new file mode 100644 index 0000000..25b3351 --- /dev/null +++ b/src/irc/worker.py @@ -0,0 +1,293 @@ +# coding=utf-8 + +# Functionality for connecting, reconnecting, registering and pinging to the +# IRC server. Recieving messages from the server and calling the module handler +# is also done in this file. + +''' +Copyright (C) 2017-2020 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/>. +''' + +from threading import Thread +import os +import re +import base64 +import traceback + +from irc.message import Message +from dbot_tools import Logger +from irc.irc import Drastikbot +from irc.modules import Modules + + +class Register: + def __init__(self, irc): + self.irc = irc + self.msg = '' + # self.cap is used to indicate the status IRCv3 capability + # negotiation. + # Values: + # -1: Check for IRCv3 | 0: Ended or none + # 1: In progress | 2: Send CAP END + self.cap = -1 + self.nickserv_ghost_status = 0 # 0: None | 1: In progress + self.nickserv_recover_status = 0 # 0: None | 1: In progress + self.motd = False + + # --- IRCv3 --- # + def cap_ls(self): + self.irc.var.ircv3_serv = True + self.cap = 1 + self.irc.var.ircv3_cap_ls = re.search(r"(?:CAP .* LS :)(.*)", + self.msg.msg).group(1).split(' ') + cap_req = [i for i in self.irc.var.ircv3_cap_ls + if i in self.irc.var.ircv3_cap_req] + + # If the server doesnt support any capabilities we support end the + # registration now + if cap_req == []: + self.cap_end() + return + + self.irc.send(('CAP', 'REQ', ':{}'.format(' '.join(cap_req)))) + + def cap_ack(self): + cap_ack = re.search(r"(?:CAP .* ACK :)(.*)", self.msg.msg).group(1) + self.irc.var.ircv3_cap_ack = cap_ack.split() + + def cap_end(self): + self.irc.send(('CAP', 'END')) + self.cap = 0 + + # -- SASL # + def sasl_succ(self): + self.irc.var.sasl_state = 1 + self.cap = 2 + + def sasl_fail(self): + self.irc.var.sasl_state = 2 + self.cap = 2 + + def sasl_init(self): + if not self.irc.var.authentication.lower() == 'sasl' \ + or 'sasl' not in self.irc.var.ircv3_cap_ack: + self.sasl_fail() + else: + self.irc.send(('AUTHENTICATE', 'PLAIN')) + self.irc.var.sasl_state = 3 + + def sasl_auth(self): + username = self.irc.var.username + password = self.irc.var.auth_password + sasl_pass = f'{username}\0{username}\0{password}' + self.irc.send(('AUTHENTICATE', + base64.b64encode(sasl_pass.encode('utf-8')))) + + # --- NickServ --- # + def nickserv_identify(self): + nickname = self.irc.var.nickname + password = self.irc.var.auth_password + self.irc.privmsg('NickServ', f'IDENTIFY {nickname} {password}') + + def nickserv_recover(self): + nickname = self.irc.var.nickname + password = self.irc.var.auth_password + if self.irc.var.authentication and self.irc.var.auth_password \ + and self.nickserv_recover_status == 0: + self.irc.privmsg('NickServ', f'RECOVER {nickname} {password}') + self.nickserv_recover_status = 1 + elif "You have regained control" in self.msg.msg: + self.nickserv_recover_status = 0 + self.irc.var.curr_nickname = nickname + self.irc.var.alt_nickname = False + + def nickserv_ghost(self): + nickname = self.irc.var.nickname + password = self.irc.var.auth_password + if self.irc.var.authentication and self.irc.var.auth_password \ + and self.nickserv_ghost_status == 0: + self.irc.privmsg('NickServ', f'GHOST {nickname} {password}') + self.nickserv_ghost_status = 1 + elif "has been ghosted" in self.msg.params \ + and self.nickserv_ghost_status == 1: + self.irc.nick(nickname) + self.nickserv_identify() + self.nickserv_ghost_status = 0 + self.irc.var.curr_nickname = nickname + self.irc.var.alt_nickname = False + elif "/msg NickServ help" in self.msg.msg or \ + "/msg NickServ HELP" in self.msg.msg: + self.nickserv_ghost_status = 0 + self.nickserv_recover() + elif "is not a registered nickname" in self.msg.msg: + self.nickserv_ghost_status = 0 + + # --- Error Handlers --- # + def err_nicnameinuse_433(self): + self.irc.var.curr_nickname = self.irc.var.curr_nickname + '_' + self.irc.nick(self.irc.var.curr_nickname) + self.irc.var.alt_nickname = True + + # --- Registration Handlers --- # + def reg_init(self): + self.irc.send(('CAP', 'LS', self.irc.var.ircv3_ver)) + self.irc.send(('USER', self.irc.var.username, '0', '*', + f':{self.irc.var.realname}')) + self.irc.var.curr_nickname = self.irc.var.nickname + self.irc.nick(self.irc.var.nickname) + + def ircv3_fn_caller(self): + a = len(self.msg.cmd_ls) + cmd_ls = self.msg.cmd_ls + if a > 2 and 'CAP LS' == f'{cmd_ls[0]} {cmd_ls[2]}': + self.cap_ls() + elif a > 2 and 'CAP ACK' == f'{cmd_ls[0]} {cmd_ls[2]}': + self.cap_ack() + # ERR_NICKNAMEINUSE + if '433' in self.msg.cmd_ls[0]: + self.err_nicnameinuse_433() + # SASL + if self.irc.var.ircv3_cap_ack and self.irc.var.sasl_state == 0: + self.sasl_init() + elif 'AUTHENTICATE' in self.msg.msg: # AUTHENTICATE + + self.sasl_auth() + elif '903' == cmd_ls[0]: # SASL authentication successful + self.sasl_succ() + elif '904' == cmd_ls[0]: # SASL authentication failed + self.sasl_fail() + # End capability negotiation. + if self.cap == 2: + self.cap_end() + + def reg_fn_caller(self): + if '433' in self.msg.cmd_ls[0]: # ERR_NICKNAMEINUSE + self.err_nicnameinuse_433() + if '376' in self.msg.cmd_ls[0]: # RPL_ENDOFMOTD + self.motd = True + + if self.motd and self.irc.var.alt_nickname \ + and self.irc.var.authentication and self.nickserv_ghost_status == 0\ + and self.nickserv_recover_status == 0: + self.nickserv_ghost() + if self.motd and self.irc.var.authentication.lower() == 'nickserv': + self.nickserv_identify() + if self.nickserv_ghost_status == 1: + self.nickserv_ghost() + if self.nickserv_recover_status == 1: + self.nickserv_recover() + if self.motd and not self.nickserv_ghost_status == 1 \ + and not self.nickserv_recover_status == 1: + self.irc.var.conn_state = 2 + self.irc.join(self.irc.var.channels) + + def reg_main(self, msg): + self.msg = msg + if self.cap != 0: + # Check for IRCv3 methods + self.ircv3_fn_caller() + else: + # Run normal IRC registration methods + self.reg_fn_caller() + + +class Main: + def __init__(self, conf_dir, proj_path, mod=False): + self.irc = Drastikbot(conf_dir) + self.irc.var.proj_path = proj_path + if mod: + self.mod = mod + mod.irc = self.irc # Update the irc variable + else: + self.mod = Modules(self.irc) + self.reg = Register(self.irc) + self.log = Logger(conf_dir, 'runtime.log') + self.irc.var.log = self.log + + def conn_lost(self): + if self.irc.var.sigint: + return + self.log.info('<!> Connection Lost. Retrying in {} seconds.' + .format(self.irc.var.reconnect_delay)) + self.irc.irc_socket.close() + self.irc.var.conn_state = 0 + self.irc.reconn_wait() # Wait before next reconnection attempt. + self.log.info('> Reconnecting...') + # Reload the class + self.__init__(self.irc.cd, self.irc.var.proj_path, mod=self.mod) + self.main(reconnect=True) # Restart the bot + + def recieve(self): + while self.irc.var.conn_state != 0: + try: + msg_raw = self.irc.irc_socket.recv(4096) + except Exception: + self.log.debug('<!!!> Exception on recieve().' + f'\n{traceback.format_exc()}') + self.conn_lost() + break + + msg_raw_ls = msg_raw.split(b'\r\n') + for line in msg_raw_ls: + if line: + msg = Message(line) + if self.irc.var.conn_state == 2: + self.service(msg) + if 'PING' == msg.cmd_ls[0]: + self.irc.send(('PONG', msg.params)) + elif self.irc.var.conn_state == 1: + self.service(msg) + self.reg.reg_main(msg) + + if len(msg_raw) == 0: + self.log.info('<!> recieve() exiting...') + self.conn_lost() + break + + def service(self, msg): + msg.channel_prep(self.irc) + self.thread_make(self.mod.mod_main, + (self.irc, msg, msg.params.split(' ')[0])) + + def thread_make(self, target, args='', daemon=False): + thread = Thread(target=target, args=(args)) + thread.daemon = daemon + thread.start() + return thread + + def sigint_hdl(self, signum, frame): + if self.irc.var.sigint == 0: + self.log.info("\n> Quiting...") + self.irc.var.sigint += 1 + self.irc.var.conn_state = 0 + self.irc.quit() + else: + self.log.info("\n Force Quit.") + os._exit(1) + + def main(self, reconnect=False): + self.irc.connect() + if not reconnect: + self.mod.mod_import() + if self.irc.var.sigint: + return + self.irc.var.conn_state = 1 + self.thread_make(self.recieve) + reg_t = self.thread_make(self.reg.reg_init) + reg_t.join() # wait until registered + self.log.info(f"\nNickname: {self.irc.var.curr_nickname}") + if not reconnect: + self.thread_make(self.mod.mod_startup, (self.irc,)) diff --git a/src/toolbox/config_check.py b/src/toolbox/config_check.py new file mode 100644 index 0000000..7943a70 --- /dev/null +++ b/src/toolbox/config_check.py @@ -0,0 +1,213 @@ +# coding=utf-8 + +# Check the bot's configuration file, verify it, add any missing enties +# either by requesting user input or by setting default values. + +''' +Copyright (C) 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/>. +''' + +from pathlib import Path + +from dbot_tools import Logger, Config +from toolbox import user_acl + + +def _check_irc(conf_dir): + log = Logger(conf_dir, 'runtime.log') + c = Config(conf_dir).read() + if "irc" not in c: + c.update({"irc": {}}) + Config(conf_dir).write(c) + log.info("<*> Configuration: created 'irc' section.") + + +def _check_owners(conf_dir): + log = Logger(conf_dir, 'runtime.log') + c = Config(conf_dir).read() + if "owners" not in c["irc"]: + print("\nSetting up the bot's owners. Please enter a comma seperated " + "list of their IRC nicknames. The nicknames must be " + "registered with nickserv.") + o = input("> ") + o.replace(" ", "") + ls = o.split(",") + c["irc"].update({"owners": ls}) + Config(conf_dir).write(c) + log.info("<*> Configuration: setting up 'irc.owners' ...") + + +def _check_connection(conf_dir): + log = Logger(conf_dir, 'runtime.log') + c = Config(conf_dir).read() + if "connection" not in c["irc"]: + print("\nSetting up the IRC server connection details.") + + network = input("Hostname [chat.freenode.net]: ").replace(" ", "") + if not network: + network = "chat.freenode.net" + + port = input("Port [6697]: ").replace(" ", "") + if not port: + port = 6697 + else: + port = int(port) + + while (1): + ssl = input("SSL: (true, false) [true]").replace(" ", "").lower() + if not ssl or ssl == "true": + ssl = True + break + elif ssl == "false": + ssl = False + break + print("\nError Invalid input. Try again.\n") + + net_password = input("Network Password []: ") + + nickname = input("Nickname [drastikbot]: ").replace(" ", "") + if not nickname: + nickname = "drastikbot" + + username = input("Username [drastikbot]: ").replace(" ", "") + if not username: + username = "drastikbot" + + realname = input("Realname [drastikbot2]: ") + if not realname: + realname = "drastikbot2" + + while (1): + authentication = input("Authentication mode (nickserv, sasl)" + " [sasl]: ").replace(" ", "").lower() + if not authentication or authentication == "sasl": + authentication = "sasl" + break + elif authentication == "nickserv": + break + print("\nError Invalid input. Try again.\n") + + auth_password = input("Authentication Password []: ") + c["irc"].update({"connection": {"network": network, + "port": port, + "ssl": ssl, + "net_password": net_password, + "nickname": nickname, + "username": username, + "realname": realname, + "authentication": authentication, + "auth_password": auth_password}}) + Config(conf_dir).write(c) + log.info("<*> Configuration: setting up 'irc.connection' ...") + + +def _check_channels(conf_dir): + log = Logger(conf_dir, 'runtime.log') + c = Config(conf_dir).read() + if "channels" not in c["irc"]: + c["irc"].update({"channels": {}}) + if not c["irc"]["channels"]: + chan_list = {} + print("\nEnter the channels you want to join.") + while True: + ch = input("Channel (with #) (leave empty to exit): ") + ch.replace(" ", "") + if not ch: + break + ps = input("Password (leave empty if there is none): ") + chan_list[ch] = ps + c["irc"]["channels"].update(chan_list) + Config(conf_dir).write(c) + log.info("<*> Configuration: setting up 'irc.channels' ...") + + +def _check_modules(conf_dir): + log = Logger(conf_dir, 'runtime.log') + c = Config(conf_dir).read() + conf_w = Config(conf_dir).write + if "modules" not in c["irc"]: + c["irc"].update({"modules": {}}) + log.info("<*> Configuration: 'irc.modules' not found. " + "Creating...") + if "load" not in c["irc"]["modules"]: + c["irc"]["modules"].update({"load": []}) + log.info("<*> Configuration: 'irc.modules.load' not found. " + "Creating...") + if not c["irc"]["modules"]["load"]: + log.info("<*> Configuration: 'irc.modules.load' is empty. " + "No modules will be loaded. Edit the configuration file's " + "'irc.modules.load' section with the modules you want " + "to use and restart the bot.") + if "global_prefix" not in c["irc"]["modules"]: + c["irc"]["modules"].update({"global_prefix": "."}) + log.info("<*> Configuration: 'irc.modules.global_prefix' not found. " + "Setting default prefix '.'") + if "channel_prefix" not in c["irc"]["modules"]: + c["irc"]["modules"].update({"channel_prefix": {}}) + log.info("<*> Configuration: creating " + "'irc.modules.channel_prefix' ...") + if "blacklist" not in c["irc"]["modules"]: + c["irc"]["modules"].update({"blacklist": {}}) + log.info("<*> Configuration: creating 'irc.modules.blacklist' ...") + if "whitelist" not in c["irc"]["modules"]: + c["irc"]["modules"].update({"whitelist": {}}) + log.info("<*> Configuration: creating 'irc.modules.whitelist' ...") + conf_w(c) + + +def _check_user_acl(conf_dir): + log = Logger(conf_dir, 'runtime.log') + c = Config(conf_dir).read() + if "user_acl" not in c["irc"]: + c["irc"].update({"user_acl": []}) + log.info("<*> Configuration: setting up 'irc.user_acl' ...") + for i in c["irc"]["user_acl"]: + if user_acl.is_expired(i): + c["irc"]["user_acl"].remove(i) + log.info(f"<*> Configuration: removed expired mask: '{i}' from " + "'irc.user_acl' ...") + Config(conf_dir).write(c) + + +def _check_sys(conf_dir): + #log = Logger(conf_dir, 'runtime.log') + c = Config(conf_dir).read() + if "sys" not in c: + c.update({"sys": {}}) + #log.info("<*> Configuration: created 'sys' section.") + if "log_level" not in c["sys"]: + c["sys"].update({"log_level": "info"}) + #log.info("<*> Configuration: created 'sys.log_level'.") + Config(conf_dir).write(c) + + +def _check_exists(conf_dir): + p = Path(f"{conf_dir}/config.json") + if p.is_file(): + return + with open(p, "w") as f: + f.write("{}") + + +def config_check(conf_dir): + _check_exists(conf_dir) + _check_sys(conf_dir) + _check_irc(conf_dir) + _check_owners(conf_dir) + _check_connection(conf_dir) + _check_channels(conf_dir) + _check_modules(conf_dir) diff --git a/src/toolbox/user_acl.py b/src/toolbox/user_acl.py new file mode 100644 index 0000000..b767b63 --- /dev/null +++ b/src/toolbox/user_acl.py @@ -0,0 +1,118 @@ +# coding=utf-8 + +# This file implements checks for user access list rules. + +''' +Copyright (C) 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 datetime + + +def _m_user(m, user): + if m == user or m == "*": + return True + elif "*" == m[0] and m[1:] == user[-len(m[1:]):]: + return True + else: + return False + + +def _m_host(m, hostmask): + if m == hostmask or m == "*": + return True + elif "*" == m[0] and m[1:] == hostmask[-len(m[1:]):]: + return True + elif "*" == m[-1] and m[:-1] == hostmask[:len(m[:-1])]: + return True + else: + return False + + +def _check_time(timestamp): + if timestamp == 0: + return True + now = datetime.datetime.now(datetime.timezone.utc).timestamp() + if timestamp > now: + return True + else: + return False + + +def _check_module(m, module): + if m == '*': + return True + ls = m.split(',') + if module in ls: + return True + else: + return False + + +def _is_banned(mask, channel, nick, user, hostmask, module): + ''' + Check if a user is banned from using the bot. + mask : is a mask following the following format: + channel nickname!username@hostmask time modules. + The mask allows for the * wildcard in frond of + the username and hostmask. + + ''' + tmp = mask.split(" ", 1) + c = tmp[0] + tmp = tmp[1].split("!", 1) + n = tmp[0] + tmp = tmp[1].split("@", 1) + u = tmp[0] + tmp = tmp[1].split(" ", 1) + h = tmp[0] + tmp = tmp[1].split(" ", 1) + t = int(tmp[0]) + m = tmp[1] + + # Bans are not case sensitive + if (c.lower() == channel.lower() or c == '*') \ + and (n.lower() == nick.lower() or n == '*') \ + and _m_user(u.lower(), user.lower()) and _check_time(t) \ + and _m_host(h, hostmask) \ + and _check_module(m, module): + return True + else: + return False + + +def is_banned(user_acl, channel, nick, user, hostmask, module): + for i in user_acl: + if _is_banned(i, channel, nick, user, hostmask, module): + return True + return False + + +def is_expired(mask): + tmp = mask.split(" ", 1) + tmp = tmp[1].split("!", 1) + tmp = tmp[1].split("@", 1) + tmp = tmp[1].split(" ", 1) + tmp = tmp[1].split(" ", 1) + t = int(tmp[0]) + if t == 0: + return False + now = datetime.datetime.now(datetime.timezone.utc).timestamp() + if t < now: + return True + else: + return False