class PubPicker(Plugin): name = "pubpicker" def enInit(self): self.venuetype = self.vars.get("venuetype", "pub") if ":" in self.venuetype: self.venuetype, self.pluraltype = self.venuetype.split(":") else: self.pluraltype = self.venuetype + "s" com = self.get('endroid.plugins.command') com.register_both(self.register, ('register', self.venuetype), '<name>', synonyms=(('register', 'new', self.venuetype), ('register', 'a', 'new', self.venuetype))) com.register_both(self.picker, ('pick', 'a', self.venuetype), synonyms=(('pick', self.venuetype), )) com.register_both(self.vote_up, ('vote', self.venuetype, 'up'), '<name>') com.register_both(self.vote_down, ('vote', self.venuetype, 'down'), '<name>') com.register_both(self.alias, ('alias', self.venuetype), '<name> to <alias>') com.register_both(self.list, ('list', self.pluraltype), synonyms=(('list', self.venuetype), )) com.register_both(self.list_aliases, ('list', self.venuetype, 'aliases')) com.register_both(self.rename, ('rename', self.venuetype), '<oldname> to <newname>') self.db = Database(DB_NAME) self.setup_db() self.load_db() def help(self): return "Let EnDroid pick a " + self.venuetype + " for you!" def load_db(self): self.pubs = {} self.aliases = {} for row in self.db.fetch(DB_TABLE, ("name", "score")): self.pubs[row["name"]] = int(row["score"]) for row in self.db.fetch(DB_ALIAS, ("name", "alias")): self.aliases[row["alias"]] = row["name"] def setup_db(self): if not self.db.table_exists(DB_TABLE): self.db.create_table(DB_TABLE, ("name", "score")) if not self.db.table_exists(DB_ALIAS): self.db.create_table(DB_ALIAS, ("name", "alias")) def add_alias(self, pub, alias): if not alias: raise AliasEmptyError('Pub name cannot be empty.') if pub == alias: return # loops are bad :-) if alias in self.aliases: self.db.delete(DB_ALIAS, {"alias": alias}) self.aliases[alias] = pub self.db.insert(DB_ALIAS, {"alias": alias, "name": pub}) self.add_alias(alias, alias.lower()) def resolve_alias(self, alias): seen = set() while alias in self.aliases: if alias in seen: raise AliasError(alias) alias = self.aliases[alias] seen.add(alias) if not alias: raise AliasEmptyError('Pub string cannot be empty.') return alias # command def vote_up(self, msg, pub): if not pub: msg.reply('Pub string should not be empty.') return try: pub = self.resolve_alias(pub) except AliasError as e: return msg.reply(ALIAS_ERROR.format(e)) except AliasEmptyError: return msg.reply('Pub name cannot be empty.') if pub not in self.pubs: self.pubs[pub] = 10 self.db.insert(DB_TABLE, {"name": pub, "score": 10}) self.add_alias(pub, pub.lower()) else: self.pubs[pub] += 1 self.save_pub(pub) # command def vote_down(self, msg, pub): if not pub: msg.reply('Pub string should not be empty.') return try: pub = self.resolve_alias(pub) except AliasError as e: return msg.reply(ALIAS_ERROR.format(e)) except AliasEmptyError: return msg.reply('Pub name cannot be empty.') if pub in self.pubs: self.pubs[pub] = max(self.pubs[pub] - 1, 0) self.save_pub(pub) def rename_pub(self, oldname, newname): if not newname: raise AliasEmptyError('Pub name must not be empty.') self.add_alias(newname, oldname) score = self.pubs[oldname] self.db.delete(DB_TABLE, {"name": oldname}) del self.pubs[oldname] self.pubs[newname] = score self.db.insert(DB_TABLE, {"name": newname, "score": score}) self.add_alias(newname, newname.lower()) def save_pub(self, pub): self.db.update(DB_TABLE, {"score": self.pubs[pub]}, {"name": pub}) self.add_alias(pub, pub.lower()) def pick_a_pub(self): # I imagine this can be done more cheaply... pubs = [] for pub, score in self.pubs.items(): if pub: # if someone enters an empty pub and we pick that pub, # we'll end up reacting as if we didn't know any pubs # if we test for "if not __" rather than "if __ is None" pubs += [pub] * score if pubs: return random.choice(pubs) else: return None # command def register(self, msg, arg): try: self.vote_up(msg, self.resolve_alias(arg)) except AliasError as e: msg.reply(ALIAS_ERROR.format(e)) except AliasEmptyError: msg.reply('Pub name cannot be empty.') # command def picker(self, msg, arg): pub = self.pick_a_pub() if pub is not None: msg.reply("Today, you should definitely go to %s" % pub) else: msg.reply("Unfortunately, I don't seem to know about any pubs " "that anyone wants to go to") # command def alias(self, msg, arg): mf = ALIAS_FOR.match(arg) mt = ALIAS_TO.match(arg) if mf: self.add_alias(mf.group(2), mf.group(1)) elif mt: self.add_alias(mt.group(1), mt.group(2)) else: msg.unhandled() # command def rename(self, msg, arg): mt = ALIAS_TO.match(arg) if mt: try: self.rename_pub(self.resolve_alias(mt.group(1)), mt.group(2)) except AliasError as e: msg.reply(ALIAS_ERROR.format(e)) except AliasEmptyError: msg.reply('Pub name cannot be empty.') else: msg.unhandled() # command def list(self, msg, arg): reply = ["Registered " + self.pluraltype + " (and their scores):"] reply.extend("%s (%d)" % (p, s) for p, s in self.pubs.items()) msg.reply("\n".join(reply)) # command def list_aliases(self, msg, arg): reply = ["Known " + self.venuetype + " aliases:"] reply.extend("%s -> %s" % (a, p) for a, p in self.aliases.items()) msg.reply("\n".join(reply)) def dependencies(self): return ['endroid.plugins.command']
class PassTheBomb(CommandPlugin): help = "Pass the bomb game for EnDroid" bombs = defaultdict(set) # users : set of bombs users = dict() # user strings : User objects def endroid_init(self): self.db = Database(DB_NAME) if not self.db.table_exists(DB_TABLE): self.db.create_table(DB_TABLE, ('user', 'kills')) # make a local copy of the registration database data = self.db.fetch(DB_TABLE, ['user', 'kills']) for dct in data: self.users[dct['user']] = User(dct['user'], dct['kills']) def cmd_furl_umbrella(self, msg, arg): """This is how a user enters the game - allows them to be targeted and to create and throw bombs""" user = msg.sender if not self.get_shielded(user): msg.reply("Your umbrella is already furled!") else: if self.get_registered(user): self.users[user].shield = False else: # they are not - register them self.db.insert(DB_TABLE, {'user': user, 'kills': 0}) self.users[user] = User(user, kills=0, shield=False) msg.reply("You furl your umbrella!") cmd_furl_umbrella.helphint = ("Furl your umbrella to participate in the " "noble game of pass the bomb!") def cmd_unfurl_umbrella(self, msg, arg): """A user with an unfurled umbrella cannot create or receive bombs""" user = msg.sender if self.get_shielded(user): msg.reply("Your umbrella is already unfurled!") else: # to get user must not have been shielded ie they must have furled # so they will be in the database self.users[user].shield = True msg.reply("You unfurl your umbrella! No bomb can reach you now!") cmd_unfurl_umbrella.helphint = ("Unfurl your umbrella to cower from the " "rain of boms!") def cmd_bomb(self, msg, arg): """Create a bomb with a specified timer. eg: 'bomb 1.5' for a 1.5 second fuse """ holder = msg.sender if self.get_shielded(holder): return msg.reply("Your sense of honour insists that you furl your " "umbrella before lighting the fuse") # otherwise get a time from the first word of arg try: time = float(arg.split(' ', 1)[0]) # make a new bomb and throw it to its creator Bomb(msg.sender, time, self).throw(msg.sender) msg.reply("Sniggering evilly, you light the fuse...") # provision for a failure to read a time float... except ValueError: msg.reply("You struggle with the matches") cmd_bomb.helphint = ("Light the fuse!") def cmd_throw(self, msg, arg): """Throw a bomb to a user, eg: 'throw [email protected]'""" target = arg.split(' ')[0] # we need a bomb to thrown if not self.bombs[msg.sender]: msg.reply("You idly throw your hat, wishing you had something " "rounder, heavier and with more smoking fuses.") # need our umbrella to be furled elif self.get_shielded(msg.sender): msg.reply("You notice that your unfurled umbrella would hinder " "your throw.") # check that target is online elif not target in self.usermanagement.get_available_users(): msg.reply("You look around but cannot spot your target") elif self.get_shielded(target): # target registered/vulnerable? msg.reply( "You see your target hunkered down under their umbrella. " "No doubt a bomb would have little effect on that " "monstrosity.") else: self.bombs[msg.sender].pop().throw(target) msg.reply("You throw the bomb!") self.messagehandler.send_chat(target, "A bomb lands by your feet!") cmd_throw.helphint = ("Throw a bomb!") def cmd_kills(self, msg, arg): kills = self.get_kills(msg.sender) nick = self.usermanagement.get_nickname(msg.sender, self.place_name) level = self.get_level(kills) text = "{} the {} has {} kill".format(nick, level, kills) text += ("" if kills == 1 else "s") msg.reply(text) cmd_kills.helphint = ("Receive and gloat over you score!") def register_kill(self, user): kills = self.get_kills(user) # change the value of 'kills' to kills+1 in the row where 'user' = user self.users[user].kills += 1 self.db.update(DB_TABLE, {'kills': kills + 1}, {'user': user}) def get_kills(self, user): return self.users[user].kills if user in self.users else 0 def get_shielded(self, user): return self.users[user].shield if user in self.users else True def get_registered(self, user): return user in self.users @staticmethod def get_level(kills): if kills < 5: level = 'novice' elif kills < 15: level = 'apprentice' elif kills < 35: level = 'journeyman' elif kills < 65: level = 'expert' elif kills < 100: level = 'master' else: level = 'grand-master' return level
class SMS(CommandPlugin): """Sends SMS messages to users; exposes relevant functions in Webex.""" class UserNotFoundError(Exception): """When we try to get the number of a user who hasn't given Endroid a number.""" class PersonalSMSLimitError(Exception): """SMS send limit reached; please wait a little to send a text.""" class GlobalSMSLimitError(Exception): """Global SMS send limit reached; please wait a bit to send a text.""" class SMSPeriodSendLimit(Exception): """Personal SMS send limit for this period has been reached.""" class InvalidConfigError(Exception): """Something in the config file is of an invalid format""" class TwilioSMSLengthLimit(Exception): """The SMS message is longer than 1600 characters - don't bother sending it since Twilio won't allow it. """ class InvalidJIDError(Exception): """Raise if JID is of an incorrect format""" class SMSBlock(Exception): """ Raise if you try and send a message to someone who has blocked you from sending messages. """ name = "sms" help = ("Send an SMS to someone. \n" "'sms set number <num>' set your phone number.\n" "'sms whoami' find out what I think your number is.\n" "'sms forget me' make me forget your number.\n" "Aliases: setnum, getnum, delnum.\n" "'sms send <user> <message>' send an SMS to a user.\n" "'sms block <user>' prevent <user> from sending you messages.\n" "'sms unblock <user>' unblock a user.\n" "'sms blocked' display the users you have blocked.\n") dependencies = ['endroid.plugins.ratelimit'] # to allow other plugins access to smslib's errors, we copy them to here SMSError = smslib.SMSError SMSAuthError = smslib.SMSAuthError SMSInvalidNumberError = smslib.SMSInvalidNumberError def endroid_init(self): # set up database, creating it if it doesn't exist if not all([ self.vars["country_code"], self.vars["phone_number"], self.vars["auth_token"], self.vars["twilio_sid"] ]): raise ValueError("Specify country_code, phone_number, auth_token " "and twilio_sid in the Endroid config file.") self._config = { "time_between_sms_resets": 1, "user_bucket_capacity": 3, "user_bucket_fillrate": 0.05, "global_bucket_capacity": 20, "global_bucket_fillrate": 1, "period_limit": 30 } for key in self._config: user_value = self.vars.get(key, "") if user_value: # Check user_value is a number try: a = user_value / 2 self._config[key] = user_value except TypeError: logging.info("{} must be a number".format(key)) self.ratelimit = self.get('endroid.plugins.ratelimit') self.user_buckets = defaultdict(lambda: self.ratelimit.create_bucket( self._config["user_bucket_capacity"], self._config[ "user_bucket_fillrate"])) self.global_bucket = self.ratelimit.create_bucket( self._config["global_bucket_capacity"], self._config["global_bucket_fillrate"]) self.db = Database(DB_NAME) # Create a table to store user's phone numbers if not self.db.table_exists(DB_TABLE): self.db.create_table(DB_TABLE, ("user", "phone")) # Create a table to record the number of SMSs sent per user if not self.db.table_exists(DB_LIMIT_TABLE): self.db.create_table(DB_LIMIT_TABLE, ("user", "texts_sent_this_period")) # Create a table to record any users and a user wants to block if not self.db.table_exists(DB_BLOCK_TABLE): self.db.create_table(DB_BLOCK_TABLE, ("user", "users_blocked")) # logging file stuff self.log = logging.getLogger(__name__) logfile = os.path.expanduser( self.vars.get('logfile', '~/.endroid/sms.log')) self.log.info("Logging SMS activity to {}".format(logfile)) handler = logging.FileHandler(logfile) formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s') handler.setFormatter(formatter) self.log.addHandler(handler) self.log.setLevel(logging.INFO) self.cron_name = "SMS_PERIOD_LIMIT" self.cron.register(self._sms_reset_number_sent, self.cron_name) if self.cron_name not in self.cron.getTimeouts(): # Set number sent to reset at some time in the future for the first # time unless there is already a reset time set. # Convert days to seconds time_between = 60 * 60 * 24 * self._config[ "time_between_sms_resets"] self.cron.setTimeout(time_between, self.cron_name, None) def sms_add_sender(self, user): """ Adds someone to the sms database so the number of messages sent is monitored. """ try: self.get_number_sms_sent(user) logging.error("User {} already in database".format(user)) except self.UserNotFoundError: self.db.insert(DB_LIMIT_TABLE, { 'user': user, 'texts_sent_this_period': 0 }) def get_number_sms_sent(self, user): """Returns the number of SMS messages sent by a user in a period.""" results = self.db.fetch(DB_LIMIT_TABLE, ['texts_sent_this_period'], {'user': user}) if not results: raise self.UserNotFoundError return results[0]['texts_sent_this_period'] @command() def sms_blocked(self, msg, arg): users_blocked = self.whos_blocked(msg.sender) if users_blocked: msg.reply("The following users are prevented from sending you " "SMS messages:\n{}".format(", ".join(users_blocked))) else: msg.reply("You haven't blocked anyone at the moment.") @command(helphint="[{sender}]") def sms_block(self, msg, arg): """ Blocks a JID from sending sms messages to this person. msg.sender: this person arg: JID """ user = msg.sender user_to_block = arg.strip() if not user_to_block: msg.reply("You don't appear to have specified a user to block.") return # JID for user_to_block cannot have white space in it if " " in user_to_block: msg.reply( "The user you wish to block can't have whitespace in it.") return people_blocked = self.whos_blocked(user) if user_to_block in people_blocked: msg.reply("User {} already blocked".format(user_to_block)) else: people_blocked.add(user_to_block) self.set_blocked_users(user, people_blocked) msg.reply("{} has been blocked from sending SMS to " "you.".format(user_to_block)) @command(helphint="{sender}") def sms_unblock(self, msg, arg): """ Unblocks the sender from sending SMS messages to the receiver """ user = msg.sender user_to_unblock = arg.strip() if not user_to_unblock: msg.reply("You don't appear to have specified a user to unblock.") return people_blocked = self.whos_blocked(user) if user_to_unblock not in people_blocked: msg.reply("User {} already not blocked".format(user_to_unblock)) else: people_blocked.remove(user_to_unblock) self.set_blocked_users(user, people_blocked) msg.reply("{} has been unblocked from sending SMS to " "you.".format(user_to_unblock)) def set_blocked_users(self, receiver, senders): """Prevent senders from being able to send SMSs to receiver.""" senders_str = ",".join(senders) am_in_db = self.db.count(DB_BLOCK_TABLE, {'user': receiver}) if am_in_db > 0: self.db.update(DB_BLOCK_TABLE, {'users_blocked': senders_str}, {'user': receiver}) else: # This receiver doesn't have an entry in the DB yet self.db.insert(DB_BLOCK_TABLE, { 'user': receiver, 'users_blocked': senders_str }) def whos_blocked(self, receiver): """Which users are not allowed to send SMSs to receiver.""" results = self.db.fetch(DB_BLOCK_TABLE, ['users_blocked'], {'user': receiver}) users_blocked = set() if results: # @@@UNICODE I don't even know where to start... users_blocked_str = results[0]['users_blocked'].encode('ascii') if users_blocked_str: users_blocked = set(users_blocked_str.split(",")) return users_blocked def _sms_reset_number_sent(self, unused_params): """Resets the number of texts sent in the database.""" self.db.empty_table(DB_LIMIT_TABLE) logging.info("Reset the number of SMS messages sent this period " "for all users") self.cron.setTimeout( 60 * 60 * 24 * self._config["time_between_sms_resets"], self.cron_name, None) @command(helphint="{number}", synonyms=("setnum", )) def sms_set_number(self, msg, arg): """ Sets the stored phone number of the sender. """ user = msg.sender number = arg if number[0] == '0': number = self.vars['country_code'] + number[1:] if not PHONE_REGEX.match(number): msg.reply("I don't recognise a number there. Use 11 digits, " "no spaces, or a + followed by 11 to 13 digits. " "I haven't done anything with your request.") return try: self.get_phone_number(user) self.db.update(DB_TABLE, {'phone': number}, {'user': user}) except self.UserNotFoundError: self.db.insert(DB_TABLE, {'user': user, 'phone': number}) msg.reply('Phone number for user {} set to {}.'.format(user, number)) @command(helphint="", synonyms=("getnum", 'sms get number')) def sms_whoami(self, msg, arg): """ Tells the user what Endroid thinks their phone number is. """ user = msg.sender try: msg.reply(self.get_phone_number(user)) return except self.UserNotFoundError: msg.reply("I don't know your number.") @command(helphint="", synonyms=("delnum", "sms forgetme")) def sms_forget_me(self, msg, arg): """ Tells Endroid to clear its stored number for the sender of the message """ user = msg.sender self.db.delete(DB_TABLE, {'user': user}) msg.reply("I've forgotten your phone number.") def number_known(self, user): """ Returns bool(we have a number stored for this JID user). :param user: JID string of user to check :return: True/False if we do/don't know their number. """ try: self.get_phone_number(user) except self.UserNotFoundError: return False return True def get_phone_number(self, user): """ Fetch stored number for a JID; raise UserNotFoundError if it doesn't exibzr resolvest :param user: the JID string of the user to search for :return: a string phone number """ results = self.db.fetch(DB_TABLE, ['phone'], {'user': user}) if not results: raise self.UserNotFoundError(user) return results[0]['phone'] def send_sms(self, sender, jid, message): """ Sends an SMS to the user with the specified JID. Returns a Deferred which acts as a string containing the server's response. Raises self.SMSError or its descendants if there is an error. Checks the user is allowed to send a message and they haven't gone over their allowed SMS messages for this time period and that there are enough tokens in their user bucket and the global bucket. :param sender: hashable object identifying the sender of the SMS :param jid: the user we're sending the SMS to :param message: the message body to send to them :return: output of Twilio, as a Deferred """ self.log.info("Attempting send of SMS from {} to {}.".format( sender, jid)) number_messages = math.ceil(float(len(message)) / 160) if number_messages > 10: raise self.TwilioSMSLengthLimit( "You can only send a message which " "is less than or equal to 1600 " "characters long. Your message is " "{} characters " "long".format(len(message))) try: number_sent = self.get_number_sms_sent(sender) number_sent += number_messages except self.UserNotFoundError: self.sms_add_sender(sender) number_sent = number_messages if sender not in self.whos_blocked(jid): if number_sent <= self._config["period_limit"]: if self.global_bucket.use_token(): if self.user_buckets[sender].use_token(): self.db.update(DB_LIMIT_TABLE, {"texts_sent_this_period": number_sent}, {"user": sender}) number = self.get_phone_number(jid) auth_token = self.vars['auth_token'] endroid_number = self.vars['phone_number'] sid = self.vars['twilio_sid'] response = smslib.send(endroid_number, number, message, sid, auth_token) def log_success(resp): self.log.info("{} to {} succeeded.".format( sender, jid)) return resp def log_failure(fail): self.log.info("{} to {} failed.".format( sender, jid)) return fail response.addCallbacks(log_success, log_failure) return response else: self.log.info( "{} to {} was personally ratelimited.".format( sender, jid)) if number_messages == 1: raise self.PersonalSMSLimitError( 'Personal SMS limit reached') else: raise self.PersonalSMSLimitError( 'Personal SMS limit ' 'will be reached. Your ' 'message will be split ' 'into {} ' 'messages'.format(int(number_messages))) else: self.log.info("{} to {} was globally ratelimited.".format( sender, jid)) raise self.GlobalSMSLimitError('Global SMS limit reached') else: self.log.info("{} has reached their SMS limit " "for the period.".format(sender)) if self._config["period_limit"] <= self.get_number_sms_sent( sender): raise self.SMSPeriodSendLimit("SMS period limit reached.") else: raise self.SMSPeriodSendLimit( "SMS period limit will be reached." " Your message will be split into " "{} messages".format(int(number_messages))) else: self.log.info("{} was blocked from sending sms by " "{}".format(sender, jid)) raise self.SMSBlock("Permission denied.") @command(helphint="{user} {message}") def sms_send(self, msg, args): """ Sends SMS from Endroid with given message body to specified user. """ args_split = args.split(' ') try: to_user = args_split[0] to_send = ' '.join(args_split[1:]) except IndexError: msg.reply('Call me like "sms send [email protected] Hello Patrick!"') return # error if we get an unexpected response from the server def errback(fail): if fail.check(smslib.SMSAuthError): msg.reply( "There was a problem with authentication. Check config.") elif fail.check(smslib.SMSError): msg.reply(fail.getErrorMessage()) else: msg.reply(str(fail)) def callback(response): msg.reply('Message sent!') try: result_deferred = self.send_sms(msg.sender, to_user, to_send) result_deferred.addCallbacks(callback, errback) except self.UserNotFoundError: msg.reply(MESSAGES['user-not-found']) except self.GlobalSMSLimitError: msg.reply( "Endroid has sent its limit of texts - please wait a bit.") except self.PersonalSMSLimitError: msg.reply("You have sent your limit of texts - please wait a bit.") except self.SMSPeriodSendLimit: msg.reply("You have used up your quota of texts for this period.")