class LudolphBot(LudolphDBMixin): """ Ludolph bot. """ _start_time = None _muc_ready = False _reloaded = False reloading = False shutting_down = False commands = COMMANDS plugins = PLUGINS room = None room_jid = None room_config = None room_invites = True room_bot_affiliation = "owner" room_user_affiliation = "member" room_admin_affiliation = "admin" room_bot_role = "" room_user_role = "" room_admin_role = "" muc = None nick = "Ludolph" # Warning: do not change the nick during runtime xmpp = None maxhistory = "16" webserver = None cron = None persistent_attrs = ("room_users_invited", "room_users_last_seen") def __init__(self, config, plugins=None): super(LudolphBot, self).__init__() self._event_handlers = { "bot_message": [self._run_command], "bot_command_not_found": [self._command_not_found], "muc_message": [], "muc_user_online": [], "muc_user_offline": [], } self.users = set() self.admins = set() self.broadcast_blacklist = set() self.room_users = set() self.room_admins = set() self.room_users_invited = set() self.room_users_last_seen = {} self._load_config(config, init=True) logger.info("Initializing jabber bot *%s*", self.nick) self._load_plugins(config, plugins, init=True) # Initialize the SleekXMPP client self.client = client = ClientXMPP(config.get("xmpp", "username"), config.get("xmpp", "password")) # These XMPP-related attributes and methods are expected to exist by plugins self.client_roster = client.client_roster self.boundjid = client.boundjid # Register XMPP plugins client.register_plugin("xep_0030") # Service Discovery client.register_plugin("xep_0045") # Multi-User Chat client.register_plugin("xep_0198") # Stream Management client.register_plugin("xep_0199") # XMPP Ping client.register_plugin("xep_0203") # Delayed Delivery client.register_plugin("xep_0084") # User Avatar client.register_plugin("xep_0153") # User Avatar vCard client.register_plugin("xep_0224") # Attention # Auto-authorize is enabled by default. User subscriptions are controlled by self._handle_new_subscription client.auto_authorize = True # Register event handlers client.add_event_handler("roster_subscription_request", self._handle_new_subscription) client.add_event_handler("session_start", self._session_start) client.add_event_handler("message", self._bot_message, threaded=True) client.add_event_handler("attention", self.handle_attention, threaded=True) if self.room: self.muc = client.plugin["xep_0045"] client.add_event_handler("groupchat_message", self._muc_message, threaded=True) client.add_event_handler("muc::%s::got_online" % self.room, self._muc_user_online, threaded=True) client.add_event_handler("muc::%s::got_offline" % self.room, self._muc_user_offline, threaded=True) # Run post initialization methods for all plugins self._post_init_plugins() # Start the web server thread for processing HTTP requests if self.webserver: # noinspection PyProtectedMember client._start_thread("webserver", self.webserver.start, track=False) # Start the scheduler thread for running periodic cron jobs if self.cron: # noinspection PyProtectedMember client._start_thread("cron", self.cron.run, track=False) # Save start time self._start_time = time.time() logger.info("Jabber bot *%s* is up and running", self.nick) # noinspection PyMethodMayBeStatic def __post_init__(self): """Run after ludolph bot instance is up and running""" pass # noinspection PyMethodMayBeStatic def __destroy__(self): """Run before ludolph bot shutdown""" pass def __getstate__(self): """Return internal data suitable for saving into persistent DB file""" # FIXME: Switch to dict comprehension after dropping support for Python 2.6 return dict((i, self.__dict__[i]) for i in self.persistent_attrs if i in self.__dict__) def __setstate__(self, state): """Set saved internal data from persistent DB""" for i in state: if i in self.persistent_attrs: self.__dict__[i].update(state[i]) @catch_exception def _db_set_item(self, name, obj): """Save object data into persistent DB""" if obj.persistent_attrs: logger.info("Syncing runtime data with persistent DB file for object: %s", name) self.db[name] = obj.__getstate__() else: logger.debug("Object %s has no persistent attributes", name) @catch_exception def _db_load_item(self, name, obj): """Load saved object data from persistent DB""" if obj.persistent_attrs: logger.info("Loading runtime data from persistent DB file for object: %s", name) data = self.db.get(name, None) if data: obj.__setstate__(data) else: logger.debug("Object %s has no saved data", name) else: logger.debug("Object %s has no persistent attributes", name) def _db_set_items(self): """Save internal data to persistent DB""" self._db_set_item(__name__, self) def _db_load_items(self): """Load saved internal data from persistent DB""" self._db_load_item(__name__, self) def _db_set_items_all(self): """Save all internal+plugin data to persistent DB for every initialized plugin""" for modname, plugin in self.plugins.items(): # ludolph.bot is part of plugins self._db_set_item(modname, plugin) def _db_load_items_all(self): """Load all internal+plugin data from persistent DB for every initialized plugin""" for modname, plugin in self.plugins.items(): # ludolph.bot is part of plugins self._db_load_item(modname, plugin) @staticmethod def read_jid_array(config, option, **keywords): """Read comma-separated config option and return a list of JIDs""" jids = set() if option in config: for jid in config[option].strip().split(","): jid = jid.strip() if not jid: continue if "@" in jid: if jid.startswith("@"): kwd = jid[1:] if kwd in keywords: jids.update(keywords[kwd]) else: logger.warn('Skipping invalid keyword "%s" from setting "%s"', jid, option) else: jids.add(jid) else: logger.warn('Skipping invalid JID "%s" from setting "%s"', jid, option) return jids def _load_config(self, config, init=False): """ Load bot settings from config object. The init parameter indicates whether this is a first-time initialization or a reload. """ logger.info("Configuring jabber bot") xmpp_config = dict(config.items("xmpp")) # Get DB file if config.has_option("global", "dbfile"): dbfile = config.get("global", "dbfile") if dbfile: self.db_enable(LudolphDB(dbfile), init=True) # Get nick name nick = xmpp_config.get("nick", "").strip() if nick: self.nick = nick # Warning: do not change the nick during runtime after this # If you are working with an OpenFire server, you will # need to use a different SSL version: if config.has_option("xmpp", "sslv3") and config.getboolean("xmpp", "sslv3"): # noinspection PyUnresolvedReferences self.client.ssl_version = ssl.PROTOCOL_SSLv3 # Users self.users.clear() self.users.update(self.read_jid_array(xmpp_config, "users")) logger.info("Current users: %s", ", ".join(self.users)) # Admins self.admins.clear() self.admins.update(self.read_jid_array(xmpp_config, "admins", users=self.users)) logger.info("Current admins: %s", ", ".join(self.admins)) # Broadcast blacklist self.broadcast_blacklist.clear() self.broadcast_blacklist.update(self.read_jid_array(xmpp_config, "broadcast_blacklist", admins=self.admins)) logger.info("Broadcast blacklist: %s", ", ".join(self.broadcast_blacklist)) # Admins vs. users if not self.admins.issubset(self.users): for i in self.admins.difference(self.users): logger.error('Admin "%s" is not specified in users. ' "This may lead to unexpected behaviour.", i) # MUC room room = xmpp_config.get("room", "").strip() if room: self.room = room self.room_jid = "%s/%s" % (self.room, self.nick) else: self.room = None self.room_jid = None # MUC room invites sending if config.has_option("xmpp", "room_invites"): self.room_invites = config.getboolean("xmpp", "room_invites") else: self.room_invites = LudolphBot.room_invites # MUC room affiliations and roles valid_affiliations = ("owner", "admin", "member", "outcast", "none") valid_roles = ("moderator", "participant", "visitor", "none") for setting, valid_values in ( ("room_bot_affiliation", valid_affiliations), ("room_user_affiliation", valid_affiliations), ("room_admin_affiliation", valid_affiliations), ("room_bot_role", valid_roles), ("room_user_role", valid_roles), ("room_admin_role", valid_roles), ): config_value = xmpp_config.get(setting, getattr(LudolphBot, setting)).strip() if config_value: if config_value not in valid_values: logger.error( 'Invalid value "%s" for "%s" setting. Must be one of: %s.', config_value, setting, ",".join(valid_values), ) config_value = None else: config_value = None setattr(self, setting, config_value) # MUC room users self.room_users.clear() if self.room: self.room_users.update(self.read_jid_array(xmpp_config, "room_users", users=self.users, admins=self.admins)) logger.info("Current room users: %s", ", ".join(self.room_users)) # MUC room admins self.room_admins.clear() if self.room: self.room_admins.update( self.read_jid_array( xmpp_config, "room_admins", users=self.users, admins=self.admins, room_users=self.room_users ) ) logger.info("Current room admins: %s", ", ".join(self.room_admins)) # Room admins vs. users if not self.room_admins.issubset(self.room_users): for i in self.room_admins.difference(self.room_users): logger.error( 'Room admin "%s" is not specified in room_users. ' "This may lead to unexpected behaviour.", i ) # Room users vs. room_users_invited if self.room_users_invited: self.room_users_invited.intersection_update(self.room_users) # Web server (any change in configuration requires restart) if init and not self.webserver: if config.has_option("webserver", "host") and config.has_option("webserver", "port"): host = config.get("webserver", "host").strip() port = config.getint("webserver", "port") if host and port: # Enable server (will be started in __init__) self.webserver = WebServer(host, port) # Cron (any change in configuration requires restart) if init and not self.cron: if config.has_option("cron", "enabled") and config.getboolean("cron", "enabled"): self.cron = Cron(db=self.db) if self._reloaded: if self.cron and self.db is None: # DB support was disabled during reload self.cron.db_disable() # noinspection PyMethodMayBeStatic @catch_exception def _post_init_plugin(self, name, plugin_obj): logger.info("Post-initializing plugin: %s", name) plugin_obj.__post_init__() # noinspection PyMethodMayBeStatic @catch_exception def _destroy_plugin(self, name, plugin_obj): logger.info("Destroying plugin: %s", name) plugin_obj.__destroy__() del plugin_obj def _load_plugins(self, config, plugins, init=False): """ Initialize plugins. The init parameter indicates whether this is a first-time initialization or a reload. """ self.plugins.reset(init=init) if init: # First-time plugin initialization -> include ourself to plugins dict self.xmpp = self self.plugins[__name__] = self else: # Bot reload - remove disabled plugins for enabled_plugin in tuple(self.plugins.keys()): # Copy for python 3 if enabled_plugin == __name__: continue # Skip ourself if enabled_plugin not in plugins: logger.info("Disabling plugin: %s", enabled_plugin) self._destroy_plugin(enabled_plugin, self.plugins.pop(enabled_plugin)) if plugins: for plugin in plugins: modname = plugin.module if init or modname not in self.plugins: logger.info("Initializing plugin: %s", modname) reinit = False else: logger.info("Reloading plugin: %s", modname) self._destroy_plugin(modname, self.plugins.pop(modname)) reinit = True try: cfg = config.items(plugin.name) # Get only plugin config section as list of (name, value) tuples obj = plugin.cls(self, cfg, reinit=reinit) except Exception as ex: logger.critical("Could not load plugin: %s", modname) logger.exception(ex) # Remove registered commands from Commands dict for this module self.commands.reset(module=modname) if self.webserver: # Remove registered webhooks for this module self.webserver.reset_webhooks(module=modname) if self.cron: # Remove registered cronjobs for this module self.cron.reset(module=modname) else: self.plugins[modname] = obj if self.db is not None: self._db_load_item(modname, obj) # Update commands cache if self.commands.all(reset=True): logger.info("Registered commands:\n%s\n", "\n".join(self.commands.display())) else: logger.warning("NO commands registered") if self.webserver: if self.webserver.webhooks: logger.info("Registered webhooks:\n%s\n", "\n".join(self.webserver.display_webhooks())) else: logger.warning("NO webhooks registered") else: logger.warning("Web server support disabled - webhooks will not work") if self.cron: if self.cron.crontab: logger.info("Registered cron jobs:\n%s\n", "\n".join(self.cron.display_cronjobs())) else: logger.warning("NO cron jobs registered") else: logger.warning("Cron support disabled - cron jobs will not work") def _post_init_plugins(self): """ Run __post_init__() method for each initialized plugin. """ for modname, plugin in self.plugins.items(): # ludolph.bot is part of plugins self._post_init_plugin(modname, plugin) def _destroy_plugins(self): """ Run __destroy__() method for each initialized plugin. """ for modname, plugin in reversed(self.plugins.items()): # ludolph.bot is part of plugins self._destroy_plugin(modname, plugin) def _run_event_handlers(self, event_name, *args): """ Run all event handlers when an event happens. """ for event_handler in self._event_handlers[event_name]: event_handler(*(copy.copy(arg) for arg in args)) def register_event_handler(self, event_name, fun, clear=False): """ Add a function into event handlers. """ event_handlers = self._event_handlers[event_name] if clear: logger.info("Event [%s]: Removing all event handlers", event_name) del event_handlers[:] logger.info('Event [%s]: Adding event handler "%s"', event_name, fun) event_handlers.append(fun) logger.debug("Event [%s]: Current event handlers: %s", event_name, event_handlers) def deregister_event_handler(self, event_name, fun): """ Remove a specific function from even handlers. """ event_handlers = self._event_handlers[event_name] if fun not in event_handlers: logger.warning('Event [%s]: Event handler "%s is not registered', event_name, fun) return logger.info('Event [%s]: Removing event handler "%s"', event_name, fun) self._event_handlers[event_name] = [i for i in event_handlers if i != fun] logger.debug("Event [%s]: Current event handlers: %s", event_name, event_handlers) def _room_members(self): """ Change multi-user chat room member list. """ query = ET.Element("{http://jabber.org/protocol/muc#admin}query") qitem = "{http://jabber.org/protocol/muc#admin}item" bot_member = {"jid": self.boundjid.bare} if self.room_bot_affiliation: bot_member["affiliation"] = self.room_bot_affiliation if self.room_bot_role: bot_member["role"] = self.room_bot_role query.append(ET.Element(qitem, bot_member)) for jid in self.room_users: room_member = {"jid": jid} affiliation = self.room_user_affiliation role = self.room_user_role if jid in self.room_admins: if self.room_admin_affiliation: affiliation = self.room_admin_affiliation if self.room_admin_role: role = self.room_admin_role if affiliation: room_member["affiliation"] = affiliation if role: room_member["role"] = role query.append(ET.Element(qitem, room_member)) iq = self.client.make_iq_set(query) iq["to"] = self.room iq["from"] = self.boundjid iq.send() def _room_config(self): """ Configure multi-user chat room. """ logger.info("Getting current configuration for MUC room %s", self.room) try: self.room_config = self.muc.getRoomConfig(self.room) except ValueError: logger.error("Could not get MUC room configuration. Maybe the room is not (properly) initialized.") return if self.room_users: self.room_config["fields"]["muc#roomconfig_membersonly"]["value"] = True self.room_config["fields"]["members_by_default"]["value"] = False else: self.room_config["fields"]["muc#roomconfig_membersonly"]["value"] = False self.room_config["fields"]["members_by_default"]["value"] = True self.room_config["fields"]["muc#maxhistoryfetch"] = self.maxhistory logger.info("Setting new configuration for MUC room %s", self.room) try: self.muc.setRoomConfig(self.room, self.room_config) except IqError as e: logger.error( "Could not configure MUC room. Error was: %s (condition=%s, etype=%s)", e.text, e.condition, e.etype ) logger.info("Setting member list for MUC room %s", self.room) try: self._room_members() except IqError as e: logger.error( "Could not configure MUC room member list. Error was: %s (condition=%s, etype=%s)", e.text, e.condition, e.etype, ) def _get_room_member(self, jid): """ Return MUC room member object according to user's bare Jabber ID. """ for nick in self.muc.rooms[self.room]: entry = self.muc.rooms[self.room][nick] if entry is not None and entry["jid"].bare == jid: return entry raise KeyError('User with jabber ID "%s" is not listed on the room member list' % jid) def get_room_jid(self, jid): """ Helper method for retrieving room occupant's Jabber ID (full) from to user's non-room jabber ID (bare). """ try: room_user = self._get_room_member(jid) except KeyError: return None else: return "%(room)s/%(nick)s" % room_user def get_room_nick(self, jid): """ Helper method for retrieving MUC room nick according to jid. """ try: return self._get_room_member(jid)["nick"] except KeyError: return None def is_jid_in_room(self, jid): """ Determine if jid is present in chat room. """ return bool(self.get_room_nick(jid)) def is_nick_in_room(self, nick): """ Determine if user with specified nick is present in chat room. """ return nick in self.muc.rooms[self.room] def _update_room_users_last_seen(self, jid): """Update last seen timestamp of user in chat room""" self.room_users_last_seen[jid] = datetime.now() def get_jid(self, msg, bare=True): """ Helper method for retrieving Jabber ID from message. """ if msg["type"] == "groupchat" and self.room: # Room MUC message jid = self.muc.getJidProperty(self.room, msg["mucnick"], "jid") elif msg["type"] == "chat" and self.room and msg["from"].bare == self.room: # Private MUC message jid = self.muc.getJidProperty(self.room, msg["from"].resource, "jid") else: jid = msg["from"] if bare and jid: return jid.bare return jid def is_jid_user(self, jid): """ Return True if bare JID (obtained by get_jid()) is user or users are not set. """ return not self.users or jid in self.users def is_jid_admin(self, jid): """ Return True if bare JID (obtained by get_jid()) is admin or admins are not set. """ return not self.admins or jid in self.admins def is_jid_room_user(self, jid): """ Return True if bare JID (obtained by get_jid()) is user or users are not set. """ return not self.room_users or jid in self.room_users def is_jid_room_admin(self, jid): """ Return True if bare JID (obtained by get_jid()) is admin or admins are not set. """ return not self.room_admins or jid in self.room_admins @staticmethod def is_msg_delayed(msg): """ Return True if msg has a delay stanza with a timestamp. """ delay = msg.get("delay", None) return delay and delay.get_stamp() def _handle_new_subscription(self, pres): """ client.auto_authorize is True by default, which is fine. But we want to restrict this to users only (if set). We do this by overriding the automatic subscription mechanism. """ user = pres["from"] if not self.users or user in self.users: logger.info('Allowing user "%s" to auto subscribe', user) # noinspection PyProtectedMember self.client._handle_new_subscription(pres) else: logger.warning('User "%s" is not allowed to subscribe', user) self.client.del_roster_item(user) # noinspection PyUnusedLocal def _session_start(self, event): """ Process the session_start event. """ self.client.get_roster() self._roster_cleanup() self.client.send_presence(pnick=self.nick) if self.room and self.muc: logger.info("Initializing multi-user chat room %s", self.room) self.muc.joinMUC(self.room, self.nick, maxhistory=self.maxhistory) def _roster_cleanup(self): """ Remove roster items with none subscription. """ roster = self.client_roster logger.info("Current roster: %s", ", ".join(roster.keys())) # Remove users with none subscription from roster # Also remove users that are not in users setting (if set) for i in tuple(roster.keys()): # Copy for python 3 if roster[i]["subscription"] == "none" or (self.users and i not in self.users): logger.warning("Roster item: %s (%s) - removing!", i, roster[i]["subscription"]) self.client.send_presence(pto=i, ptype="unsubscribe") self.client.del_roster_item(i) elif roster[i]["subscription"] == "to": logger.info("Roster item: %s (%s) - sending presence subscription", i, roster[i]["subscription"]) self.client.send_presence_subscription(i) else: logger.info("Roster item: %s (%s) - ok", i, roster[i]["subscription"]) def _command_not_found(self, msg, cmd_name): """ Default bot_command_not_found event handler - called in case the command does not exist. """ if self.xmpp.is_jid_user(self.xmpp.get_jid(msg)): self.msg_reply(msg, "ERROR: **%s**: command not found" % cmd_name) def _run_command(self, msg): """ Default bot_message event handler - parses the message, finds a command and runs it. """ try: cmd_name = msg.get("body", "").split()[0].strip() except IndexError: cmd_name = "" # Seek received text in available commands and get command cmd = self.commands.get_command(cmd_name) if cmd: start_time = time.time() # Get and run command out = cmd.get_fun(self)(msg) if out: cmd_time = time.time() - start_time logger.info("Command %s.%s finished in %g seconds", cmd.module, cmd.name, cmd_time) else: # Fire the bot_command_not_found event (by default: self._command_not_found()) self._run_event_handlers("bot_command_not_found", msg, cmd_name) def _bot_message(self, msg, types=("chat", "normal")): """ Incoming message handler. """ msg_type = msg["type"] if msg_type == "error": error = msg["error"] logger.error( 'Received error message from=%s to=%s: type="%s", condition="%s"', msg["from"], msg["to"], error["type"], error["condition"], ) if msg_type not in types: if msg_type != "groupchat": # Groupchat is handled by muc_message() logger.warning("Unhandled %s message from %s: %s", msg_type, msg["from"], msg) return if self.is_msg_delayed(msg): return # Ignore delayed messages # Wrap around the Message object msg = IncomingLudolphMessage.wrap_msg(msg) # Fire the bot_message event (by default: self._run_command()) self._run_event_handlers("bot_message", msg) def _muc_message(self, msg): """ MUC Incoming message handler. """ if not self._muc_ready: return if msg["mucnick"] == self.nick: return # Loop protection if self.is_msg_delayed(msg): return # Ignore delayed messages # Respond to the message only if the bots nickname is mentioned # And only if we can get user's JID nick = self.nick + ":" if msg["body"].startswith(nick) and self.get_jid(msg): msg["body"] = msg["body"][len(nick) :].lstrip() self._bot_message(msg, types=("groupchat",)) else: # Fire the muc_message event (nothing by default) self._run_event_handlers("muc_message", IncomingLudolphMessage.wrap_msg(msg)) def _muc_user_online(self, presence): """ Process an online presence stanza from a chat room. """ # Configure room and say hello from jabber bot if this is a presence stanza if presence["from"] == self.room_jid: self._room_config() self.client.send_presence(pto=presence["from"], pnick=self.nick) self._muc_ready = True logger.info("People in MUC room: %s", ", ".join(self.muc.getRoster(self.room))) # Reminder: We cannot use presence stanzas here because they are asynchronous # Reminder: We cannot use roster information here, because roster may not be ready at this point and # roster_users != room_users # Save last seen info and send invitation to all users; unless an invitation was sent in the past for user in self.room_users: if self.is_jid_in_room(user): logger.info('User "%s" already in MUC room', user) self._update_room_users_last_seen(user) elif user != self.room: if user in self.room_users_last_seen: logger.info( 'User "%s" is not currently present in MUC room, but was last seen %s', user, self.room_users_last_seen[user].isoformat(), ) else: logger.info('User "%s" is not present in MUC room', user) if self.room_invites: if user in self.room_users_invited: logger.info('User "%s" was already invited to MUC room', user) else: logger.info('Inviting "%s" to MUC room', user) self.muc.invite(self.room, user) self.room_users_invited.add(user) else: # Say hello to new user muc = presence["muc"] logger.info( 'User "%s" with nick "%s", role "%s" and affiliation "%s" is joining MUC room', muc["jid"], muc["nick"], muc["role"], muc["affiliation"], ) self._update_room_users_last_seen(muc["jid"].bare) # Fire the muc_user_online event (nothing by default) self._run_event_handlers("muc_user_online", presence) def _muc_user_offline(self, presence): """ Process a offline presence stanza from a chat room. """ # Log user last seen status muc = presence["muc"] logger.info( 'User "%s" with nick "%s", role "%s" and affiliation "%s" is leaving MUC room', muc["jid"], muc["nick"], muc["role"], muc["affiliation"], ) self._update_room_users_last_seen(muc["jid"].bare) # Fire the muc_user_offline event (nothing by default) self._run_event_handlers("muc_user_online", presence) def handle_attention(self, msg): self.msg_reply(msg, "Whats up, buddy? If you are lost, type **help** to see what I am capable of...") # noinspection PyUnusedLocal def shutdown(self, signalnum, handler): """ Shutdown signal handler (called from main.py). """ logger.info("Requested shutdown (%s)", signalnum) if self.shutting_down: logger.warn("Shutdown is already in progress...") return self.shutting_down = True try: if self.webserver: self.webserver.stop() except Exception as e: logger.exception(e) logger.error("Webserver shutdown failed") try: if self.cron: self.cron.stop() except Exception as e: logger.exception(e) logger.error("Cron shutdown failed") try: if self.db is not None: self._db_set_items_all() # all plugins (including ludolph.bot) self.db.close() self.db_disable() except Exception as e: logger.exception(e) logger.critical("Persistent DB file could not be properly closed") try: self._destroy_plugins() except Exception as e: logger.exception(e) try: self.client.abort() except Exception as e: # Unhandled exception in SleekXMPP when socket is not connected and shutdown is requested if not self.client.socket: logger.exception(e) raise SystemExit(99) raise def prereload(self): """ Cleanup during reload phase. Runs before plugin loading in main (called from main.py). """ self.commands.reset() if self.webserver: self.webserver.reset_webhooks() self.webserver.reset_webapp() if self.cron: self.cron.reset() if self.db is not None: self._db_set_items_all() # all plugins (including ludolph.bot) self.db.close() self.db_disable() def reload(self, config, plugins=None): """ Reload bot configuration and plugins (called from main.py). """ logger.info("Requested reload") self._reloaded = True self._load_config(config, init=False) self._load_plugins(config, plugins, init=False) if self.room and self.muc: self._muc_ready = False self.muc.leaveMUC(self.room, self.nick) logger.info("Reinitializing multi-user chat room %s", self.room) self.muc.joinMUC(self.room, self.nick, maxhistory=self.maxhistory) self._post_init_plugins() @staticmethod def msg_copy(msg, **kwargs): """ Create copy of message stanza. """ msg = copy.copy(msg) for key, val in kwargs.items(): msg[key] = val return msg def msg_send(self, mto, mbody, mfrom=None, mnick=None, **kwargs): """ Create message and send it. """ return OutgoingLudolphMessage.create(mbody, **kwargs).send(self, mto, mfrom=mfrom, mnick=mnick) # noinspection PyMethodMayBeStatic def msg_reply(self, msg, mbody, preserve_msg=False, **kwargs): """ Set message reply text and html, and send it. """ if mbody is None: return None # Command performs custom message sending if preserve_msg: msg = self.msg_copy(msg) return OutgoingLudolphMessage.create(mbody, **kwargs).reply(msg) def msg_resend(self, msg, **kwargs): """ Re-send message to original recipient with optional delay. """ defaults = {"mtype": msg.get("mtype", None), "msubject": msg.get("subject", None)} defaults.update(kwargs) return OutgoingLudolphMessage.create(msg["body"], **kwargs).send(self, msg["from"], mfrom=msg["to"]) def msg_broadcast(self, mbody, **kwargs): """ Send message to all users in roster. """ msg = OutgoingLudolphMessage.create(mbody, **kwargs) i = 0 for jid in self.client_roster: if not (jid == self.boundjid.bare or jid in self.broadcast_blacklist): msg.send(self, jid) i += 1 return i
class LudolphBot(LudolphDBMixin): """ Ludolph bot. """ _start_time = None _muc_ready = False _reloaded = False reloading = False shutting_down = False commands = COMMANDS plugins = PLUGINS room = None room_jid = None room_config = None room_invites = True room_bot_affiliation = 'owner' room_user_affiliation = 'member' room_admin_affiliation = 'admin' room_bot_role = '' room_user_role = '' room_admin_role = '' muc = None nick = 'Ludolph' # Warning: do not change the nick during runtime xmpp = None maxhistory = '16' webserver = None cron = None persistent_attrs = ('room_users_invited', 'room_users_last_seen') def __init__(self, config, plugins=None): super(LudolphBot, self).__init__() self._event_handlers = { 'bot_message': [self._run_command], 'bot_command_not_found': [self._command_not_found], 'muc_message': [], 'muc_user_online': [], 'muc_user_offline': [], } self.users = set() self.admins = set() self.broadcast_blacklist = set() self.room_users = set() self.room_admins = set() self.room_users_invited = set() self.room_users_last_seen = {} self._load_config(config, init=True) logger.info('Initializing jabber bot *%s*', self.nick) self._load_plugins(config, plugins, init=True) # Initialize the SleekXMPP client self.client = client = ClientXMPP(config.get('xmpp', 'username'), config.get('xmpp', 'password')) # These XMPP-related attributes and methods are expected to exist by plugins self.client_roster = client.client_roster self.boundjid = client.boundjid # Register XMPP plugins client.register_plugin('xep_0030') # Service Discovery client.register_plugin('xep_0045') # Multi-User Chat client.register_plugin('xep_0198') # Stream Management client.register_plugin('xep_0199') # XMPP Ping client.register_plugin('xep_0203') # Delayed Delivery client.register_plugin('xep_0084') # User Avatar client.register_plugin('xep_0153') # User Avatar vCard client.register_plugin('xep_0224') # Attention # Auto-authorize is enabled by default. User subscriptions are controlled by self._handle_new_subscription client.auto_authorize = True # Register event handlers client.add_event_handler('roster_subscription_request', self._handle_new_subscription) client.add_event_handler('session_start', self._session_start) client.add_event_handler('message', self._bot_message, threaded=True) client.add_event_handler('attention', self.handle_attention, threaded=True) if self.room: self.muc = client.plugin['xep_0045'] client.add_event_handler('groupchat_message', self._muc_message, threaded=True) client.add_event_handler('muc::%s::got_online' % self.room, self._muc_user_online, threaded=True) client.add_event_handler('muc::%s::got_offline' % self.room, self._muc_user_offline, threaded=True) # Run post initialization methods for all plugins self._post_init_plugins() # Start the web server thread for processing HTTP requests if self.webserver: # noinspection PyProtectedMember client._start_thread('webserver', self.webserver.start, track=False) # Start the scheduler thread for running periodic cron jobs if self.cron: # noinspection PyProtectedMember client._start_thread('cron', self.cron.run, track=False) # Save start time self._start_time = time.time() logger.info('Jabber bot *%s* is up and running', self.nick) # noinspection PyMethodMayBeStatic def __post_init__(self): """Run after ludolph bot instance is up and running""" pass # noinspection PyMethodMayBeStatic def __destroy__(self): """Run before ludolph bot shutdown""" pass def __getstate__(self): """Return internal data suitable for saving into persistent DB file""" # FIXME: Switch to dict comprehension after dropping support for Python 2.6 return dict((i, self.__dict__[i]) for i in self.persistent_attrs if i in self.__dict__) def __setstate__(self, state): """Set saved internal data from persistent DB""" for i in state: if i in self.persistent_attrs: self.__dict__[i].update(state[i]) @catch_exception def _db_set_item(self, name, obj): """Save object data into persistent DB""" if obj.persistent_attrs: logger.info( 'Syncing runtime data with persistent DB file for object: %s', name) self.db[name] = obj.__getstate__() else: logger.debug('Object %s has no persistent attributes', name) @catch_exception def _db_load_item(self, name, obj): """Load saved object data from persistent DB""" if obj.persistent_attrs: logger.info( 'Loading runtime data from persistent DB file for object: %s', name) data = self.db.get(name, None) if data: obj.__setstate__(data) else: logger.debug('Object %s has no saved data', name) else: logger.debug('Object %s has no persistent attributes', name) def _db_set_items(self): """Save internal data to persistent DB""" self._db_set_item(__name__, self) def _db_load_items(self): """Load saved internal data from persistent DB""" self._db_load_item(__name__, self) def _db_set_items_all(self): """Save all internal+plugin data to persistent DB for every initialized plugin""" for modname, plugin in self.plugins.items( ): # ludolph.bot is part of plugins self._db_set_item(modname, plugin) def _db_load_items_all(self): """Load all internal+plugin data from persistent DB for every initialized plugin""" for modname, plugin in self.plugins.items( ): # ludolph.bot is part of plugins self._db_load_item(modname, plugin) @staticmethod def read_jid_array(config, option, **keywords): """Read comma-separated config option and return a list of JIDs""" jids = set() if option in config: for jid in config[option].strip().split(','): jid = jid.strip() if not jid: continue if '@' in jid: if jid.startswith('@'): kwd = jid[1:] if kwd in keywords: jids.update(keywords[kwd]) else: logger.warn( 'Skipping invalid keyword "%s" from setting "%s"', jid, option) else: jids.add(jid) else: logger.warn('Skipping invalid JID "%s" from setting "%s"', jid, option) return jids def _load_config(self, config, init=False): """ Load bot settings from config object. The init parameter indicates whether this is a first-time initialization or a reload. """ logger.info('Configuring jabber bot') xmpp_config = dict(config.items('xmpp')) # Get DB file if config.has_option('global', 'dbfile'): dbfile = config.get('global', 'dbfile') if dbfile: self.db_enable(LudolphDB(dbfile), init=True) # Get nick name nick = xmpp_config.get('nick', '').strip() if nick: self.nick = nick # Warning: do not change the nick during runtime after this # If you are working with an OpenFire server, you will # need to use a different SSL version: if config.has_option('xmpp', 'sslv3') and config.getboolean( 'xmpp', 'sslv3'): # noinspection PyUnresolvedReferences self.client.ssl_version = ssl.PROTOCOL_SSLv3 # Users self.users.clear() self.users.update(self.read_jid_array(xmpp_config, 'users')) logger.info('Current users: %s', ', '.join(self.users)) # Admins self.admins.clear() self.admins.update( self.read_jid_array(xmpp_config, 'admins', users=self.users)) logger.info('Current admins: %s', ', '.join(self.admins)) # Broadcast blacklist self.broadcast_blacklist.clear() self.broadcast_blacklist.update( self.read_jid_array(xmpp_config, 'broadcast_blacklist', admins=self.admins)) logger.info('Broadcast blacklist: %s', ', '.join(self.broadcast_blacklist)) # Admins vs. users if not self.admins.issubset(self.users): for i in self.admins.difference(self.users): logger.error( 'Admin "%s" is not specified in users. ' 'This may lead to unexpected behaviour.', i) # MUC room room = xmpp_config.get('room', '').strip() if room: self.room = room self.room_jid = '%s/%s' % (self.room, self.nick) else: self.room = None self.room_jid = None # MUC room invites sending if config.has_option('xmpp', 'room_invites'): self.room_invites = config.getboolean('xmpp', 'room_invites') else: self.room_invites = LudolphBot.room_invites # MUC room affiliations and roles valid_affiliations = ('owner', 'admin', 'member', 'outcast', 'none') valid_roles = ('moderator', 'participant', 'visitor', 'none') for setting, valid_values in (('room_bot_affiliation', valid_affiliations), ('room_user_affiliation', valid_affiliations), ('room_admin_affiliation', valid_affiliations), ('room_bot_role', valid_roles), ('room_user_role', valid_roles), ('room_admin_role', valid_roles)): config_value = xmpp_config.get(setting, getattr(LudolphBot, setting)).strip() if config_value: if config_value not in valid_values: logger.error( 'Invalid value "%s" for "%s" setting. Must be one of: %s.', config_value, setting, ','.join(valid_values)) config_value = None else: config_value = None setattr(self, setting, config_value) # MUC room users self.room_users.clear() if self.room: self.room_users.update( self.read_jid_array(xmpp_config, 'room_users', users=self.users, admins=self.admins)) logger.info('Current room users: %s', ', '.join(self.room_users)) # MUC room admins self.room_admins.clear() if self.room: self.room_admins.update( self.read_jid_array(xmpp_config, 'room_admins', users=self.users, admins=self.admins, room_users=self.room_users)) logger.info('Current room admins: %s', ', '.join(self.room_admins)) # Room admins vs. users if not self.room_admins.issubset(self.room_users): for i in self.room_admins.difference(self.room_users): logger.error( 'Room admin "%s" is not specified in room_users. ' 'This may lead to unexpected behaviour.', i) # Room users vs. room_users_invited if self.room_users_invited: self.room_users_invited.intersection_update(self.room_users) # Web server (any change in configuration requires restart) if init and not self.webserver: if config.has_option('webserver', 'host') and config.has_option( 'webserver', 'port'): host = config.get('webserver', 'host').strip() port = config.getint('webserver', 'port') if host and port: # Enable server (will be started in __init__) self.webserver = WebServer(host, port) # Cron (any change in configuration requires restart) if init and not self.cron: if config.has_option('cron', 'enabled') and config.getboolean( 'cron', 'enabled'): self.cron = Cron(db=self.db) if self._reloaded: if self.cron and self.db is None: # DB support was disabled during reload self.cron.db_disable() # noinspection PyMethodMayBeStatic @catch_exception def _post_init_plugin(self, name, plugin_obj): logger.info('Post-initializing plugin: %s', name) plugin_obj.__post_init__() # noinspection PyMethodMayBeStatic @catch_exception def _destroy_plugin(self, name, plugin_obj): logger.info('Destroying plugin: %s', name) plugin_obj.__destroy__() del plugin_obj def _load_plugins(self, config, plugins, init=False): """ Initialize plugins. The init parameter indicates whether this is a first-time initialization or a reload. """ self.plugins.reset(init=init) if init: # First-time plugin initialization -> include ourself to plugins dict self.xmpp = self self.plugins[__name__] = self else: # Bot reload - remove disabled plugins for enabled_plugin in tuple( self.plugins.keys()): # Copy for python 3 if enabled_plugin == __name__: continue # Skip ourself if enabled_plugin not in plugins: logger.info('Disabling plugin: %s', enabled_plugin) self._destroy_plugin(enabled_plugin, self.plugins.pop(enabled_plugin)) if plugins: for plugin in plugins: modname = plugin.module if init or modname not in self.plugins: logger.info('Initializing plugin: %s', modname) reinit = False else: logger.info('Reloading plugin: %s', modname) self._destroy_plugin(modname, self.plugins.pop(modname)) reinit = True try: cfg = config.items( plugin.name ) # Get only plugin config section as list of (name, value) tuples obj = plugin.cls(self, cfg, reinit=reinit) except Exception as ex: logger.critical('Could not load plugin: %s', modname) logger.exception(ex) # Remove registered commands from Commands dict for this module self.commands.reset(module=modname) if self.webserver: # Remove registered webhooks for this module self.webserver.reset_webhooks(module=modname) if self.cron: # Remove registered cronjobs for this module self.cron.reset(module=modname) else: self.plugins[modname] = obj if self.db is not None: self._db_load_item(modname, obj) # Update commands cache if self.commands.all(reset=True): logger.info('Registered commands:\n%s\n', '\n'.join(self.commands.display())) else: logger.warning('NO commands registered') if self.webserver: if self.webserver.webhooks: logger.info('Registered webhooks:\n%s\n', '\n'.join(self.webserver.display_webhooks())) else: logger.warning('NO webhooks registered') else: logger.warning( 'Web server support disabled - webhooks will not work') if self.cron: if self.cron.crontab: logger.info('Registered cron jobs:\n%s\n', '\n'.join(self.cron.display_cronjobs())) else: logger.warning('NO cron jobs registered') else: logger.warning('Cron support disabled - cron jobs will not work') def _post_init_plugins(self): """ Run __post_init__() method for each initialized plugin. """ for modname, plugin in self.plugins.items( ): # ludolph.bot is part of plugins self._post_init_plugin(modname, plugin) def _destroy_plugins(self): """ Run __destroy__() method for each initialized plugin. """ for modname, plugin in reversed( self.plugins.items()): # ludolph.bot is part of plugins self._destroy_plugin(modname, plugin) def _run_event_handlers(self, event_name, *args): """ Run all event handlers when an event happens. """ for event_handler in self._event_handlers[event_name]: event_handler(*(copy.copy(arg) for arg in args)) def register_event_handler(self, event_name, fun, clear=False): """ Add a function into event handlers. """ event_handlers = self._event_handlers[event_name] if clear: logger.info('Event [%s]: Removing all event handlers', event_name) del event_handlers[:] logger.info('Event [%s]: Adding event handler "%s"', event_name, fun) event_handlers.append(fun) logger.debug('Event [%s]: Current event handlers: %s', event_name, event_handlers) def deregister_event_handler(self, event_name, fun): """ Remove a specific function from even handlers. """ event_handlers = self._event_handlers[event_name] if fun not in event_handlers: logger.warning('Event [%s]: Event handler "%s is not registered', event_name, fun) return logger.info('Event [%s]: Removing event handler "%s"', event_name, fun) self._event_handlers[event_name] = [ i for i in event_handlers if i != fun ] logger.debug('Event [%s]: Current event handlers: %s', event_name, event_handlers) def _room_members(self): """ Change multi-user chat room member list. """ query = ET.Element('{http://jabber.org/protocol/muc#admin}query') qitem = '{http://jabber.org/protocol/muc#admin}item' bot_member = {'jid': self.boundjid.bare} if self.room_bot_affiliation: bot_member['affiliation'] = self.room_bot_affiliation if self.room_bot_role: bot_member['role'] = self.room_bot_role query.append(ET.Element(qitem, bot_member)) for jid in self.room_users: room_member = {'jid': jid} affiliation = self.room_user_affiliation role = self.room_user_role if jid in self.room_admins: if self.room_admin_affiliation: affiliation = self.room_admin_affiliation if self.room_admin_role: role = self.room_admin_role if affiliation: room_member['affiliation'] = affiliation if role: room_member['role'] = role query.append(ET.Element(qitem, room_member)) iq = self.client.make_iq_set(query) iq['to'] = self.room iq['from'] = self.boundjid iq.send() def _room_config(self): """ Configure multi-user chat room. """ logger.info('Getting current configuration for MUC room %s', self.room) try: self.room_config = self.muc.getRoomConfig(self.room) except ValueError: logger.error( 'Could not get MUC room configuration. Maybe the room is not (properly) initialized.' ) return if self.room_users: self.room_config['fields']['muc#roomconfig_membersonly'][ 'value'] = True self.room_config['fields']['members_by_default']['value'] = False else: self.room_config['fields']['muc#roomconfig_membersonly'][ 'value'] = False self.room_config['fields']['members_by_default']['value'] = True self.room_config['fields']['muc#maxhistoryfetch'] = self.maxhistory logger.info('Setting new configuration for MUC room %s', self.room) try: self.muc.setRoomConfig(self.room, self.room_config) except IqError as e: logger.error( 'Could not configure MUC room. Error was: %s (condition=%s, etype=%s)', e.text, e.condition, e.etype) logger.info('Setting member list for MUC room %s', self.room) try: self._room_members() except IqError as e: logger.error( 'Could not configure MUC room member list. Error was: %s (condition=%s, etype=%s)', e.text, e.condition, e.etype) def _get_room_member(self, jid): """ Return MUC room member object according to user's bare Jabber ID. """ for nick in self.muc.rooms[self.room]: entry = self.muc.rooms[self.room][nick] if entry is not None and entry['jid'].bare == jid: return entry raise KeyError( 'User with jabber ID "%s" is not listed on the room member list' % jid) def get_room_jid(self, jid): """ Helper method for retrieving room occupant's Jabber ID (full) from to user's non-room jabber ID (bare). """ try: room_user = self._get_room_member(jid) except KeyError: return None else: return '%(room)s/%(nick)s' % room_user def get_room_nick(self, jid): """ Helper method for retrieving MUC room nick according to jid. """ try: return self._get_room_member(jid)['nick'] except KeyError: return None def is_jid_in_room(self, jid): """ Determine if jid is present in chat room. """ return bool(self.get_room_nick(jid)) def is_nick_in_room(self, nick): """ Determine if user with specified nick is present in chat room. """ return nick in self.muc.rooms[self.room] def _update_room_users_last_seen(self, jid): """Update last seen timestamp of user in chat room""" self.room_users_last_seen[jid] = datetime.now() def get_jid(self, msg, bare=True): """ Helper method for retrieving Jabber ID from message. """ if msg['type'] == 'groupchat' and self.room: # Room MUC message jid = self.muc.getJidProperty(self.room, msg['mucnick'], 'jid') elif msg['type'] == 'chat' and self.room and msg[ 'from'].bare == self.room: # Private MUC message jid = self.muc.getJidProperty(self.room, msg['from'].resource, 'jid') else: jid = msg['from'] if bare and jid: return jid.bare return jid def is_jid_user(self, jid): """ Return True if bare JID (obtained by get_jid()) is user or users are not set. """ return not self.users or jid in self.users def is_jid_admin(self, jid): """ Return True if bare JID (obtained by get_jid()) is admin or admins are not set. """ return not self.admins or jid in self.admins def is_jid_room_user(self, jid): """ Return True if bare JID (obtained by get_jid()) is user or users are not set. """ return not self.room_users or jid in self.room_users def is_jid_room_admin(self, jid): """ Return True if bare JID (obtained by get_jid()) is admin or admins are not set. """ return not self.room_admins or jid in self.room_admins @staticmethod def is_msg_delayed(msg): """ Return True if msg has a delay stanza with a timestamp. """ delay = msg.get('delay', None) return delay and delay.get_stamp() def _handle_new_subscription(self, pres): """ client.auto_authorize is True by default, which is fine. But we want to restrict this to users only (if set). We do this by overriding the automatic subscription mechanism. """ user = pres['from'] if not self.users or user in self.users: logger.info('Allowing user "%s" to auto subscribe', user) # noinspection PyProtectedMember self.client._handle_new_subscription(pres) else: logger.warning('User "%s" is not allowed to subscribe', user) self.client.del_roster_item(user) # noinspection PyUnusedLocal def _session_start(self, event): """ Process the session_start event. """ self.client.get_roster() self._roster_cleanup() self.client.send_presence(pnick=self.nick) if self.room and self.muc: logger.info('Initializing multi-user chat room %s', self.room) self.muc.joinMUC(self.room, self.nick, maxhistory=self.maxhistory) def _roster_cleanup(self): """ Remove roster items with none subscription. """ roster = self.client_roster logger.info('Current roster: %s', ', '.join(roster.keys())) # Remove users with none subscription from roster # Also remove users that are not in users setting (if set) for i in tuple(roster.keys()): # Copy for python 3 if roster[i]['subscription'] == 'none' or (self.users and i not in self.users): logger.warning('Roster item: %s (%s) - removing!', i, roster[i]['subscription']) self.client.send_presence(pto=i, ptype='unsubscribe') self.client.del_roster_item(i) elif roster[i]['subscription'] == 'to': logger.info( 'Roster item: %s (%s) - sending presence subscription', i, roster[i]['subscription']) self.client.send_presence_subscription(i) else: logger.info('Roster item: %s (%s) - ok', i, roster[i]['subscription']) def _command_not_found(self, msg, cmd_name): """ Default bot_command_not_found event handler - called in case the command does not exist. """ if self.xmpp.is_jid_user(self.xmpp.get_jid(msg)): self.msg_reply(msg, 'ERROR: **%s**: command not found' % cmd_name) def _run_command(self, msg): """ Default bot_message event handler - parses the message, finds a command and runs it. """ try: cmd_name = msg.get('body', '').split()[0].strip() except IndexError: cmd_name = '' # Seek received text in available commands and get command cmd = self.commands.get_command(cmd_name) if cmd: start_time = time.time() # Get and run command out = cmd.get_fun(self)(msg) if out: cmd_time = time.time() - start_time logger.info('Command %s.%s finished in %g seconds', cmd.module, cmd.name, cmd_time) else: # Fire the bot_command_not_found event (by default: self._command_not_found()) self._run_event_handlers('bot_command_not_found', msg, cmd_name) def _bot_message(self, msg, types=('chat', 'normal')): """ Incoming message handler. """ msg_type = msg['type'] if msg_type == 'error': error = msg['error'] logger.error( 'Received error message from=%s to=%s: type="%s", condition="%s"', msg['from'], msg['to'], error['type'], error['condition']) if msg_type not in types: if msg_type != 'groupchat': # Groupchat is handled by muc_message() logger.warning('Unhandled %s message from %s: %s', msg_type, msg['from'], msg) return if self.is_msg_delayed(msg): return # Ignore delayed messages # Wrap around the Message object msg = IncomingLudolphMessage.wrap_msg(msg) # Fire the bot_message event (by default: self._run_command()) self._run_event_handlers('bot_message', msg) def _muc_message(self, msg): """ MUC Incoming message handler. """ if not self._muc_ready: return if msg['mucnick'] == self.nick: return # Loop protection if self.is_msg_delayed(msg): return # Ignore delayed messages # Respond to the message only if the bots nickname is mentioned # And only if we can get user's JID nick = self.nick + ':' if msg['body'].startswith(nick) and self.get_jid(msg): msg['body'] = msg['body'][len(nick):].lstrip() self._bot_message(msg, types=('groupchat', )) else: # Fire the muc_message event (nothing by default) self._run_event_handlers('muc_message', IncomingLudolphMessage.wrap_msg(msg)) def _muc_user_online(self, presence): """ Process an online presence stanza from a chat room. """ # Configure room and say hello from jabber bot if this is a presence stanza if presence['from'] == self.room_jid: self._room_config() self.client.send_presence(pto=presence['from'], pnick=self.nick) self._muc_ready = True logger.info('People in MUC room: %s', ', '.join(self.muc.getRoster(self.room))) # Reminder: We cannot use presence stanzas here because they are asynchronous # Reminder: We cannot use roster information here, because roster may not be ready at this point and # roster_users != room_users # Save last seen info and send invitation to all users; unless an invitation was sent in the past for user in self.room_users: if self.is_jid_in_room(user): logger.info('User "%s" already in MUC room', user) self._update_room_users_last_seen(user) elif user != self.room: if user in self.room_users_last_seen: logger.info( 'User "%s" is not currently present in MUC room, but was last seen %s', user, self.room_users_last_seen[user].isoformat()) else: logger.info('User "%s" is not present in MUC room', user) if self.room_invites: if user in self.room_users_invited: logger.info( 'User "%s" was already invited to MUC room', user) else: logger.info('Inviting "%s" to MUC room', user) self.muc.invite(self.room, user) self.room_users_invited.add(user) else: # Say hello to new user muc = presence['muc'] logger.info( 'User "%s" with nick "%s", role "%s" and affiliation "%s" is joining MUC room', muc['jid'], muc['nick'], muc['role'], muc['affiliation']) self._update_room_users_last_seen(muc['jid'].bare) # Fire the muc_user_online event (nothing by default) self._run_event_handlers('muc_user_online', presence) def _muc_user_offline(self, presence): """ Process a offline presence stanza from a chat room. """ # Log user last seen status muc = presence['muc'] logger.info( 'User "%s" with nick "%s", role "%s" and affiliation "%s" is leaving MUC room', muc['jid'], muc['nick'], muc['role'], muc['affiliation']) self._update_room_users_last_seen(muc['jid'].bare) # Fire the muc_user_offline event (nothing by default) self._run_event_handlers('muc_user_online', presence) def handle_attention(self, msg): self.msg_reply( msg, 'Whats up, buddy? If you are lost, type **help** to see what I am capable of...' ) # noinspection PyUnusedLocal def shutdown(self, signalnum, handler): """ Shutdown signal handler (called from main.py). """ logger.info('Requested shutdown (%s)', signalnum) if self.shutting_down: logger.warn('Shutdown is already in progress...') return self.shutting_down = True try: if self.webserver: self.webserver.stop() except Exception as e: logger.exception(e) logger.error('Webserver shutdown failed') try: if self.cron: self.cron.stop() except Exception as e: logger.exception(e) logger.error('Cron shutdown failed') try: if self.db is not None: self._db_set_items_all() # all plugins (including ludolph.bot) self.db.close() self.db_disable() except Exception as e: logger.exception(e) logger.critical('Persistent DB file could not be properly closed') try: self._destroy_plugins() except Exception as e: logger.exception(e) try: self.client.abort() except Exception as e: # Unhandled exception in SleekXMPP when socket is not connected and shutdown is requested if not self.client.socket: logger.exception(e) raise SystemExit(99) raise def prereload(self): """ Cleanup during reload phase. Runs before plugin loading in main (called from main.py). """ self.commands.reset() if self.webserver: self.webserver.reset_webhooks() self.webserver.reset_webapp() if self.cron: self.cron.reset() if self.db is not None: self._db_set_items_all() # all plugins (including ludolph.bot) self.db.close() self.db_disable() def reload(self, config, plugins=None): """ Reload bot configuration and plugins (called from main.py). """ logger.info('Requested reload') self._reloaded = True self._load_config(config, init=False) self._load_plugins(config, plugins, init=False) if self.room and self.muc: self._muc_ready = False self.muc.leaveMUC(self.room, self.nick) logger.info('Reinitializing multi-user chat room %s', self.room) self.muc.joinMUC(self.room, self.nick, maxhistory=self.maxhistory) self._post_init_plugins() @staticmethod def msg_copy(msg, **kwargs): """ Create copy of message stanza. """ msg = copy.copy(msg) for key, val in kwargs.items(): msg[key] = val return msg def msg_send(self, mto, mbody, mfrom=None, mnick=None, **kwargs): """ Create message and send it. """ return OutgoingLudolphMessage.create(mbody, **kwargs).send(self, mto, mfrom=mfrom, mnick=mnick) # noinspection PyMethodMayBeStatic def msg_reply(self, msg, mbody, preserve_msg=False, **kwargs): """ Set message reply text and html, and send it. """ if mbody is None: return None # Command performs custom message sending if preserve_msg: msg = self.msg_copy(msg) return OutgoingLudolphMessage.create(mbody, **kwargs).reply(msg) def msg_resend(self, msg, **kwargs): """ Re-send message to original recipient with optional delay. """ defaults = { 'mtype': msg.get('mtype', None), 'msubject': msg.get('subject', None) } defaults.update(kwargs) return OutgoingLudolphMessage.create(msg['body'], **kwargs).send(self, msg['from'], mfrom=msg['to']) def msg_broadcast(self, mbody, **kwargs): """ Send message to all users in roster. """ msg = OutgoingLudolphMessage.create(mbody, **kwargs) i = 0 for jid in self.client_roster: if not (jid == self.boundjid.bare or jid in self.broadcast_blacklist): msg.send(self, jid) i += 1 return i
def _load_config(self, config, init=False): """ Load bot settings from config object. The init parameter indicates whether this is a first-time initialization or a reload. """ logger.info("Configuring jabber bot") xmpp_config = dict(config.items("xmpp")) # Get DB file if config.has_option("global", "dbfile"): dbfile = config.get("global", "dbfile") if dbfile: self.db_enable(LudolphDB(dbfile), init=True) # Get nick name nick = xmpp_config.get("nick", "").strip() if nick: self.nick = nick # Warning: do not change the nick during runtime after this # If you are working with an OpenFire server, you will # need to use a different SSL version: if config.has_option("xmpp", "sslv3") and config.getboolean("xmpp", "sslv3"): # noinspection PyUnresolvedReferences self.client.ssl_version = ssl.PROTOCOL_SSLv3 # Users self.users.clear() self.users.update(self.read_jid_array(xmpp_config, "users")) logger.info("Current users: %s", ", ".join(self.users)) # Admins self.admins.clear() self.admins.update(self.read_jid_array(xmpp_config, "admins", users=self.users)) logger.info("Current admins: %s", ", ".join(self.admins)) # Broadcast blacklist self.broadcast_blacklist.clear() self.broadcast_blacklist.update(self.read_jid_array(xmpp_config, "broadcast_blacklist", admins=self.admins)) logger.info("Broadcast blacklist: %s", ", ".join(self.broadcast_blacklist)) # Admins vs. users if not self.admins.issubset(self.users): for i in self.admins.difference(self.users): logger.error('Admin "%s" is not specified in users. ' "This may lead to unexpected behaviour.", i) # MUC room room = xmpp_config.get("room", "").strip() if room: self.room = room self.room_jid = "%s/%s" % (self.room, self.nick) else: self.room = None self.room_jid = None # MUC room invites sending if config.has_option("xmpp", "room_invites"): self.room_invites = config.getboolean("xmpp", "room_invites") else: self.room_invites = LudolphBot.room_invites # MUC room affiliations and roles valid_affiliations = ("owner", "admin", "member", "outcast", "none") valid_roles = ("moderator", "participant", "visitor", "none") for setting, valid_values in ( ("room_bot_affiliation", valid_affiliations), ("room_user_affiliation", valid_affiliations), ("room_admin_affiliation", valid_affiliations), ("room_bot_role", valid_roles), ("room_user_role", valid_roles), ("room_admin_role", valid_roles), ): config_value = xmpp_config.get(setting, getattr(LudolphBot, setting)).strip() if config_value: if config_value not in valid_values: logger.error( 'Invalid value "%s" for "%s" setting. Must be one of: %s.', config_value, setting, ",".join(valid_values), ) config_value = None else: config_value = None setattr(self, setting, config_value) # MUC room users self.room_users.clear() if self.room: self.room_users.update(self.read_jid_array(xmpp_config, "room_users", users=self.users, admins=self.admins)) logger.info("Current room users: %s", ", ".join(self.room_users)) # MUC room admins self.room_admins.clear() if self.room: self.room_admins.update( self.read_jid_array( xmpp_config, "room_admins", users=self.users, admins=self.admins, room_users=self.room_users ) ) logger.info("Current room admins: %s", ", ".join(self.room_admins)) # Room admins vs. users if not self.room_admins.issubset(self.room_users): for i in self.room_admins.difference(self.room_users): logger.error( 'Room admin "%s" is not specified in room_users. ' "This may lead to unexpected behaviour.", i ) # Room users vs. room_users_invited if self.room_users_invited: self.room_users_invited.intersection_update(self.room_users) # Web server (any change in configuration requires restart) if init and not self.webserver: if config.has_option("webserver", "host") and config.has_option("webserver", "port"): host = config.get("webserver", "host").strip() port = config.getint("webserver", "port") if host and port: # Enable server (will be started in __init__) self.webserver = WebServer(host, port) # Cron (any change in configuration requires restart) if init and not self.cron: if config.has_option("cron", "enabled") and config.getboolean("cron", "enabled"): self.cron = Cron(db=self.db) if self._reloaded: if self.cron and self.db is None: # DB support was disabled during reload self.cron.db_disable()
def _load_config(self, config, init=False): """ Load bot settings from config object. The init parameter indicates whether this is a first-time initialization or a reload. """ logger.info('Configuring jabber bot') xmpp_config = dict(config.items('xmpp')) # Get DB file if config.has_option('global', 'dbfile'): dbfile = config.get('global', 'dbfile') if dbfile: self.db_enable(LudolphDB(dbfile), init=True) # Get nick name nick = xmpp_config.get('nick', '').strip() if nick: self.nick = nick # Warning: do not change the nick during runtime after this # If you are working with an OpenFire server, you will # need to use a different SSL version: if config.has_option('xmpp', 'sslv3') and config.getboolean( 'xmpp', 'sslv3'): # noinspection PyUnresolvedReferences self.client.ssl_version = ssl.PROTOCOL_SSLv3 # Users self.users.clear() self.users.update(self.read_jid_array(xmpp_config, 'users')) logger.info('Current users: %s', ', '.join(self.users)) # Admins self.admins.clear() self.admins.update( self.read_jid_array(xmpp_config, 'admins', users=self.users)) logger.info('Current admins: %s', ', '.join(self.admins)) # Broadcast blacklist self.broadcast_blacklist.clear() self.broadcast_blacklist.update( self.read_jid_array(xmpp_config, 'broadcast_blacklist', admins=self.admins)) logger.info('Broadcast blacklist: %s', ', '.join(self.broadcast_blacklist)) # Admins vs. users if not self.admins.issubset(self.users): for i in self.admins.difference(self.users): logger.error( 'Admin "%s" is not specified in users. ' 'This may lead to unexpected behaviour.', i) # MUC room room = xmpp_config.get('room', '').strip() if room: self.room = room self.room_jid = '%s/%s' % (self.room, self.nick) else: self.room = None self.room_jid = None # MUC room invites sending if config.has_option('xmpp', 'room_invites'): self.room_invites = config.getboolean('xmpp', 'room_invites') else: self.room_invites = LudolphBot.room_invites # MUC room affiliations and roles valid_affiliations = ('owner', 'admin', 'member', 'outcast', 'none') valid_roles = ('moderator', 'participant', 'visitor', 'none') for setting, valid_values in (('room_bot_affiliation', valid_affiliations), ('room_user_affiliation', valid_affiliations), ('room_admin_affiliation', valid_affiliations), ('room_bot_role', valid_roles), ('room_user_role', valid_roles), ('room_admin_role', valid_roles)): config_value = xmpp_config.get(setting, getattr(LudolphBot, setting)).strip() if config_value: if config_value not in valid_values: logger.error( 'Invalid value "%s" for "%s" setting. Must be one of: %s.', config_value, setting, ','.join(valid_values)) config_value = None else: config_value = None setattr(self, setting, config_value) # MUC room users self.room_users.clear() if self.room: self.room_users.update( self.read_jid_array(xmpp_config, 'room_users', users=self.users, admins=self.admins)) logger.info('Current room users: %s', ', '.join(self.room_users)) # MUC room admins self.room_admins.clear() if self.room: self.room_admins.update( self.read_jid_array(xmpp_config, 'room_admins', users=self.users, admins=self.admins, room_users=self.room_users)) logger.info('Current room admins: %s', ', '.join(self.room_admins)) # Room admins vs. users if not self.room_admins.issubset(self.room_users): for i in self.room_admins.difference(self.room_users): logger.error( 'Room admin "%s" is not specified in room_users. ' 'This may lead to unexpected behaviour.', i) # Room users vs. room_users_invited if self.room_users_invited: self.room_users_invited.intersection_update(self.room_users) # Web server (any change in configuration requires restart) if init and not self.webserver: if config.has_option('webserver', 'host') and config.has_option( 'webserver', 'port'): host = config.get('webserver', 'host').strip() port = config.getint('webserver', 'port') if host and port: # Enable server (will be started in __init__) self.webserver = WebServer(host, port) # Cron (any change in configuration requires restart) if init and not self.cron: if config.has_option('cron', 'enabled') and config.getboolean( 'cron', 'enabled'): self.cron = Cron(db=self.db) if self._reloaded: if self.cron and self.db is None: # DB support was disabled during reload self.cron.db_disable()