Merge branch 'better-multilines' into 'master'

Better multilines by adding flood protection, consolidating privmsg/reply stuff

Closes #21 and #23

See merge request !14
This commit is contained in:
Brian S. Stephan 2017-02-12 12:04:37 -06:00
commit 5d5b58daa5
10 changed files with 101 additions and 76 deletions

View File

@ -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

View File

@ -0,0 +1 @@
"""Dispatch is Django/ircbot glue code that allows REST clients to hit Django views and execute IRC bot stuff."""

View File

@ -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)

View File

@ -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)

View File

@ -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():
@ -77,9 +78,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))
@ -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()

View File

@ -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()

View File

@ -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__()
@ -371,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():
@ -832,35 +839,9 @@ 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))
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
@ -876,24 +857,52 @@ class IRCBot(irc.client.SimpleIRCClient):
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.
"""
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:
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
# 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)
# antiflood
lines += 1
time.sleep(int(0.5 * lines))
# done splitting, or it was never necessary
self.privmsg(replypath, reply)
if stop:
return "NO MORE"

View File

@ -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)

View File

@ -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 "
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))
"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 "
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))
"".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 "
self.bot.reply(None, "{0:s} added a line to storycraft "
"#{1:d}! - next player is {2:s}"
"".format(nick, game_id, player.nick))
"".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.")

View File

@ -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