From 8b4f8b25458794d06d7fd3766a5b3408a98a5a4e Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Sun, 12 Feb 2017 10:44:53 -0600 Subject: [PATCH 1/6] move message splitting into IRCBot.reply() leaves IRCBot.privmsg() pretty vanilla. this should make it clearer which version for modules/etc to use (hint: it's reply) bss/dr.botzo#21 --- ircbot/bot.py | 47 ++++++++++++++++++++--------------------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/ircbot/bot.py b/ircbot/bot.py index 57516c6..2f43767 100644 --- a/ircbot/bot.py +++ b/ircbot/bot.py @@ -53,6 +53,11 @@ class LenientServerConnection(irc.client.ServerConnection): buffer_class = irc.buffer.LenientDecodingLineBuffer + def _prep_message(self, string): + """Override SimpleIRCClient._prep_message to add some logging.""" + log.debug("preparing message %s", string) + return super(LenientServerConnection, self)._prep_message(string) + class DrReactor(irc.client.Reactor): """Customize the basic IRC library's Reactor with more features.""" @@ -318,6 +323,7 @@ class IRCBot(irc.client.SimpleIRCClient): """A single-server IRC bot class.""" reactor_class = DrReactor + splitter = "..." def __init__(self, reconnection_interval=60): super(IRCBot, self).__init__() @@ -832,33 +838,7 @@ class IRCBot(irc.client.SimpleIRCClient): return log.debug("OUTGOING PRIVMSG: t[%s] m[%s]", target, text) - - splitter = "..." - - # split messages that are too long. Max length is 512. - # TODO: this does not properly handle when the nickmask has been masked by the ircd - # is the above still the case? - space = 512 - len('\r\n') - len(' PRIVMSG ') - len(target) - len(' :') - len(self.nickmask) - len(' :') - splitspace = space - (len(splitter) + 1) - - if len(text) > space: - times = 1 - - while len(text) > splitspace: - splitpos = text.rfind(' ', 0, splitspace) - splittext = text[0:splitpos] + ' ' + splitter - text = splitter + ' ' + text[splitpos+1:] - self.connection.send_raw("PRIVMSG {0:s} :{1:s}".format(target, splittext)) - - times = times + 1 - if times >= 4: - # this is stupidly long, abort - return - - # done splitting - self.connection.send_raw("PRIVMSG {0:s} :{1:s}".format(target, text)) - else: - self.connection.send_raw("PRIVMSG {0:s} :{1:s}".format(target, text)) + self.connection.send_raw("PRIVMSG {0:s} :{1:s}".format(target, text)) def reply(self, event, replystr, stop=False): """Reply over IRC to replypath or return a string with the reply. @@ -894,6 +874,19 @@ class IRCBot(irc.client.SimpleIRCClient): else: replies = replystr.split('\n') for reply in replies: + # split messages that are too long. max length is 512, but we also need to + # account for display and that'll show our hostname + space = 512 - len(':{0:s} PRIVMSG {1:s} :\r\n'.format(self.nickmask, replypath)) + splitspace = space - (len(self.splitter) + 1) + + while len(reply) > space: + # do splitting + splitpos = reply.rfind(' ', 0, splitspace) + splittext = reply[0:splitpos] + ' ' + self.splitter + reply = self.splitter + ' ' + reply[splitpos + 1:] + self.privmsg(replypath, splittext) + + # done splitting, or it was never necessary self.privmsg(replypath, reply) if stop: return "NO MORE" From 010afd05ce3999abeef13024043606ee3445ca70 Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Sun, 12 Feb 2017 10:58:18 -0600 Subject: [PATCH 2/6] add some anti-flood protection stuff this throttles multi-line messages in a way that probably doesn't affect the normal cases much, and scales fairly well to far longer text. for some reason long ascii art still triggers the flood detection, but with this code at least it happens later in the process. so, success, for now? i can fix the ascii art at some future point if i ever hit it practically closes bss/dr.botzo#23 --- ircbot/bot.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ircbot/bot.py b/ircbot/bot.py index 2f43767..df7b617 100644 --- a/ircbot/bot.py +++ b/ircbot/bot.py @@ -872,6 +872,7 @@ class IRCBot(irc.client.SimpleIRCClient): if recursing: return replystr else: + lines = 0 replies = replystr.split('\n') for reply in replies: # split messages that are too long. max length is 512, but we also need to @@ -886,6 +887,10 @@ class IRCBot(irc.client.SimpleIRCClient): reply = self.splitter + ' ' + reply[splitpos + 1:] self.privmsg(replypath, splittext) + # antiflood + lines += 1 + time.sleep(int(0.5 * lines)) + # done splitting, or it was never necessary self.privmsg(replypath, reply) if stop: From 23bb5cdd78d271f4492d8e4c7efb623c7fd54882 Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Sun, 12 Feb 2017 11:33:13 -0600 Subject: [PATCH 3/6] allow IRCBot.reply() to work eventless reply() used to require an event, but all it used it for was to determine the destination and to identify recursion. basically, strictly only -replies-. we can make this a more robust privmsg, too, by adding explicit_target and inferring recursion as False. this will let basically any code currently using privmsg to use reply instead, and benefit from multi-line and line splitting bss/dr.botzo#21 --- ircbot/bot.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/ircbot/bot.py b/ircbot/bot.py index df7b617..3d52e07 100644 --- a/ircbot/bot.py +++ b/ircbot/bot.py @@ -840,7 +840,7 @@ class IRCBot(irc.client.SimpleIRCClient): log.debug("OUTGOING PRIVMSG: t[%s] m[%s]", target, text) self.connection.send_raw("PRIVMSG {0:s} :{1:s}".format(target, text)) - def reply(self, event, replystr, stop=False): + def reply(self, event, replystr, stop=False, explicit_target=None): """Reply over IRC to replypath or return a string with the reply. The primary utility for this is to properly handle recursion. The @@ -853,22 +853,32 @@ class IRCBot(irc.client.SimpleIRCClient): method will certainly have recursion do odd things with your module. Args: - event incoming event - replystr the message to reply with - stop whether or not to let other handlers see this + event incoming event + replystr the message to reply with + stop whether or not to let other handlers see this + explicit_target if event is none, use this for the destination Returns: The replystr if the event is inside recursion, or, potentially, "NO MORE" to stop other event handlers from acting. """ - log.debug("in reply for e[%s] r[%s]", event, replystr) - replypath = ircbotlib.reply_destination_for_event(event) + if event: + log.debug("in reply for e[%s] r[%s]", event, replystr) + replypath = ircbotlib.reply_destination_for_event(event) + recursing = getattr(event, '_recursing', False) + log.debug("determined recursing to be %s", recursing) + elif explicit_target: + log.debug("in reply for e[NO EVENT] r[%s]", replystr) + replypath = explicit_target + recursing = False + else: + log.warning("reply() called with no event and no explicit target, aborting") + return + log.debug("replypath: %s", replypath) if replystr is not None: - recursing = getattr(event, '_recursing', False) - log.debug("determined recursing to be %s", recursing) if recursing: return replystr else: From 6cb3757ef9ce2d9712a2da8e87ea52504e20fc41 Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Sun, 12 Feb 2017 11:39:04 -0600 Subject: [PATCH 4/6] convert ircplugin users of privmsg to reply bss/dr.botzo#21 --- acro/ircplugin.py | 22 +++++++++++++--------- storycraft/ircplugin.py | 34 ++++++++++++++++++++-------------- twitter/ircplugin.py | 2 +- 3 files changed, 34 insertions(+), 24 deletions(-) diff --git a/acro/ircplugin.py b/acro/ircplugin.py index 814cba8..07114d2 100644 --- a/acro/ircplugin.py +++ b/acro/ircplugin.py @@ -143,7 +143,8 @@ class Acro(Plugin): self.game.state = 1 self.game.channel = channel - self.bot.privmsg(self.game.channel, "starting a new game of acro. it will run until you tell it to quit.") + self.bot.reply(None, "starting a new game of acro. it will run until you tell it to quit.", + explicit_target=self.game.channel) self._start_new_round() @@ -156,8 +157,9 @@ class Acro(Plugin): self.game.rounds[-1].acro = acro sleep_time = self.game.rounds[-1].seconds_to_submit + (self.game.rounds[-1].seconds_to_submit_step * (len(acro)-3)) - self.bot.privmsg(self.game.channel, "the round has started! your acronym is '{0:s}'. " - "submit within {1:d} seconds via !acro submit [meaning]".format(acro, sleep_time)) + self.bot.reply(None, "the round has started! your acronym is '{0:s}'. " + "submit within {1:d} seconds via !acro submit [meaning]".format(acro, sleep_time), + explicit_target=self.game.channel) t = threading.Thread(target=self.thread_do_process_submissions, args=(sleep_time,)) t.daemon = True @@ -258,7 +260,7 @@ class Acro(Plugin): self.game.state = 3 self.game.rounds[-1].sub_shuffle = list(self.game.rounds[-1].submissions.keys()) random.shuffle(self.game.rounds[-1].sub_shuffle) - self.bot.privmsg(self.game.channel, "here are the results. vote with !acro vote [number]") + self.bot.reply(None, "here are the results. vote with !acro vote [number]", explicit_target=self.game.channel) self._print_round_acros() t = threading.Thread(target=self.thread_do_process_votes, args=()) @@ -271,7 +273,8 @@ class Acro(Plugin): i = 0 for s in self.game.rounds[-1].sub_shuffle: log.debug("%s is %s", str(i), s) - self.bot.privmsg(self.game.channel, " {0:d}: {1:s}".format(i+1, self.game.rounds[-1].submissions[s])) + self.bot.reply(None, " {0:d}: {1:s}".format(i+1, self.game.rounds[-1].submissions[s]), + explicit_target=self.game.channel) i += 1 def _take_acro_vote(self, nick, vote): @@ -295,7 +298,7 @@ class Acro(Plugin): """Clean up and output for ending the current round.""" self.game.state = 4 - self.bot.privmsg(self.game.channel, "voting's over! here are the scores for the round:") + self.bot.reply(None, "voting's over! here are the scores for the round:", explicit_target=self.game.channel) self._print_round_scores() self._add_round_scores_to_game_scores() @@ -309,7 +312,8 @@ class Acro(Plugin): i = 0 for s in list(self.game.rounds[-1].submissions.keys()): votes = [x for x in list(self.game.rounds[-1].votes.values()) if x == s] - self.bot.privmsg(self.game.channel, " {0:d} ({1:s}): {2:d}".format(i+1, s, len(votes))) + self.bot.reply(None, " {0:d} ({1:s}): {2:d}".format(i+1, s, len(votes)), + explicit_target=self.game.channel) i += 1 def _add_round_scores_to_game_scores(self): @@ -336,14 +340,14 @@ class Acro(Plugin): self.game.state = 0 self.game.quit = False - self.bot.privmsg(self.game.channel, "game's over! here are the final scores:") + self.bot.reply(None, "game's over! here are the final scores:", explicit_target=self.game.channel) self._print_game_scores() def _print_game_scores(self): """Print the final calculated scores.""" for s in list(self.game.scores.keys()): - self.bot.privmsg(self.game.channel, " {0:s}: {1:d}".format(s, self.game.scores[s])) + self.bot.reply(None, " {0:s}: {1:d}".format(s, self.game.scores[s]), explicit_target=self.game.channel) plugin = Acro diff --git a/storycraft/ircplugin.py b/storycraft/ircplugin.py index d743fa7..d0509af 100644 --- a/storycraft/ircplugin.py +++ b/storycraft/ircplugin.py @@ -133,8 +133,9 @@ class Storycraft(Plugin): player = StorycraftPlayer.objects.create(nick=nick, nickmask=event.source, game=game) # tell the control channel - self.bot.privmsg(master_channel, "{0:s} created a game of storycraft - do '!storycraft game " - "{1:d} join' to take part!".format(nick, game.pk)) + self.bot.reply(None, "{0:s} created a game of storycraft - do '!storycraft game " + "{1:d} join' to take part!".format(nick, game.pk), + explicit_target=master_channel) log.debug("%s added a new game", nick) return self.bot.reply(event, "Game #{0:d} has been created. When everyone has joined, do " @@ -163,7 +164,8 @@ class Storycraft(Plugin): # output results master_channel = settings.STORYCRAFT_MASTER_CHANNEL - self.bot.privmsg(master_channel, "{0:s} joined storycraft #{1:d}!".format(nick, game_id)) + self.bot.reply(None, "{0:s} joined storycraft #{1:d}!".format(nick, game_id), + explicit_target=master_channel) log.debug("%s joined game #%d", nick, game_id) return self.bot.reply(event, "{0:s}, welcome to the game.".format(nick)) else: @@ -235,9 +237,10 @@ class Storycraft(Plugin): master_channel = settings.STORYCRAFT_MASTER_CHANNEL # tell the control channel - self.bot.privmsg(master_channel, "{0:s} started storycraft #{1:d}! - first player " - "is {2:s}, do '!storycraft game {1:d} show line' when the game is " - "assigned to you.".format(nick, game_id, player.nick)) + self.bot.reply(None, "{0:s} started storycraft #{1:d}! - first player " + "is {2:s}, do '!storycraft game {1:d} show line' when the game is " + "assigned to you.".format(nick, game_id, player.nick), + explicit_target=master_channel) log.debug("%s started game #%d", nick, game_id) return self.bot.reply(event, "Game #{0:d} started.".format(game_id)) @@ -343,22 +346,25 @@ class Storycraft(Plugin): return_msg = 'Line logged. Please add another. ' + progress_str else: # notify the new owner, too - self.bot.privmsg(player.nick, "You have a new line in storycraft " - "#{0:d}: '{1:s}' {2:s}" - "".format(game_id, last_line.line, progress_str)) + self.bot.reply(None, "You have a new line in storycraft " + "#{0:d}: '{1:s}' {2:s}" + "".format(game_id, last_line.line, progress_str), + explicit_target=player.nick) master_channel = settings.STORYCRAFT_MASTER_CHANNEL log.debug("%s added a line to #%d", nick, game_id) # log output if game.status == StorycraftGame.STATUS_IN_PROGRESS: - self.bot.privmsg(master_channel, "{0:s} added a line to storycraft " - "#{1:d}! - next player is {2:s}" - "".format(nick, game_id, player.nick)) + self.bot.reply(None, "{0:s} added a line to storycraft " + "#{1:d}! - next player is {2:s}" + "".format(nick, game_id, player.nick), + explicit_target=master_channel) return self.bot.reply(event, return_msg) else: - self.bot.privmsg(master_channel, "{0:s} finished storycraft #{1:d}!" - "".format(nick, game_id)) + self.bot.reply(None, "{0:s} finished storycraft #{1:d}!" + "".format(nick, game_id), + explicit_target=master_channel) return self.bot.reply(event, "Line logged (and game completed).") else: return self.bot.reply(event, "Failed to assign game to next player.") diff --git a/twitter/ircplugin.py b/twitter/ircplugin.py index 2655190..04c247e 100644 --- a/twitter/ircplugin.py +++ b/twitter/ircplugin.py @@ -276,7 +276,7 @@ class Twitter(Plugin): mentions.reverse() for mention in mentions: reply = self._return_tweet_or_retweet_text(tweet=mention, print_source=True) - self.bot.privmsg(out_chan, reply) + self.bot.reply(None, reply, explicit_target=out_chan) since_id = mention['id'] if mention['id'] > since_id else since_id twittersettings.mentions_since_id = since_id From 015eacbe533e3c0daab9b13616263d4e1898685c Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Sun, 12 Feb 2017 11:39:40 -0600 Subject: [PATCH 5/6] xmlrpc: expose IRCBot.reply, use it over privmsg converts dispatch and the admin form to reply closes bss/dr.botzo#21 --- dispatch/views.py | 4 ++-- ircbot/admin.py | 4 ++-- ircbot/bot.py | 3 ++- ircbot/forms.py | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/dispatch/views.py b/dispatch/views.py index dc12ef0..7d16961 100644 --- a/dispatch/views.py +++ b/dispatch/views.py @@ -77,9 +77,9 @@ class DispatchMessage(generics.GenericAPIView): # connect over XML-RPC and send try: bot_url = 'http://{0:s}:{1:d}/'.format(settings.IRCBOT_XMLRPC_HOST, settings.IRCBOT_XMLRPC_PORT) - bot = xmlrpc.client.ServerProxy(bot_url) + bot = xmlrpc.client.ServerProxy(bot_url, allow_none=True) log.debug("sending '%s' to channel %s", text, action.destination) - bot.privmsg(action.destination, text) + bot.reply(None, text, False, action.destination) except Exception as e: new_data = copy.deepcopy(message.data) new_data['status'] = "FAILED - {0:s}".format(str(e)) diff --git a/ircbot/admin.py b/ircbot/admin.py index d2ef34a..11c02fc 100644 --- a/ircbot/admin.py +++ b/ircbot/admin.py @@ -30,8 +30,8 @@ def send_privmsg(request): message = form.cleaned_data['message'] bot_url = 'http://{0:s}:{1:d}/'.format(settings.IRCBOT_XMLRPC_HOST, settings.IRCBOT_XMLRPC_PORT) - bot = xmlrpc.client.ServerProxy(bot_url) - bot.privmsg(target, message) + bot = xmlrpc.client.ServerProxy(bot_url, allow_none=True) + bot.reply(None, message, False, target) form = PrivmsgForm() else: form = PrivmsgForm() diff --git a/ircbot/bot.py b/ircbot/bot.py index 3d52e07..e01b82a 100644 --- a/ircbot/bot.py +++ b/ircbot/bot.py @@ -377,7 +377,8 @@ class IRCBot(irc.client.SimpleIRCClient): t.start() # register XML-RPC stuff - self.xmlrpc.register_function(self.privmsg, 'privmsg') + self.xmlrpc_register_function(self.privmsg, 'privmsg') + self.xmlrpc_register_function(self.reply, 'reply') def _connected_checker(self): if not self.connection.is_connected(): diff --git a/ircbot/forms.py b/ircbot/forms.py index f413b74..ec89820 100644 --- a/ircbot/forms.py +++ b/ircbot/forms.py @@ -2,7 +2,7 @@ import logging -from django.forms import Form, CharField +from django.forms import Form, CharField, Textarea log = logging.getLogger('markov.forms') @@ -12,4 +12,4 @@ class PrivmsgForm(Form): """Accept a privmsg to send to the ircbot.""" target = CharField() - message = CharField() + message = CharField(widget=Textarea) From 0ec5f8033cd795bd92b5ebbd30d6e86961104c0a Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Sun, 12 Feb 2017 11:58:20 -0600 Subject: [PATCH 6/6] dispatch: code quality cleanups bss/dr.botzo#17 --- dispatch/__init__.py | 1 + dispatch/models.py | 6 ++---- dispatch/serializers.py | 7 +++++++ dispatch/views.py | 12 ++++++------ 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/dispatch/__init__.py b/dispatch/__init__.py index e69de29..e8fe2e6 100644 --- a/dispatch/__init__.py +++ b/dispatch/__init__.py @@ -0,0 +1 @@ +"""Dispatch is Django/ircbot glue code that allows REST clients to hit Django views and execute IRC bot stuff.""" diff --git a/dispatch/models.py b/dispatch/models.py index eddb2a3..fb3ab48 100644 --- a/dispatch/models.py +++ b/dispatch/models.py @@ -9,24 +9,23 @@ log = logging.getLogger('dispatch.models') class Dispatcher(models.Model): - """Organize dispatchers by key.""" key = models.CharField(max_length=16, unique=True) class Meta: + """Meta options.""" + permissions = ( ('send_message', "Can send messages to dispatchers"), ) def __str__(self): """String representation.""" - return "{0:s}".format(self.key) class DispatcherAction(models.Model): - """Handle requests to dispatchers and do something with them.""" PRIVMSG_TYPE = 'privmsg' @@ -44,5 +43,4 @@ class DispatcherAction(models.Model): def __str__(self): """String representation.""" - return "{0:s} -> {1:s} {2:s}".format(self.dispatcher.key, self.type, self.destination) diff --git a/dispatch/serializers.py b/dispatch/serializers.py index 500aa9d..ca38c16 100644 --- a/dispatch/serializers.py +++ b/dispatch/serializers.py @@ -6,22 +6,29 @@ from dispatch.models import Dispatcher, DispatcherAction class DispatcherActionSerializer(serializers.ModelSerializer): + """Serializer for the individual actions associated to a preconfigured dispatcher.""" class Meta: + """Meta options.""" + model = DispatcherAction fields = ('id', 'dispatcher', 'type', 'destination') class DispatcherSerializer(serializers.ModelSerializer): + """Serializer for a dispatcher, a set of actions for a key.""" actions = DispatcherActionSerializer(many=True, read_only=True) class Meta: + """Meta options.""" + model = Dispatcher fields = ('id', 'key', 'actions') class DispatchMessageSerializer(serializers.Serializer): + """Serializer for dispatch messaging.""" message = serializers.CharField() status = serializers.CharField(read_only=True) diff --git a/dispatch/views.py b/dispatch/views.py index 7d16961..21f534f 100644 --- a/dispatch/views.py +++ b/dispatch/views.py @@ -18,8 +18,10 @@ log = logging.getLogger('dispatch.views') class HasSendMessagePermission(IsAuthenticated): + """Class to check if the authenticated user can send messages via dispatch.""" def has_permission(self, request, view): + """Check user permission for dispatch.send_message.""" if request.user.has_perm('dispatch.send_message'): return True @@ -27,7 +29,6 @@ class HasSendMessagePermission(IsAuthenticated): class DispatcherList(generics.ListAPIView): - """List all dispatchers.""" queryset = Dispatcher.objects.all() @@ -35,7 +36,6 @@ class DispatcherList(generics.ListAPIView): class DispatcherDetail(generics.RetrieveAPIView): - """Detail the given dispatcher.""" queryset = Dispatcher.objects.all() @@ -43,12 +43,12 @@ class DispatcherDetail(generics.RetrieveAPIView): class DispatcherDetailByKey(DispatcherDetail): + """Detail a specific dispatcher.""" lookup_field = 'key' class DispatchMessage(generics.GenericAPIView): - """Send a message to the given dispatcher.""" permission_classes = (HasSendMessagePermission,) @@ -57,12 +57,13 @@ class DispatchMessage(generics.GenericAPIView): serializer_class = DispatchMessageSerializer def get(self, request, *args, **kwargs): - dispatcher = self.get_object() + """Return a default message, since this needs POST.""" data = {'message': "", 'status': "READY"} message = self.serializer_class(data=data) return Response(message.initial_data) def post(self, request, *args, **kwargs): + """Accept and dispatch a provided message.""" dispatcher = self.get_object() message = self.serializer_class(data=request.data) if message.is_valid(): @@ -101,12 +102,12 @@ class DispatchMessage(generics.GenericAPIView): class DispatchMessageByKey(DispatchMessage): + """Dispatch a message for a specific key.""" lookup_field = 'key' class DispatcherActionList(generics.ListAPIView): - """List all dispatchers.""" queryset = DispatcherAction.objects.all() @@ -114,7 +115,6 @@ class DispatcherActionList(generics.ListAPIView): class DispatcherActionDetail(generics.RetrieveAPIView): - """Detail the given dispatcher.""" queryset = DispatcherAction.objects.all()