diff --git a/evennia/commands/default/cmdset_player.py b/evennia/commands/default/cmdset_player.py index 9a96a375a1..5ed27ff8a9 100644 --- a/evennia/commands/default/cmdset_player.py +++ b/evennia/commands/default/cmdset_player.py @@ -69,4 +69,5 @@ class PlayerCmdSet(CmdSet): self.add(comms.CmdCdesc()) self.add(comms.CmdPage()) self.add(comms.CmdIRC2Chan()) + self.add(comms.CmdIRCStatus()) self.add(comms.CmdRSS2Chan()) diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index 9b53e94116..fe2bba5ae1 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -796,6 +796,26 @@ class CmdPage(COMMAND_DEFAULT_CLASS): self.msg("You paged %s with: '%s'." % (", ".join(received), message)) +def _list_bots(): + """ + Helper function to produce a list of all IRC bots. + + Returns: + bots (str): A table of bots or an error message. + + """ + ircbots = [bot for bot in PlayerDB.objects.filter(db_is_bot=True, username__startswith="ircbot-")] + if ircbots: + from evennia.utils.evtable import EvTable + table = EvTable("{w#dbref{n", "{wbotname{n", "{wev-channel{n", "{wirc-channel{n", "{wSSL{n", maxwidth=_DEFAULT_WIDTH) + for ircbot in ircbots: + ircinfo = "%s (%s:%s)" % (ircbot.db.irc_channel, ircbot.db.irc_network, ircbot.db.irc_port) + table.add_row("#%i" % ircbot.id, ircbot.db.irc_botname, ircbot.db.ev_channel, ircinfo, ircbot.db.irc_ssl) + return table + else: + return "No irc bots found." + return + class CmdIRC2Chan(COMMAND_DEFAULT_CLASS): """ link an evennia channel to an external IRC channel @@ -841,19 +861,9 @@ class CmdIRC2Chan(COMMAND_DEFAULT_CLASS): if 'list' in self.switches: # show all connections - ircbots = [bot for bot in PlayerDB.objects.filter(db_is_bot=True, username__startswith="ircbot-")] - if ircbots: - from evennia.utils.evtable import EvTable - table = EvTable("{wdbid{n", "{wbotname{n", "{wev-channel{n", "{wirc-channel{n", "{wSSL{n", maxwidth=_DEFAULT_WIDTH) - for ircbot in ircbots: - ircinfo = "%s (%s:%s)" % (ircbot.db.irc_channel, ircbot.db.irc_network, ircbot.db.irc_port) - table.add_row("#%i" % ircbot.id, ircbot.db.irc_botname, ircbot.db.ev_channel, ircinfo, ircbot.db.irc_ssl) - self.msg(table) - else: - self.msg("No irc bots found.") + self.msg(_list_bots()) return - if('disconnect' in self.switches or 'remove' in self.switches or 'delete' in self.switches): botname = "ircbot-%s" % self.lhs @@ -911,6 +921,74 @@ class CmdIRC2Chan(COMMAND_DEFAULT_CLASS): irc_network=irc_network, irc_port=irc_port, irc_ssl=irc_ssl) self.msg("Connection created. Starting IRC bot.") + +class CmdIRCStatus(COMMAND_DEFAULT_CLASS): + """ + Check and reboot IRC bot. + + Usage: + ircstatus [#dbref ping||nicklist||reconnect] + + If not given arguments, will return a list of all bots (like + @irc2chan/list). The 'ping' argument will ping the IRC network to + see if the connection is still responsive. The 'users' argument + will return a list of users on the remote IRC channel. Finally, + 'reconnect' will force the client to disconnect and reconnect + again. This may be a last resort if the client has silently lost + connection (this may happen if the remote network experience + network issues). During the reconnection messages sent to either + channel will be lost. + + """ + key = "@ircstatus" + locks = "cmd:serversetting(IRC_ENABLED) and perm(ircstatus) or perm(Builders))" + help_category = "Comms" + + def func(self): + "Handles the functioning of the command." + + if not self.args: + self.msg(_list_bots()) + return + # should always be on the form botname option + args = self.args.split() + if len(args) != 2: + self.msg("Usage: @ircstatus [#dbref ping||nicklist||reconnect]") + return + botname, option = args + if option not in ("ping", "users", "reconnect", "nicklist"): + self.msg("Not a valid option.") + return + matches = None + if utils.dbref(botname): + matches = PlayerDB.objects.filter(db_is_bot=True, id=utils.dbref(botname)) + else: + self.msg("No matching IRC-bot was found.") + return + ircbot = matches[0] + channel = ircbot.db.irc_channel + network = ircbot.db.irc_network + port = ircbot.db.irc_port + chtext = "IRC bot '%s' on channel %s (%s:%s)" % (ircbot.db.irc_botname, channel, network, port) + if option == "ping": + # check connection by sending outself a ping through the server. + self.caller.msg("Pinging through %s." % chtext) + ircbot.ping(self.caller) + elif option in ("users", "nicklist"): + # retrieve user list. The bot must handles the echo since it's + # an asynchronous call. + self.caller.msg("Requesting nicklist from %s (%s:%s)." % (channel, network, port)) + ircbot.get_nicklist(self.caller) + elif self.caller.locks.check_lockstring(self.caller, "dummy:perm(ircstatus) or perm(Immortals)"): + # reboot the client + self.caller.msg("Forcing a disconnect + reconnect of %s." % chtext) + ircbot.reconnect() + else: + self.caller.msg("You don't have permission to force-reload the IRC bot.") + + + + # RSS connection class CmdRSS2Chan(COMMAND_DEFAULT_CLASS): """ diff --git a/evennia/players/bots.py b/evennia/players/bots.py index c20e5199c7..2ca708ad51 100644 --- a/evennia/players/bots.py +++ b/evennia/players/bots.py @@ -194,6 +194,47 @@ class IRCBot(Bot): "ssl": self.db.irc_ssl} _SESSIONS.start_bot_session("evennia.server.portal.irc.IRCBotFactory", configdict) + def get_nicklist(self, caller): + """ + Retrive the nick list from the connected channel. + + Args: + caller (Object or Player): The requester of the list. This will + be stored and echoed to when the irc network replies with the + requested info. + + Notes: Since the return is asynchronous, the caller is stored internally + in a list; all callers in this list will get the nick info once it + returns (it is a custom OOB inputfunc option). The callback will not + survive a reload (which should be fine, it's very quick). + """ + if not hasattr(self, "_nicklist_callers"): + self._nicklist_callers = [] + self._nicklist_callers.append(caller) + super(IRCBot, self).msg(request_nicklist="") + return + + def ping(self, caller): + """ + Fire a ping to the IRC server. + + Args: + caller (Object or Player): The requester of the ping. + + """ + if not hasattr(self, "_ping_callers"): + self._ping_callers = [] + self._ping_callers.append(caller) + super(IRCBot, self).msg(ping="") + + def reconnect(self): + """ + Force a protocol-side reconnect of the client without + having to destroy/recreate the bot "player". + + """ + super(IRCBot, self).msg(reconnect="") + def msg(self, text=None, **kwargs): """ Takes text from connected channel (only). @@ -204,7 +245,7 @@ class IRCBot(Bot): Kwargs: options (dict): Options dict with the following allowed keys: - from_channel (str): dbid of a channel this text originated from. - - from_obj (str): dbid of an object sending this text. + - from_obj (list): list of objects this text. """ from_obj = kwargs.get("from_obj", None) @@ -224,17 +265,43 @@ class IRCBot(Bot): Args: session (Session, optional): Session responsible for this command. - text (str, optional): Command string. - kwargs (dict, optional): Additional Information passed from bot. - Typically information is only passed by IRCbot including: - user (str): The name of the user who sent the message. - channel (str): The name of channel the message was sent to. - type (str): Nature of message. Either 'msg' or 'action'. + txt (str, optional): Command string. + Kwargs: + user (str): The name of the user who sent the message. + channel (str): The name of channel the message was sent to. + type (str): Nature of message. Either 'msg', 'action', 'nicklist' or 'ping'. + nicklist (list, optional): Set if `type='nicklist'`. This is a list of nicks returned by calling + the `self.get_nicklist`. It must look for a list `self._nicklist_callers` + which will contain all callers waiting for the nicklist. + timings (float, optional): Set if `type='ping'`. This is the return (in seconds) of a + ping request triggered with `self.ping`. The return must look for a list + `self._ping_callers` which will contain all callers waiting for the ping return. """ - if kwargs["type"] == "action": + if kwargs["type"] == "nicklist": + # the return of a nicklist request + if hasattr(self, "_nicklist_callers") and self._nicklist_callers: + chstr = "%s (%s:%s)" % (self.db.irc_channel, self.db.irc_network, self.db.irc_port) + for obj in self._nicklist_callers: + obj.msg("Nicks at %s:\n %s" % (chstr, ", ".join(kwargs["nicklist"]))) + self._nicklist_callers = [] + return + + elif kwargs["type"] == "ping": + # the return of a ping + if hasattr(self, "_ping_callers") and self._ping_callers: + chstr = "%s (%s:%s)" % (self.db.irc_channel, self.db.irc_network, self.db.irc_port) + for obj in self._ping_callers: + obj.msg("IRC ping return from %s took %ss." % (chstr, kwargs["timing"])) + self._ping_callers = [] + return + + elif kwargs["type"] == "action": + # An action (irc pose) text = "%s@%s %s" % (kwargs["user"], kwargs["channel"], txt) + else: + # A normal channel message text = "%s@%s: %s" % (kwargs["user"], kwargs["channel"], txt) if not self.ndb.ev_channel and self.db.ev_channel: diff --git a/evennia/server/portal/irc.py b/evennia/server/portal/irc.py index e1fdfa3b6a..008f2ac690 100644 --- a/evennia/server/portal/irc.py +++ b/evennia/server/portal/irc.py @@ -134,6 +134,7 @@ class IRCBot(irc.IRCClient, Session): logger = None factory = None channel = None + sourceURL = "http://code.evennia.com" def signedOn(self): """ @@ -194,6 +195,42 @@ class IRCBot(irc.IRCClient, Session): user = user.split('!', 1)[0] self.data_in(text=msg, type="action", user=user, channel=channel) + def get_nicklist(self): + """ + Retrieve name list from the channel. The return + is handled by the catch methods below. + + """ + if not self.nicklist: + self.sendLine("NAMES %s" % self.channel) + + def irc_RPL_NAMREPLY(self, prefix, params): + "Handles IRC NAME request returns (nicklist)" + channel = params[2].lower() + if channel != self.channel.lower(): + return + self.nicklist += params[3].split(' ') + + def irc_RPL_ENDOFNAMES(self, prefix, params): + "Called when the nicklist has finished being returned." + channel = params[1].lower() + if channel != self.channel.lower(): + return + self.data_in(text="", type="nicklist", user="server", channel=channel, nicklist=self.nicklist) + self.nicklist = [] + + def pong(self, user, time): + """ + Called with the return timing from a PING. + + Args: + user (str): Njame of user + time (float): Ping time in secs. + + """ + self.data_in(text="", type="ping", user="server", channel=self.channel, timing=time) + + def data_in(self, text=None, **kwargs): """ Data IRC -> Server. @@ -221,6 +258,28 @@ class IRCBot(irc.IRCClient, Session): text = parse_irc_colors(text) self.say(self.channel, text) + def send_request_nicklist(self, *args, **kwargs): + """ + Send a request for the channel nicklist. The return (handled + by `self.irc_RPL_ENDOFNAMES`) will be sent back as a message + with type `nicklist'. + """ + self.get_nicklist() + + def send_ping(self, *args, **kwargs): + """ + Send a ping. The return (handled by `self.pong`) will be sent + back as a message of type 'ping'. + """ + self.ping(self.nickname) + + def send_reconnect(self, *args, **kwargs): + """ + The server instructs us to rebuild the connection by force, + probably because the client silently lost connection. + """ + self.factory.reconnect() + def send_default(self, *args, **kwargs): """ Ignore other types of sends. @@ -231,7 +290,7 @@ class IRCBot(irc.IRCClient, Session): class IRCBotFactory(protocol.ReconnectingClientFactory): """ - Creates instances of AnnounceBot, connecting with a staggered + Creates instances of IRCBot, connecting with a staggered increase in delay """ @@ -264,6 +323,7 @@ class IRCBotFactory(protocol.ReconnectingClientFactory): self.port = port self.ssl = ssl self.bot = None + self.nicklists = {} def buildProtocol(self, addr): """ @@ -280,6 +340,7 @@ class IRCBotFactory(protocol.ReconnectingClientFactory): protocol.network = self.network protocol.port = self.port protocol.ssl = self.ssl + protocol.nicklist = [] return protocol def startedConnecting(self, connector): @@ -312,9 +373,22 @@ class IRCBotFactory(protocol.ReconnectingClientFactory): reason (str): The reason for the failure. """ - if not self.bot or (self.bot and self.bot.stopping): + if not (self.bot or (self.bot and self.bot.stopping)): self.retry(connector) + def reconnect(self): + """ + Force a reconnection of the bot protocol. This requires + de-registering the session and then reattaching a new one, + otherwise you end up with an ever growing number of bot + sessions. + + """ + self.bot.stopping = True + self.bot.transport.loseConnection() + self.sessionhandler.server_disconnect(self.bot) + self.start() + def start(self): """ Connect session to sessionhandler. @@ -326,7 +400,7 @@ class IRCBotFactory(protocol.ReconnectingClientFactory): from twisted.internet import ssl service = reactor.connectSSL(self.network, int(self.port), self, ssl.ClientContextFactory()) except ImportError: - self.caller.msg("To use SSL, the PyOpenSSL module must be installed.") + logger.log_err("To use SSL, the PyOpenSSL module must be installed.") else: service = internet.TCPClient(self.network, int(self.port), self) self.sessionhandler.portal.services.addService(service)