commit 43ff02296948de6eb794070b2626d733a44cd4c2 Author: +++ Date: Sun Jul 26 14:27:05 2020 +0200 initial commit for a jabberbot based on slixmpp diff --git a/common.py b/common.py new file mode 100644 index 0000000..1e07cd9 --- /dev/null +++ b/common.py @@ -0,0 +1,114 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- + +# file: common.py +# date: 24.07.2020 +# desc: common functions related to hackbot.py + + +import slixmpp + + +def get_type_from_stanza(stanza): + ''' + Returns the type of a stanza. + param 1: stanza object + returns: string or false + ''' + if isinstance(stanza, slixmpp.Message): + return stanza.get_type() + return False + +def get_body_from_stanza(stanza): + ''' + Extracts the body from the given stanza. + returns: string or false + ''' + message_type = get_type_from_stanza(stanza) + if message_type is not False: + if message_type == 'groupchat': + return stanza['body'] + elif message_type in ('chat', 'normal', 'error', 'headline'): + print('\tTyp not supported yet: {}'.format(message_type)) + return False + return False + +def get_command_from_body(stanza): + ''' + Checks if the given stanzas body starts with a command. Returns command + or false. + param 1: stanza object + returns: string or false + ''' + body = get_body_from_stanza(stanza) + if body is not False: + if body.lstrip().startswith('!'): + word = body.split()[0] + if word[1:].isalpha(): + return word[1:].lower() + return False + +def get_arguments_from_body(stanza): + ''' + Grabs all from body behind the leading word. + param 1: stanza object + returns: list or false + ''' + body = get_body_from_stanza(stanza) + if body is not False: + behind = body.split()[1:] + if len(behind) != 0: + return behind + return False + +def get_nick_from_stanza(stanza): + ''' + ''' + if isinstance(stanza, slixmpp.Message): + message_type = stanza.get_type() + + if message_type == 'groupchat': + return stanza.get_mucnick() + + elif isinstance(stanza, slixmpp.Presence): + jid = stanza.getFrom() + return jid.resource + + else: print('Unhandled message: {}'.format(str(stanza))) + + +# Elements and functions at a message object: + +# print('\nfrom: {}'.format(msg.get_from())) +# print('bare: {}'.format(msg.get_from().bare)) +# print('node: {}'.format(msg.get_from().node)) +# print('domain: {}'.format(msg.get_from().domain)) +# print('resource: {}'.format(msg.get_from().resource)) +# print('lang: {}'.format(msg.get_lang())) +# print('muc nick: {}'.format(msg.get_mucnick())) +# print('muc room: {}'.format(msg.get_mucroom())) +# print('parent thread: {}'.format(msg.get_parent_thread())) +# print('payload: {}'.format(msg.get_payload())) +# print('values: {}'.format(msg.get_stanza_values())) +# print('to: {}'.format(msg.get_to())) +# print('type: {}'.format(msg.get_type())) +# print('\npayload:') +# for i in msg.get_payload(): +# print('keys: {}'.format(i.keys())) +# print('items: {}'.format(i.items())) +# print('tag: {}'.format(i.tag)) +# print('text: {}'.format(i.text)) +# print('\nvalues:') +# for i in msg.get_stanza_values(): +# print('{}: {}'.format(i, msg[i])) +# print('\n:') +# +# mlang = msg['lang'] +# mnick = msg['mucnick'] +# mbody = msg['body'] +# mroom = msg['mucroom'] +# mfrom = msg['from'] +# mtype = msg['type'] +# mid = msg['id'] +# mto = msg['to'] + diff --git a/constants.py b/constants.py new file mode 100644 index 0000000..c5f2107 --- /dev/null +++ b/constants.py @@ -0,0 +1,24 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- + +# file: constants.py +# date: 26.07.2020 +# desc: provides a class with a read only variable (constante). idea found at +# https://stackoverflow.com/questions/2682745/how-do-i-create-a-constant-in-python + +# Modul is used to provide hackbots start time. As hackbot starts plugin manager +# import all modules to grab command and description. If uptime is imported +# itself imports constants and creates the starttime. + + +import time + +class Const: + + ''' + Class to provide a readonly constant. + ''' + + __slots__ = () + birth = time.strftime('%Y-%m-%dT%H:%M:%S', time.gmtime()) + diff --git a/hackbot.conf.templ b/hackbot.conf.templ new file mode 100644 index 0000000..02610b5 --- /dev/null +++ b/hackbot.conf.templ @@ -0,0 +1,9 @@ +[settings] +loglevel = info +plugindir = ./plugins + +[jabber] +jid = nick@jabber.example.com +password = strong-secure-password +room = room@chat.jabber.example.com +nick = mynick diff --git a/hackbot.py b/hackbot.py new file mode 100644 index 0000000..61055c9 --- /dev/null +++ b/hackbot.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# file: hackbot.py +# date: 24.07.2020 +# desc: class to deal with presence and messages in the given muc. + + +import sys +import logging + +import common +from idlebot import IdleBot +from manager import PluginManager + +class HackBot(IdleBot): + + ''' + Deals with the messages and presences from the muc. + ''' + + def __init__(self, jid, password, room, nick, plugin_dir): + IdleBot.__init__(self, jid, password, room, nick) + + self.add_event_handler("groupchat_message", self.muc_message) + + self.plugin_manager = PluginManager(plugin_dir) + self.plugin_store = self.plugin_manager.collect_plugins() + + def muc_message(self, msg): + """ + Process incoming message stanzas from any chat room. Be aware + that if you also have any handlers for the 'message' event, + message stanzas may be processed by both handlers, so check + the 'type' attribute when using a 'message' event handler. + Arguments: + msg -- The received message stanza. See the documentation + for stanza objects and the Message stanza to see + how it may be used. + """ + + # dont answer myself ... prevent self flooding + if msg['mucnick'] == self.nick: + logging.debug('Message from myself ... ignored') + return + + # check for command + command = common.get_command_from_body(msg) + if command is not False: + logging.debug('Command received: {}'.format(command)) + + # if command is help (needs a better argument handling) + if command == 'help': + arguments = common.get_arguments_from_body(msg) + logging.debug('Arguments: {}'.format(arguments)) + if arguments is False: + msg.reply(self.help()).send() + else: msg.reply(self.help(arguments[0].strip())).send() + + # command refernces a plugin + elif command in self.plugin_store.keys(): + logging.debug('Valid comand: {}'.format(command)) + self.run_plugin(command, msg, self.answer_muc) + + # command is unknown + else: + logging.warning('Unknown command: {}'.format(command)) + message = ': '.join((msg['mucnick'], + '{} is not a valid command'.format(msg))) + self.answer_muc(message) + + # only for debugging + else: logging.debug('No command found') + + def run_plugin(self, command, stanza, callback): + ''' + Creates a instance from the module is stored in plugin store under + the given command key and runs it. + Arguments: + command -- The command is received in MUC and matched a key in + the plugin store (string). + stanza -- The message object caused the call. + callback -- Function to post the response from plugin. + ''' + instance = self.plugin_store[command].Plugin(self.answer_muc) + instance.run(stanza) + + def answer_muc(self, message, room=None): + ''' + Sends a message to the given room. + Arguments: + message -- The message to send (string). + room -- The room where to send (string). + ''' + if room is None: + room = self.room + self.send_message(mto = room, + mbody = message, + mtype = 'groupchat') + + def help(self, *command): + ''' + Checks if arguments is false or not. Depends on this result it + calles long or short help. + Arguments: + command -- The command for which help requests. Optional. + Returns: + help -- string + ''' + if not command or command is False: + logging.debug('Empty help request. Send all commands.') + return self.help_overview() + else: return self.help_command(command) + + def help_overview(self): + ''' + Grabs short desciptions from all available plugins an deliver it to + MUC. + Returns: + helpstring -- string + ''' + commands = [] + helpstring = 'Available commands:' + for key in self.plugin_store.keys(): + if key == 'help': + continue + commands.append(key) + commands.sort() + for key in commands: + description = self.plugin_store[key].Plugin.get_description() + line = '{0:10s}: {1}'.format(key, description) + helpstring = '\n'.join((helpstring, line)) + return helpstring + + def help_command(self, command): + ''' + param 1: tuple (with one element) + ''' + for i in command: + if i not in self.plugin_store.keys(): + msg = '"{}" is not a valid argument for help'.format(i) + return msg + instance = self.plugin_store[i].Plugin() + return instance.help() + + diff --git a/idlebot.py b/idlebot.py new file mode 100644 index 0000000..2688e5a --- /dev/null +++ b/idlebot.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# file: idlebot.py +# date: 23.07.2020 +# desc: class to deal with server related jabber events and muc-offline +# presences. + +import time +import random +import logging +import slixmpp +logging = logging.getLogger(__name__) + + +class IdleBot(slixmpp.ClientXMPP): + + """ + Connect the given server, logs in and join the given room. If lost + connection to server it tryes to reconnect. + """ + + def __init__(self, jid, password, room, nick): + slixmpp.ClientXMPP.__init__(self, jid, password) + + self.nick = nick + self.room = room + self.room_roster = {} + self.add_event_handler("session_start", self.start) + self.add_event_handler("session_end", self.reconnect) + self.add_event_handler("disconnected", self.reconnect) + self.add_event_handler("muc::%s::got_online" % self.room, + self.muc_online) + self.add_event_handler("muc::%s::got_offline" % room, + self.muc_offline) + + async def start(self, event): + """ + Process the session_start event. + Typical actions for the session_start event are + requesting the roster and broadcasting an initial + presence stanza. + Arguments: + event -- An empty dictionary. The session_start + event does not provide any additional + data. + """ + await self.get_roster() + logging.info('Send presence') + self.send_presence() + self.join_room(self.room) + + def join_room(self, room): + ''' + Sends a presence stanza for the given chat room. + Arguments: + room -- The room to join. + ''' + self.plugin['xep_0045'].join_muc(room, + self.nick, + # If a room password is needed, use: + # password=the_room_password, + wait=True) + logging.info('Joined room {}'.format(room)) + self.room_roster[room] = [] + + def muc_online(self, presence): + """ + Process a presence stanza from a chat room. In this case, + we only add the sers nick to our room roster. Items in + presence['muc'] are 'room', 'nick', 'jid', 'lang', 'role', + 'affiliation'. Because 'jid' is (depends on server) possible + empty, we only can add 'nick' to the roster. + Arguments: + presence -- The received presence stanza. See the + documentation for the Presence stanza + to see how else it may be used. + """ + nick = presence['muc']['nick'] + room = presence['muc']['room'] + if nick not in self.room_roster[room]: + self.room_roster[room].append(nick) + logging.debug('Roster: {}'.format(self.room_roster)) + + # if bot joins the room great + greeting = ("Hello everybody, my name is {} and i'am the " + "new kid in town. :)".format(self.nick)) + if presence['muc']['nick'] == self.nick: + self.send_message(mto = room, + mbody = greeting, + mtype = 'groupchat') + + def muc_offline(self, presence): + """ + Process a presence stanza from a chat room. At first we look + for the nick who leaves the room. In case we are the user, we + clear the roster and try to rejoin. Otherwise we remove the nick + from roster. + Arguments: + presence -- The received presence stanza. See the + documentation for the Presence stanza + to see how else it may be used. + """ + nick = presence['muc']['nick'] + room = presence['muc']['room'] + if nick == self.nick: + self.room_roster[room] = [] + logging.info('Receive unavailable from {}'.format(room)) + timeout = random.randint(0,10) + logging.debug('Set timeout to {}'.format(timeout)) + time.sleep(timeout) + self.join_room(room) + else: + if nick in self.room_roster[room]: + sel.room_roster[room].remove(nick) + logging.debug('Roster: {}'.format(self.room_roster)) + + def reconnect(self, event): + ''' + Deals with alls events for disconnections. Tryes to reconnect. + ''' + logging.warning('Receive a disconnect event: {}'.format(event)) + self.disconnect() + logging.info('Try to reconnect') + self.connect() + + def hangup(self): + ''' + Process a disconnect from server. Is only called from + KeyboardInterrup exception to disconnect from server and terminate + the process.. + ''' + self.disconnect() + + def run(self): + ''' + Registers needed plugins, connect the server and try to hold this + connection. + ''' + self.register_plugin('xep_0045') # Multi-User Chat + self.register_plugin('xep_0012') # Last Activity + self.connect() + try: + self.process(forever=True) + except KeyboardInterrupt: + self.hangup() + + diff --git a/manager.py b/manager.py new file mode 100644 index 0000000..8137b98 --- /dev/null +++ b/manager.py @@ -0,0 +1,96 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- + + +import sys +import logging +logging = logging.getLogger() +import os.path +import importlib +from os import listdir + + +class Plugin(object): + + ''' + Proto type of plugin class. Only subclasses are valid plugins. + ''' + + __command = '' + + @staticmethod + def get_module(): + return Plugin.__module + + @staticmethod + def get_command(): + return Plugin.__command + + @staticmethod + def get_description(): + return Plugin.__description + + def __init__(self, callback): + self.callback = callback + + def help(self): + return 'Sorry ... Help is unavailable at the moment' + + def run(self, msg): + self.callback('Sorry ... Run is unavailable at the moment') + + +class PluginManager(): + + ''' + Handles the plugins. Optional becomes a directory for search plugins. + ''' + + def __init__(self, plug_dir=None): + ''' + Initialize callback function and plugin directory. If no callback is + given it returns imediality. If no plugin directory is given it uses + actual directory. + ''' + if plug_dir is None: + self.plugin_dir = './plugins' + else: self.plugin_dir = plug_dir + + def collect_plugins(self): + ''' + Find all files in plugin directory and grabs filename, provided + command and short description. + ''' + self.plugins = {} + search_dir = os.path.realpath(self.plugin_dir) + sys.path.insert(0, search_dir) + logging.debug('Search plugins in {}'.format(search_dir)) + files = [x[:-3] for x in os.listdir(search_dir) if x.endswith('.py')] + for filename in files: + plugin = self.import_plugin(filename) + if plugin is False: + continue + command = plugin.Plugin.get_command() + self.plugins[command] = plugin + return self.plugins + + def import_plugin(self, filename): + ''' + Imports or reimports a module depending its known or not. It's + tested by the command provided by the plugin. (Not best praxis but + the easyst way. And i dont know, what a trouble is causes, if a + module ist moved.) + param 1: string + ''' + try: + plugin = importlib.import_module(filename) + except Exception as e: + logging.error('Cant import module: {}'.format(filename)) + logging.error('Exception: {}'.format(e)) + return False + if not issubclass(plugin.Plugin, Plugin): + logging.error('Not a valid plugin: {}'.format(filename)) + return False + logging.debug('Found plugin {}.'.format(filename)) + return plugin + diff --git a/plugins/dsa.py b/plugins/dsa.py new file mode 100644 index 0000000..f548433 --- /dev/null +++ b/plugins/dsa.py @@ -0,0 +1,159 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# file: dsa.py +# date: 26.07.2020 +# desc: Serves debians security alerts + + +import logging +import urllib3 +import threading +from lxml import etree +from manager import Plugin +import common + + +logging = logging.getLogger() + + +class Plugin(Plugin): + + ''' + Fetchs debians security alerts, grabs title and links and sends it to + the muc. + ''' + + __module = __name__ + __command = 'dsa' + __description = 'Serves debians security alerts' + + @staticmethod + def get_module(): + return Plugin.__module + + @staticmethod + def get_command(): + return Plugin.__command + + @staticmethod + def get_description(): + return Plugin.__description + + def __init__(self, callback=None): + self.callback = callback + + def help(self): + return ('!dsa serves the actual debian security alerts. A given ' + ' number reduces the count of alerts displayed to number. ' + 'Not implemented at the moment.' + '\nSyntax: !dsa ') + + def run(self, stanza): + ''' + Starts a thread to grab debians security alerts returns + immediately. + param 1: stanza object + ''' + call_msg = 'Call "!help {}"'.format(self.get_command()) + no_count_msg = ' '.join(('Not a valid count: "{}"!', call_msg)) + count = False + + muc_nick = common.get_nick_from_stanza(stanza) + arguments = common.get_arguments_from_body(stanza) + + if arguments is not False: + count = self.get_count(arguments[0]) + if count is False: + self.callback(': '.join((muc_nick, no_count_msg))) + return + + logging.debug('Start thread for debian security alerts') + dsa_thread = DsaThread(self.callback, count) + dsa_thread.run() + logging.debug('DSA Thread started') + + def get_count(self, item): + ''' + Try to convert a string into integer. + param 1: string + retuns: integer or false + ''' + try: + value = int(item.strip()) + return value + except Exception as e: + logging.warning('Invalid value for count: {}'.format(item)) + logging.warning('Exception: {}'.format(e)) + return False + + +class DsaThread(threading.Thread): + ''' + The thread who fetched and returns the wp search. + ''' + def __init__(self, callback, count): + threading.Thread.__init__(self) + self.callback = callback + self.count = count + + def run(self): + ''' + Starts the thread. + ''' + dsa_response = self.get_file() + if dsa_response == False: + self.callback('Error while fetching DSA') + else: + status = dsa_response.status + logging.debug("Server returns {}".format(status)) + if status != 200: + self.callback('Server returns {}'.format(status)) + xmldoc = etree.fromstring(dsa_response.data) + message = self.string_factory(xmldoc) + self.callback(message) + + def string_factory(self, xmldoc): + ''' + Extracts interested things from the given dsa xml document and + creates a string to post im muc. + param 1: xml object + ''' + message = 'Debian Security Alerts:' + nsmap = { + "purl": "http://purl.org/rss/1.0/", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + } + about_list = xmldoc.xpath('//purl:item/@rdf:about', namespaces=nsmap) + for about in reversed(about_list): + dsa_id = self.get_id_from_about(about) + title = xmldoc.xpath( + '//purl:item[@rdf:about="{}"]/purl:title/text()'.format( + about), namespaces=nsmap)[0] + message = '\n'.join((message, title)) + return message + + def get_file(self): + ''' + Fetchs the security alerts from debian.org + param 1: string + returns: request object or false + ''' + url = 'https://www.debian.org/security/dsa-long' + logging.debug('Try to fetch {}'.format(url)) + http = urllib3.PoolManager() + try: + dsa_response = http.request('Get', url) + return dsa_response + except: + logging.debug('{}: failed to fetch'.format(url)) + return False + + def get_id_from_about(self, about): + ''' + Extracts the dsa id from tehe given string. + param 1: string + ''' + return int(about.split('/')[-1].split('-')[1]) + + diff --git a/plugins/status.py b/plugins/status.py new file mode 100644 index 0000000..9aa9b19 --- /dev/null +++ b/plugins/status.py @@ -0,0 +1,131 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- + +# file: ./plugins/status.py +# date: 23.07.2020 +# desc: status plugin ... grabs the krautspaces door status from +# https://status.kraut.space/api and returns the result. + +import urllib3 +import codecs +import json +import time +import threading +import logging +logging = logging.getLogger() +from manager import Plugin + +lock = threading.Lock() + + +class Plugin(Plugin): + + __module = 'status' + __command = 'status' + __description = 'Deliver krautspace door status' + + @staticmethod + def get_module(): + return Plugin.__module + + @staticmethod + def get_command(): + return Plugin.__command + + @staticmethod + def get_description(): + return Plugin.__description + + def __init__(self, callback=None): + self.callback = callback + + def help(self): + return ('!status grabs the doorstatus from https://status.kraut.space ' + 'and delivers him to MUC.\nSyntax: !status') + + def run(self, stanza): + ''' + Starts a thread to grab krautspaces door status und returns + immediately. + ''' + logging.debug('Start thread for status') + api_thread = ApiThread(self.callback) + api_thread.run() + logging.debug('Status thread started') + + +class ApiThread(threading.Thread): + ''' + The thread who fetched, parsed and returns the door status. + ''' + def __init__(self, callback): + threading.Thread.__init__(self) + self.callback = callback + + def run(self): + ''' + Runs the thread. + ''' + api_page = self.get_file() + if api_page == None: + self.go_back('Error while connecting API') + else: + status = api_page.status + logging.debug("Page returns {}".format(status)) + if status == 200: + message = self.parse_api(api_page.data) + self.go_back(message) + else: + self.go_back('Error while fetching API') + + def get_file(self): + ''' + Grabs the API file, parse it and returns a json ... otherwise none. + returns: json or none + ''' + url = 'https://status.kraut.space/api' + logging.debug('Try to fetch {}'.format(url)) + http = urllib3.PoolManager() + try: + api_page = http.request('Get', url) + logging.debug('{}: successfull fetched'.format(url)) + except: + logging.debug('{}: failed to fetch'.format(url)) + return None + return api_page + + def parse_api(self, page_data): + ''' + Extracts needed data from given json and create the message. + param 1: json + returns: string + ''' + timestamp = None + status = None + message = None + json_string = page_data.decode('utf-8') + json_dict = json.loads(json_string) + status = json_dict['state']['open'] + unixtime = json_dict['state']['lastchange'] + timestamp = time.strftime('%d.%m.%Y %H:%M', time.localtime(unixtime)) + logging.debug('Open: {}; Time: {}; Last Change: {}'.format( + status, timestamp, unixtime)) + if status is True: + message = 'Space is open since {}.'.format(timestamp) + elif status is False: + message = 'Space is closed since {}.'.format(timestamp) + else: + message = 'Invalid status: "{}"'.format(status) + return message + + def go_back(self, message): + ''' + param 1: string + ''' + lock.acquire() + try: + self.callback(message) + finally: + lock.release() + + diff --git a/plugins/timer.py b/plugins/timer.py new file mode 100644 index 0000000..cde55cb --- /dev/null +++ b/plugins/timer.py @@ -0,0 +1,151 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- + +# file: timer.py +# date: 25.07.2020 +# desc: Starts a time for max. 24 hours and sends a message after it has +# expied. + +import common +import logging +logging = logging.getLogger() +from manager import Plugin +from threading import Timer, Lock + +lock = Lock() + + +class Plugin(Plugin): + + ''' + Timer starts a timer and sends a message to muc after it has expired. + ''' + + __module = 'timer' + __command = 'timer' + __description = 'Starts a timer.' + + @staticmethod + def get_module(): + return Plugin.__module + + @staticmethod + def get_command(): + return Plugin.__command + + @staticmethod + def get_description(): + return Plugin.__description + + def __init__(self, callback=None): + self.callback = callback + + def help(self): + return ('!timer sets a timer that sends a completion message when it ' + 'has expired. The function expects a time value and ' + 'optionally a unit. Possible units are seconds, seconde, ' + 'sekunden, sec, sek, s, minute, minutes, minuten, min, ' + 'hour, hours, stunde, stunden or h. If no unit is given it ' + 'uses seconds as default. The timer runs for a maximum of 24 ' + 'hours.' + '\nSyntax: !timer ') + + def run(self, stanza): + ''' + ''' + self.units = ('seconde', 'seconds', 'sekunde', 'sekunden', + 'sec', 'sek', 's', + 'minute', 'minutes', 'minuten', 'min', 'm', + 'hour', 'hours', 'h', 'stunde', 'stunden') + + call_msg = 'Call "!help {}"'.format(self.get_command()) + no_args_msg = ' '.join(('Timer without time!', call_msg)) + no_valu_msg = ' '.join(('Not a valid value: "{}"!', call_msg)) + no_unit_msg = ' '.join(('Not a valid unit: "{}"!', call_msg)) + to_long_msg = 'Sorry ... but i dont want stay here for this time' + + value = None + unit = None + + muc_nick = common.get_nick_from_stanza(stanza) + arguments = common.get_arguments_from_body(stanza) + logging.debug('Arguments: {}'.format(arguments)) + + if arguments is False: + logging.warning('No arguments for timer. Abort.') + self.callback(': '.join((muc_nick, no_args_msg))) + return + else: + try: + value = self.get_timer_value(arguments[0]) + unit = self.get_timer_unit(arguments[1]) + except IndexError: + pass + except Exception as e: + logging.warning('Error while creating timer') + logging.warning('Exception: {}'.format(e)) + if value in (None, False): + msg = ': '.join((muc_nick, no_valu_msg.format(arguments[0]))) + self.callback(msg) + return + if unit is False: + msg = ': '.join((muc_nick, no_unit_msg.format(arguments[1]))) + self.callback(msg) + return + # timer starten + elif unit is None: + self.start_timer(value, self.callback, muc_nick) + else: + value = value * unit + if value > 5184000: + logging.warning('Timer value to hight: {}'.format(value)) + self.callback(': '.join((muc_nick, to_long_msg))) + self.start_timer(value, self.callback, muc_nick) + + def get_timer_value(self, item): + ''' + Try to convert a string into integer. + param 1: string + retuns: integer or false + ''' + try: + value = int(item.strip()) + return value + except Exception as e: + logging.warning('Invalid value for timer: {}'.format(item)) + logging.warning('Exception: {}'.format(e)) + return False + + def get_timer_unit(self, item): + ''' + param 1: string + returns: integer + ''' + if item.strip() in self.units[0:7]: + logging.debug('Timer unit: seconds') + factor = 1 + elif item.strip() in self.units[7:12]: + logging.debug('Timer unit: minutes') + factor = 60 + elif item.strip() in self.units[13:17]: + logging.debug('Timer unit: hours') + factor = 60 * 60 + else: + logging.warning('Invalid unit for timer: {}'.format(item.strip())) + factor = False + return factor + + def start_timer(self, value, callback, muc_nick): + ''' + Starts the timer. Arguments are the duration in seconds and the + chatters nick who called timer. + param 1: integer + param 2: string + ''' + timer_start_msg = 'Timer started' + timer_ends_msg = '{}: Timer finished!'.format(muc_nick) + t = Timer(value, callback, [timer_ends_msg]) + t.start() + logging.debug('Timer started for {} seconds'.format(value)) + self.callback(': '.join((muc_nick, timer_start_msg))) + diff --git a/plugins/uptime.py b/plugins/uptime.py new file mode 100644 index 0000000..fe549e5 --- /dev/null +++ b/plugins/uptime.py @@ -0,0 +1,84 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# file: uptime.py +# date: 25.07.2020 +# desc: Returns bots uptime + + +import time +import datetime +import logging +logging = logging.getLogger() +from manager import Plugin +from constants import Const + + +class Plugin(Plugin): + + ''' + Returns the hackbots uptime. The variable birth from the globalvar + module is used to become the starttime. + ''' + + __module = __name__ + __command = 'uptime' + __description = 'Returns hackbots uptime' + + @staticmethod + def get_module(): + return Plugin.__module + + @staticmethod + def get_command(): + return Plugin.__command + + @staticmethod + def get_description(): + return Plugin.__description + + def __init__(self, callback=None): + self.callback = callback + + def help(self): + return ('!uptime returns hackbots uptime. There are no options. ' + '\nSyntax: !uptime') + + def run(self, stanza): + ''' + Starts the plugin. + ''' + const = Const() + birth = const.birth + uptime = self.calculate_uptime(birth) + if uptime == False: + logging.warning('Error while calculating uptime') + self.callback('Error while calculating uptime') + else: + self.callback('My uptime is {}'.format(uptime)) + + + + def calculate_uptime(self, birth): + logging.debug('Calculate uptime since {} UTC'.format(birth)) + start = self.birth2time(birth) + now = datetime.datetime.utcnow() + print('*** Start: {} :: Now: {}'.format(start, now)) + try: + uptime = now - start + print('Uptime: {}'.format(uptime)) + return uptime + except Exception as exc: + logging.warning('ERROR: {}'.format(exc)) + return False + + + def birth2time(self, birth): + time_object = None + datum, zeit = birth.split('T') + jahr, monat, tag = datum.split('-') + stunde, minute, sekunde = zeit.split(':') + time_object = datetime.datetime(int(jahr), int(monat), int(tag), \ + int(stunde), int(minute), int(sekunde), 0, None) + return time_object + diff --git a/plugins/wpsearch.py b/plugins/wpsearch.py new file mode 100644 index 0000000..27e4d9b --- /dev/null +++ b/plugins/wpsearch.py @@ -0,0 +1,155 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# file: generic.plgin +# date: 23.07.2020 +# desc: generic plugin + + +import json +import common +import logging +import requests +import threading +from manager import Plugin +logging = logging.getLogger() + +lock = threading.Lock() + + +class Plugin(Plugin): + + ''' + ''' + + __module = __name__ + __command = 'wpsearch' + __description = 'Seach wikipedia' + + @staticmethod + def get_module(): + return Plugin.__module + + @staticmethod + def get_command(): + return Plugin.__command + + @staticmethod + def get_description(): + return Plugin.__description + + def __init__(self, callback=None): + self.callback = callback + + def help(self): + return ('!wp-search searches in wikipedia for a given search term. ' + 'Arguments are language shortcut and search pattern. If no ' + 'language shortcut is given, en is used as default. ' + '\nSyntax: !wp-search ') + + def run(self, stanza): + ''' + ''' + lang = 'en' + pattern = None + call_msg = 'Call "!help {}"'.format(self.get_command()) + no_args_msg = ' '.join(('No search pattern!', call_msg)) + + muc_nick = common.get_nick_from_stanza(stanza) + arguments = common.get_arguments_from_body(stanza) + logging.debug('Arguments: {}'.format(arguments)) + + if arguments is False: + logging.warning('No arguments for wp search. Abort.') + self.callback(': '.join((muc_nick, no_args_msg))) + return + else: + if len(arguments) == 1: + pattern = arguments[0] + elif len(arguments) > 1: + logging.warning('Not implemented yet.') + self.callback('Not implemented yet') + return + logging.debug('Start thread for wp search') + self.callback('Search started') + api_thread = ApiThread(self.callback) + api_thread.run(lang, pattern, muc_nick) + + +class ApiThread(threading.Thread): + ''' + The thread who fetched and returns the wp search. + ''' + def __init__(self, callback): + threading.Thread.__init__(self) + self.callback = callback + + def run(self, lang, pattern, muc_nick): + ''' + Starts the thread. + ''' + data = self.get_file(lang, pattern) + if data == False: + self.callback('Error while connecting WP') + else: + # TODO: check if its a json needed ! + logging.debug(data) + msg = self.string_factory(data) + self.callback(': '.join((muc_nick, msg))) + + def get_file(self, lang, pattern): + ''' + Grabs the API file, parse it and returns a json ... otherwise none. + param 1: string + param 2: string + param 3: string + returns: json or none + ''' + api_url = 'https://{}.wikipedia.org/w/api.php'.format(lang) + api_params = { + 'action': 'query', + 'prop': 'extracts|info', + 'explaintext': '', + 'redirects': '', + 'exchars': 200, + 'continue': '', + 'format': 'json', + 'titles': pattern, + 'inprop': 'url', + 'formatversion': 2 + } + + try: + logging.debug('Try to fetch {}'.format(api_url)) + session = requests.Session() + response = session.get(url = api_url, params = api_params) + data = response.json() + logging.debug('{}: successfull fetched'.format(api_url)) + except: + logging.debug('{}: failed to fetch'.format(api_url)) + return False + return data + + def string_factory(self, data): + ''' + param 1: json + returns: string + ''' + msg = '' + if 'redirects' in data['query'].keys(): + for i in data['query']['redirects']: + rfr = i['from'] + rto = i['to'] + msg = '\n'.join((msg, 'Redirect from {} to {}'.format( \ + rfr, rto))) + logging.debug('Message: {}'.format(msg)) + pages = data['query']['pages'] + for i in pages: + if 'extract' in i.keys(): + msg = '\n'.join((msg, 'Summary:', i['extract'])) + logging.debug('Message: {}'.format(msg)) + else: msg = '\n'.join((msg, 'Nothing found')) + msg = '\n'.join((msg, 'URL:', i['fullurl'])) + logging.debug('Message: {}'.format(msg)) + return msg + diff --git a/runhackbot.py b/runhackbot.py new file mode 100755 index 0000000..97d05ae --- /dev/null +++ b/runhackbot.py @@ -0,0 +1,82 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- + +import os +import sys +import logging +from configparser import ConfigParser +from hackbot import HackBot + + +# Setup logging. +format_string = '%(asctime)s: %(levelname)-8s %(message)s' +logging.basicConfig(level=logging.WARNING, format=format_string) + +def setup_config(default_config, config_file, config): + ''' + param 1: dictionary + param 1: string + param 2: configparser object + returns: configparser object oder false + ''' + config.read_dict(default_config) + if not config.read(config_file): + logging.error('Config file {} not found or not readable'.format\ + (config_file)) + return False + return config + +def display_setup(config): + ''' + param 1: configparser object + ''' + for section in config.sections(): + print('Section: {}'.format(section)) + for key, value in config.items(section): + print('\t{}: {}'.format(key, value)) + print('\n', end='') + +def run_hackbot(): + ''' + Configure and starts logging and hackbot. + ''' + log_levels = ('critical', 'error', 'warning', 'info', 'debug') + config_file = './hackbot.conf' + default_config = { + 'settings': { + 'loglevel': 'warning', + 'plugindir': '.' + }, + 'jabber': { + 'jid': '', + 'password': '', + 'room': '', + 'nick': '' + } + } + config = ConfigParser() + config = setup_config(default_config, config_file, config) + + if config is False: + sys.exit() + + logger = logging.getLogger() + if not config['settings']['loglevel'] in log_levels: + logging.warning('Invalid loglevel given: {} Use default level: {}'.\ + format(config['settings']['loglevel'], + default_config['settings']['loglevel'])) + config.set('settings', 'loglevel', \ + default_config['settings']['loglevel']) + logger.setLevel(config['settings']['loglevel'].upper()) + + xmpp = HackBot(config['jabber']['jid'], + config['jabber']['password'], + config['jabber']['room'], + config['jabber']['nick'], + config['settings']['plugindir']) + xmpp.run() + + +if __name__ == '__main__': + run_hackbot() +