class Countdown(callbacks.Plugin): def __init__(self, irc, *args, **kwargs): self.__parent = super(Countdown, self) self.__parent.__init__(irc, *args, **kwargs) self._resolved = {} def _countdown_resp(self, irc, remaining_seconds, end_response): if remaining_seconds > 0: delta = timedelta(seconds=remaining_seconds) irc.reply(format_timedelta(delta), prefixNick=False) else: irc.reply(end_response, prefixNick=False) @decowrap(['positiveInt', additional('text')]) def countdown(self, irc, msg, args, seconds, final_message=None): """<seconds> [final_message] Counts down """ if final_message is None: final_message = 'GO!' now = time() callback_part = partial(self._countdown_resp, irc) trigger_resolve_at = now + seconds - min(seconds, 3) schedule.addEvent(self._populate_resolved, trigger_resolve_at) for alarm_point in countdown_alarm_points(seconds): schedule.addEvent( partial(callback_part, alarm_point, final_message), now + seconds - alarm_point)
class SimpleUser(callbacks.Plugin): """A very simple user authentication. As in, it fully depends on hostmask.""" @internationalizeDocstring def hostmask(self, irc, msg, args, nick): """[<nick>] Returns the hostmask of <nick>. If <nick> isn't given, return the hostmask of the person giving the command. """ if not nick: nick = msg.nick irc.reply(irc.state.nickToHostmask(nick)) hostmask = wrap(hostmask, [additional("seenNick")]) @internationalizeDocstring def whoami(self, irc, msg, args): """takes no arguments Returns the name of the user calling the command. """ try: user = ircdb.users.getUser(msg.prefix) irc.reply(user.name) except KeyError: error = _("I don't recognize you.") irc.reply(error) whoami = wrap(whoami)
class Countdown(callbacks.Plugin): def __init__(self, irc, *args, **kwargs): self.__parent = super(Countdown, self) self.__parent.__init__(irc, *args, **kwargs) self._resolved = {} self._destination_ips = [ ('::ffff:225.0.0.1', 5551), ('::ffff:24.4.154.5', 15555), ('ff02::1', 5551), ] self._destination_hosts = [('hidoi.moebros.org', 15555), ('me.cwma.me', 15555)] def _populate_resolved(self): for dest in self._destination_hosts: try: self._resolved[dest[0]] = \ '::ffff:' + socket.gethostbyname(dest[0]) except socket.gaierror: pass def _destinations(self): for host, post in self._destination_ips: yield host, post for host, port in self._destination_hosts: yield self._resolved[host], port def _send_go_packets(self): sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) for dest in self._destinations(): print "sending to %r" % (dest, ) sock.sendto('GO!', 0, dest) def _countdown_resp(self, irc, remaining_seconds, end_response): if remaining_seconds > 0: delta = timedelta(seconds=remaining_seconds) irc.reply(format_timedelta(delta), prefixNick=False) else: irc.reply(end_response, prefixNick=False) self._send_go_packets() @decowrap(['positiveInt', additional('text')]) def countdown(self, irc, msg, args, seconds, final_message=None): """<seconds> [final_message] Counts down """ if final_message is None: final_message = 'GO!' now = time() callback_part = partial(self._countdown_resp, irc) trigger_resolve_at = now + seconds - min(seconds, 3) schedule.addEvent(self._populate_resolved, trigger_resolve_at) for alarm_point in countdown_alarm_points(seconds): schedule.addEvent( partial(callback_part, alarm_point, final_message), now + seconds - alarm_point)
class Nickometer(callbacks.Plugin): def punish(self, damage, reason): self.log.debug('%s lameness points awarded: %s', damage, reason) return damage def nickometer(self, irc, msg, args, nick): """[<nick>] Tells you how lame said nick is. If <nick> is not given, uses the nick of the person giving the command. """ score = 0L if not nick: nick = msg.nick originalNick = nick if not nick: irc.error('Give me a nick to judge as the argument, please.') return specialCost = [('69', 500), ('dea?th', 500), ('dark', 400), ('n[i1]ght', 300), ('n[i1]te', 500), ('f**k', 500), ('sh[i1]t', 500), ('coo[l1]', 500), ('kew[l1]', 500), ('lame', 500), ('dood', 500), ('dude', 500), ('[l1](oo?|u)[sz]er', 500), ('[l1]eet', 500), ('e[l1]ite', 500), ('[l1]ord', 500), ('pron', 1000), ('warez', 1000), ('xx', 100), ('\\[rkx]0', 1000), ('\\0[rkx]', 1000)] letterNumberTranslator = string.maketrans('023457+8', 'ozeasttb') for special in specialCost: tempNick = nick if special[0][0] != '\\': tempNick = tempNick.translate(letterNumberTranslator) if tempNick and re.search(special[0], tempNick, re.IGNORECASE): score += self.punish(special[1], 'matched special case /%s/' % special[0]) # I don't really know about either of these next two statements, # but they don't seem to do much harm. # Allow Perl referencing nick = re.sub('^\\\\([A-Za-z])', '\1', nick) # C-- ain't so bad either nick = re.sub('^C--$', 'C', nick) # Punish consecutive non-alphas matches = re.findall('[^\w\d]{2,}', nick) for match in matches: score += self.punish(slowPow(10, len(match)), '%s consecutive non-alphas ' % len(match)) # Remove balanced brackets ... while 1: nickInitial = nick nick = re.sub('^([^()]*)(\()(.*)(\))([^()]*)$', '\1\3\5', nick, 1) nick = re.sub('^([^{}]*)(\{)(.*)(\})([^{}]*)$', '\1\3\5', nick, 1) nick = re.sub('^([^[\]]*)(\[)(.*)(\])([^[\]]*)$', '\1\3\5', nick, 1) if nick == nickInitial: break self.log.debug('Removed some matching brackets %r => %r', nickInitial, nick) # ... and punish for unmatched brackets unmatched = re.findall('[][(){}]', nick) if len(unmatched) > 0: score += self.punish(slowPow(10, len(unmatched)), '%s unmatched parentheses' % len(unmatched)) # Punish k3wlt0k k3wlt0k_weights = (5, 5, 2, 5, 2, 3, 1, 2, 2, 2) for i in range(len(k3wlt0k_weights)): hits = re.findall( ` i `, nick) if (hits and len(hits) > 0): score += self.punish(k3wlt0k_weights[i] * len(hits) * 30, '%s occurrences of %s ' % (len(hits), i)) # An alpha caps is not lame in middle or at end, provided the first # alpha is caps. nickOriginalCase = nick match = re.search('^([^A-Za-z]*[A-Z].*[a-z].*?)[-_]?([A-Z])', nick) if match: nick = ''.join([ nick[:match.start(2)], nick[match.start(2)].lower(), nick[match.start(2) + 1:] ]) match = re.search('^([^A-Za-z]*)([A-Z])([a-z])', nick) if match: nick = ''.join([ nick[:match.start(2)], nick[match.start(2):match.end(2)].lower(), nick[match.end(2):] ]) # Punish uppercase to lowercase shifts and vice-versa, modulo # exceptions above # the commented line is the equivalent of the original, but i think # they intended my version, otherwise, the first caps alpha will # still be punished #cshifts = caseShifts(nickOriginalCase); cshifts = caseShifts(nick) if cshifts > 1 and re.match('.*[A-Z].*', nick): score += self.punish(slowPow(9, cshifts), '%s case shifts' % cshifts) # Punish lame endings if re.match('.*[XZ][^a-zA-Z]*$', nickOriginalCase): score += self.punish(50, 'the last alphanumeric character was lame') # Punish letter to numeric shifts and vice-versa nshifts = numberShifts(nick) if nshifts > 1: score += self.punish(slowPow(9, nshifts), '%s letter/number shifts' % nshifts) # Punish extraneous caps caps = re.findall('[A-Z]', nick) if caps and len(caps) > 0: score += self.punish(slowPow(7, len(caps)), '%s extraneous caps' % len(caps)) # one trailing underscore is ok. i also added a - for parasite- nick = re.sub('[-_]$', '', nick) # Punish anything that's left remains = re.findall('[^a-zA-Z0-9]', nick) if remains and len(remains) > 0: score += self.punish(50 * len(remains) + slowPow(9, len(remains)), '%s extraneous symbols' % len(remains)) # Use an appropriate function to map [0, +inf) to [0, 100) percentage = 100 * (1 + math.tanh((score - 400.0) / 400.0)) * \ (1 - 1 / (1 + score / 5.0)) / 2 # if it's above 99.9%, show as many digits as is insteresting score_string = re.sub('(99\\.9*\\d|\\.\\d).*', '\\1', ` percentage `) irc.reply('The "lame nick-o-meter" reading for "%s" is %s%%.' % (originalNick, score_string)) self.log.debug( 'Calculated lameness score for %s as %s ' '(raw score was %s)', originalNick, score_string, score) nickometer = wrap(nickometer, [additional('text')])
class Dicebot(callbacks.Plugin): """This plugin supports rolling the dice using !roll 4d20+3 as well as automatically rolling such combinations it sees in the channel (if autoRoll option is enabled for that channel) or query (if autoRollInPrivate option is enabled). """ rollReStandard = re.compile( r'((?P<rolls>\d+)#)?(?P<spec>[+-]?(\d*d\d+|\d+)([+-](\d*d\d+|\d+))*)$') rollReSR = re.compile(r'(?P<rolls>\d+)#sd$') rollReSRX = re.compile(r'(?P<rolls>\d+)#sdx$') rollReSRE = re.compile(r'(?P<pool>\d+),(?P<thr>\d+)#sde$') rollRe7Sea = re.compile( r'((?P<count>\d+)#)?(?P<prefix>[-+])?(?P<rolls>\d+)(?P<k>k{1,2})(?P<keep>\d+)(?P<mod>[+-]\d+)?$' ) rollRe7Sea2ed = re.compile( r'(?P<rolls>([-+]|\d)+)s(?P<skill>\d)(?P<vivre>-)?(l(?P<lashes>\d+))?(?P<explode>ex)?(?P<cursed>r15)?$' ) rollReWoD = re.compile(r'(?P<rolls>\d+)w(?P<explode>\d|-)?$') rollReDH = re.compile(r'(?P<rolls>\d*)vs\((?P<thr>([-+]|\d)+)\)$') rollReWG = re.compile(r'(?P<rolls>\d+)#wg$') validationDH = re.compile(r'^[+\-]?\d{1,4}([+\-]\d{1,4})*$') validation7sea2ed = re.compile(r'^[+\-]?\d{1,2}([+\-]\d{1,2})*$') convertMoney = re.compile( r'((?P<prefix>(?P<p_curr>[$€£₴₽¥元])(?P<p_amount>(\d+[\.,]\d+)|([\.,]\d+)|(\d+)))|(?P<suffix>(?P<s_amount>(\d+[\.,]\d+)|([\.,]\d+)|(\d+))( ?(?P<s_curr>[^\d\s]+))))(?P<output>( [^\d\s]+)*)' ) MAX_DICE = 1000 MIN_SIDES = 2 MAX_SIDES = 100 MAX_ROLLS = 30 def __init__(self, irc): super(Dicebot, self).__init__(irc) self.deck = Deck() self.money = MoneyConverter(HttpRequester()) def _roll(self, dice, sides, mod=0): """ Roll a die several times, return sum of the results plus the static modifier. Arguments: dice -- number of dice rolled; sides -- number of sides each die has; mod -- number added to the total result (optional); """ res = int(mod) for _ in range(dice): res += random.randrange(1, sides + 1) return res def _rollMultiple(self, dice, sides, rolls=1, mod=0): """ Roll several dice several times, return a list of results. Specified number of dice with specified sides is rolled several times. Each time the sum of results is calculated, with optional modifier added. The list of these sums is returned. Arguments: dice -- number of dice rolled each time; sides -- number of sides each die has; rolls -- number of times dice are rolled; mod -- number added to the each total result (optional); """ return [self._roll(dice, sides, mod) for i in range(rolls)] @staticmethod def _formatMod(mod): """ Format a numeric modifier for printing expressions such as 1d20+3. Nonzero numbers are formatted with a sign, zero is formatted as an empty string. """ return ('%+d' % mod) if mod != 0 else '' def _process(self, irc, text): """ Process a message and reply with roll results, if any. The message is split to the words and each word is checked against all known expression forms (first applicable form is used). All results are printed together in the IRC reply. """ checklist = [ (self.rollReStandard, self._parseStandardRoll), (self.rollReSR, self._parseShadowrunRoll), (self.rollReSRX, self._parseShadowrunXRoll), (self.rollReSRE, self._parseShadowrunExtRoll), (self.rollRe7Sea, self._parse7SeaRoll), (self.rollRe7Sea2ed, self._parse7Sea2edRoll), (self.rollReWoD, self._parseWoDRoll), (self.rollReDH, self._parseDHRoll), (self.rollReWG, self._parseWGRoll), ] results = [] for word in text.split(): for expr, parser in checklist: m = expr.match(word) if m: r = parser(m) if r: results.append(r) break if results: irc.reply('; '.join(results)) def _parseStandardRoll(self, m): """ Parse rolls such as 3#2d6+1d4+2. This is a roll (or several rolls) of several dice with optional static modifiers. It yields one number (the sum of results and modifiers) for each roll series. """ rolls = int(m.group('rolls') or 1) spec = m.group('spec') if not spec[0] in '+-': spec = '+' + spec r = re.compile( r'(?P<sign>[+-])((?P<dice>\d*)d(?P<sides>\d+)|(?P<mod>\d+))') totalMod = 0 totalDice = {} for m in r.finditer(spec): if not m.group('mod') is None: totalMod += int(m.group('sign') + m.group('mod')) continue dice = int(m.group('dice') or 1) sides = int(m.group('sides')) if dice > self.MAX_DICE or sides > self.MAX_SIDES or sides < self.MIN_SIDES: return if m.group('sign') == '-': sides *= -1 totalDice[sides] = totalDice.get(sides, 0) + dice if len(totalDice) == 0: return results = [] for _ in range(rolls): result = totalMod for sides, dice in totalDice.items(): if sides > 0: result += self._roll(dice, sides) else: result -= self._roll(dice, -sides) results.append(result) specFormatted = '' self.log.debug(repr(totalDice)) for sides, dice in sorted(list(totalDice.items()), key=itemgetter(0), reverse=True): if sides > 0: if len(specFormatted) > 0: specFormatted += '+' specFormatted += '%dd%d' % (dice, sides) else: specFormatted += '-%dd%d' % (dice, -sides) specFormatted += self._formatMod(totalMod) return '[%s] %s' % (specFormatted, ', '.join([str(i) for i in results])) def _parseShadowrunRoll(self, m): """ Parse Shadowrun-specific roll such as 3#sd. """ rolls = int(m.group('rolls')) if rolls < 1 or rolls > self.MAX_DICE: return L = self._rollMultiple(1, 6, rolls) self.log.debug(format("%L", [str(i) for i in L])) return self._processSRResults(L, rolls) def _parseShadowrunXRoll(self, m): """ Parse Shadowrun-specific 'exploding' roll such as 3#sdx. """ rolls = int(m.group('rolls')) if rolls < 1 or rolls > self.MAX_DICE: return L = self._rollMultiple(1, 6, rolls) self.log.debug(format("%L", [str(i) for i in L])) reroll = L.count(6) while reroll: rerolled = self._rollMultiple(1, 6, reroll) self.log.debug(format("%L", [str(i) for i in rerolled])) L.extend([r for r in rerolled if r >= 5]) reroll = rerolled.count(6) return self._processSRResults(L, rolls, True) @staticmethod def _processSRResults(results, pool, isExploding=False): hits = results.count(6) + results.count(5) ones = results.count(1) isHit = hits > 0 isGlitch = ones >= (pool + 1) / 2 explStr = ', exploding' if isExploding else '' if isHit: hitsStr = format('%n', (hits, 'hit')) glitchStr = ', glitch' if isGlitch else '' return '(pool %d%s) %s%s' % (pool, explStr, hitsStr, glitchStr) if isGlitch: return '(pool %d%s) critical glitch!' % (pool, explStr) return '(pool %d%s) 0 hits' % (pool, explStr) def _parseShadowrunExtRoll(self, m): """ Parse Shadowrun-specific Extended test roll such as 14,3#sde. """ pool = int(m.group('pool')) if pool < 1 or pool > self.MAX_DICE: return threshold = int(m.group('thr')) if threshold < 1 or threshold > self.MAX_DICE: return result = 0 passes = 0 glitches = [] critGlitch = None while result < threshold: L = self._rollMultiple(1, 6, pool) self.log.debug(format('%L', [str(i) for i in L])) hits = L.count(6) + L.count(5) result += hits passes += 1 isHit = hits > 0 isGlitch = L.count(1) >= (pool + 1) / 2 if isGlitch: if not isHit: critGlitch = passes break glitches.append(ordinal(passes)) glitchStr = format(', glitch at %L', glitches) if len(glitches) > 0 else '' if critGlitch is None: return format('(pool %i, threshold %i) %n, %n%s', pool, threshold, (passes, 'pass'), (result, 'hit'), glitchStr) else: return format( '(pool %i, threshold %i) critical glitch at %s pass%s, %n so far', pool, threshold, ordinal(critGlitch), glitchStr, (result, 'hit')) def _parse7Sea2edRoll(self, m): """ Parse 7th Sea 2ed roll (4s2 is its simplest form). Full spec: https://redd.it/80l7jm """ rolls = m.group('rolls') if rolls is None: return # additional validation if not re.match(self.validation7sea2ed, rolls): return roll_count = eval(rolls) if roll_count < 1 or roll_count > self.MAX_ROLLS: return skill = int(m.group('skill')) vivre = m.group('vivre') == '-' explode = m.group('explode') == 'ex' lashes = 0 if m.group('lashes') is None else int(m.group('lashes')) cursed = m.group('cursed') is not None self.log.debug( format( '7sea2ed: %i (%s) dices at %i skill. lashes = %i. explode is %s. vivre is %s', roll_count, str(rolls), skill, lashes, "enabled" if explode else "disabled", "enabled" if vivre else "disabled")) roller = SevenSea2EdRaiseRoller(lambda x: self._rollMultiple(1, 10, x), skill_rank=skill, explode=explode, lash_count=lashes, joie_de_vivre=vivre, raise_target=15 if cursed else 10) return '[%s]: %s' % (m.group(0), str( roller.roll_and_count(roll_count))) def _parse7SeaRoll(self, m): """ Parse 7th Sea-specific roll (4k2 is its simplest form). """ rolls = int(m.group('rolls')) if rolls < 1 or rolls > self.MAX_ROLLS: return count = int(m.group('count') or 1) keep = int(m.group('keep')) mod = int(m.group('mod') or 0) prefix = m.group('prefix') k = m.group('k') explode = prefix != '-' if keep < 1 or keep > self.MAX_ROLLS: return if keep > rolls: keep = rolls if rolls > 10: keep += rolls - 10 rolls = 10 if keep > 10: mod += (keep - 10) * 10 keep = 10 unkept = (prefix == '+' or k == 'kk') and keep < rolls explodeStr = ', not exploding' if not explode else '' results = [] for _ in range(count): L = self._rollMultiple(1, 10, rolls) if explode: for i in range(len(L)): if L[i] == 10: while True: rerolled = self._roll(1, 10) L[i] += rerolled if rerolled < 10: break self.log.debug(format("%L", [str(i) for i in L])) L.sort(reverse=True) keptDice, unkeptDice = L[:keep], L[keep:] unkeptStr = ' | %s' % ', '.join([str(i) for i in unkeptDice ]) if unkept else '' keptStr = ', '.join([str(i) for i in keptDice]) results.append('(%d) %s%s' % (sum(keptDice) + mod, keptStr, unkeptStr)) return '[%dk%d%s%s] %s' % (rolls, keep, self._formatMod(mod), explodeStr, '; '.join(results)) def _parseWoDRoll(self, m): """ Parse New World of Darkness roll (5w) """ rolls = int(m.group('rolls')) if rolls < 1 or rolls > self.MAX_ROLLS: return if m.group('explode') == '-': explode = 0 elif m.group('explode') is not None and m.group('explode').isdigit(): explode = int(m.group('explode')) if explode < 8 or explode > 10: explode = 10 else: explode = 10 L = self._rollMultiple(1, 10, rolls) self.log.debug(format("%L", [str(i) for i in L])) successes = len([x for x in L if x >= 8]) if explode: for i in range(len(L)): if L[i] >= explode: while True: rerolled = self._roll(1, 10) self.log.debug(str(rerolled)) if rerolled >= 8: successes += 1 if rerolled < explode: break if explode == 0: explStr = ', not exploding' elif explode != 10: explStr = ', %d-again' % explode else: explStr = '' result = format('%n', (successes, 'success')) if successes > 0 else 'FAIL' return '(%d%s) %s' % (rolls, explStr, result) def _parseDHRoll(self, m): """ Parse Dark Heresy roll (3vs(20+30-10)) """ rolls = int(m.group('rolls') or 1) if rolls < 1 or rolls > self.MAX_ROLLS: return thresholdExpr = m.group('thr') # additional validation if not re.match(self.validationDH, thresholdExpr): return threshold = eval(thresholdExpr) rollResults = self._rollMultiple(1, 100, rolls) results = [threshold - roll for roll in rollResults] return '%s (%s vs %d)' % (', '.join( [str(i) for i in results]), ', '.join([str(i) for i in rollResults]), threshold) def _parseWGRoll(self, m): """ Parse WH40K: Wrath & Glory roll (10#wg) """ rolls = int(m.group('rolls') or 1) if rolls < 1 or rolls > self.MAX_ROLLS: return L = self._rollMultiple(1, 6, rolls) self.log.debug(format("%L", [str(i) for i in L])) return self._processWGResults(L, rolls) @staticmethod def _processWGResults(results, pool): wrathstrings = ["❶", "❷", "❸", "❹", "❺", "❻"] strTag = "" wrathDie = results.pop(0) n6 = results.count(6) n5 = results.count(5) n4 = results.count(4) icons = 2 * n6 + n5 + n4 Glory = wrathDie == 6 Complication = wrathDie == 1 iconssymb = wrathstrings[wrathDie - 1] + " " if Glory: strTag += "| Glory" icons += 2 elif wrathDie > 3: icons += 1 elif Complication: strTag += "| Complication" iconssymb += n6 * "➅ " + n5 * "5 " + n4 * "4 " isNonZero = icons > 0 if isNonZero: iconsStr = str(icons) + " icon(s): " + iconssymb + strTag return '[pool %d] %s' % (pool, iconsStr) def _autoRollEnabled(self, irc, channel): """ Check if automatic rolling is enabled for this context. """ return ((irc.isChannel(channel) and self.registryValue('autoRoll', channel)) or (not irc.isChannel(channel) and self.registryValue('autoRollInPrivate'))) @wrap(['somethingWithoutSpaces']) def roll(self, irc, msg, args, text): """<dice>d<sides>[<modifier>] Rolls a die with <sides> number of sides <dice> times, summarizes the results and adds optional modifier <modifier> For example, 2d6 will roll 2 six-sided dice; 10d10-3 will roll 10 ten-sided dice and subtract 3 from the total result. """ if self._autoRollEnabled(irc, msg.args[0]): return self._process(irc, text) @wrap def shuffle(self, irc, msg, args): """takes no arguments Restores and shuffles the deck. """ self.deck.shuffle() irc.reply('shuffled') @wrap([additional('positiveInt', 1)]) def draw(self, irc, msg, args, count): """[<count>] Draws <count> cards (1 if omitted) from the deck and shows them. """ cards = [next(self.deck) for i in range(count)] irc.reply(', '.join(cards)) deal = draw @wrap([rest('anything')]) def money(self, irc, msg, args, user_input): """ Converts money using some online service. Syntax is: !m <amount> (<to>)* amount is either $200, 200$, 200 $ or 200 USD if <to> is omitted, then it is assumed to be ['usd', 'eur']. """ self.log.debug(user_input) if user_input is None: self.log.debug('user_input is None') return user_input = self.convertMoney.match(user_input) if user_input.group('prefix') is not None: input = user_input.group('p_curr') amount = float(user_input.group('p_amount')) elif user_input.group('suffix') is not None: input = user_input.group('s_curr') amount = float(user_input.group('s_amount')) else: self.log.debug('user_input does not have prefix and suffix.') return outputs = ('' if user_input.group('output') is None else user_input.group('output')).split() outputs = ['usd', 'eur'] if len(outputs) == 0 else [ x for x in outputs if x is not None and len(x) > 0 ] irc.reply(self.money.convert(amount, input, outputs)) m = money def doPrivmsg(self, irc, msg): if not self._autoRollEnabled(irc, msg.args[0]): return if ircmsgs.isAction(msg): text = ircmsgs.unAction(msg) else: text = msg.args[1] self._process(irc, text)
class Owner(callbacks.Plugin): """Owner-only commands for core Supybot. This is a core Supybot module that should not be removed!""" # This plugin must be first; its priority must be lowest; otherwise odd # things will happen when adding callbacks. def __init__(self, irc=None): if irc is not None: assert not irc.getCallback(self.name()) self.__parent = super(Owner, self) self.__parent.__init__(irc) # Setup command flood detection. self.commands = ircutils.FloodQueue( conf.supybot.abuse.flood.interval()) conf.supybot.abuse.flood.interval.addCallback( self.setFloodQueueTimeout) # Setup plugins and default plugins for commands. # # This needs to be done before we connect to any networks so that the # children of supybot.plugins (the actual plugins) exist and can be # loaded. for (name, s) in registry._cache.items(): if 'alwaysLoadDefault' in name or 'alwaysLoadImportant' in name: continue if name.startswith('supybot.plugins'): try: (_, _, name) = registry.split(name) except ValueError: # unpack list of wrong size. continue # This is just for the prettiness of the configuration file. # There are no plugins that are all-lowercase, so we'll at # least attempt to capitalize them. if name == name.lower(): name = name.capitalize() conf.registerPlugin(name) if name.startswith('supybot.commands.defaultPlugins'): try: (_, _, _, name) = registry.split(name) except ValueError: # unpack list of wrong size. continue registerDefaultPlugin(name, s) # Setup Irc objects, connected to networks. If world.ircs is already # populated, chances are that we're being reloaded, so don't do this. if not world.ircs: for network in conf.supybot.networks(): try: self._connect(network) except socket.error as e: self.log.error('Could not connect to %s: %s.', network, e) except Exception as e: self.log.exception('Exception connecting to %s:', network) self.log.error('Could not connect to %s: %s.', network, e) def callPrecedence(self, irc): return ([], [cb for cb in irc.callbacks if cb is not self]) def outFilter(self, irc, msg): if msg.command == 'PRIVMSG' and not world.testing: if ircutils.strEqual(msg.args[0], irc.nick): self.log.warning('Tried to send a message to myself: %r.', msg) return None return msg def reset(self): # This has to be done somewhere, I figure here is as good place as any. callbacks.IrcObjectProxy._mores.clear() self.__parent.reset() def _connect(self, network, serverPort=None, password='', ssl=False): try: group = conf.supybot.networks.get(network) group.servers()[0] except (registry.NonExistentRegistryEntry, IndexError): if serverPort is None: raise ValueError('connect requires a (server, port) ' \ 'if the network is not registered.') conf.registerNetwork(network, password, ssl) server = '%s:%s' % serverPort conf.supybot.networks.get(network).servers.append(server) assert conf.supybot.networks.get(network).servers(), \ 'No servers are set for the %s network.' % network self.log.debug('Creating new Irc for %s.', network) newIrc = irclib.Irc(network) driver = drivers.newDriver(newIrc) self._loadPlugins(newIrc) return newIrc def _loadPlugins(self, irc): self.log.debug('Loading plugins (connecting to %s).', irc.network) alwaysLoadImportant = conf.supybot.plugins.alwaysLoadImportant() important = conf.supybot.commands.defaultPlugins.importantPlugins() for (name, value) in conf.supybot.plugins.getValues(fullNames=False): if irc.getCallback(name) is None: load = value() if not load and name in important: if alwaysLoadImportant: s = '%s is configured not to be loaded, but is being '\ 'loaded anyway because ' \ 'supybot.plugins.alwaysLoadImportant is True.' self.log.warning(s, name) load = True if load: # We don't load plugins that don't start with a capital # letter. if name[0].isupper() and not irc.getCallback(name): # This is debug because each log logs its beginning. self.log.debug('Loading %s.', name) try: m = plugin.loadPluginModule(name, ignoreDeprecation=True) plugin.loadPluginClass(irc, m) except callbacks.Error as e: # This is just an error message. log.warning(str(e)) except plugins.NoSuitableDatabase as e: s = 'Failed to load %s: no suitable database(%s).' % ( name, e) log.warning(s) except ImportError as e: e = str(e) if e.endswith(name): s = 'Failed to load {0}: No plugin named {0} exists.'.format( utils.str.dqrepr(name)) elif "No module named 'config'" in e: s = ( "Failed to load %s: This plugin may be incompatible " "with your current Python version." % name) else: s = 'Failed to load %s: import error (%s).' % ( name, e) log.warning(s) except Exception as e: log.exception('Failed to load %s:', name) else: # Let's import the module so configuration is preserved. try: _ = plugin.loadPluginModule(name) except Exception as e: log.debug( 'Attempted to load %s to preserve its ' 'configuration, but load failed: %s', name, e) world.starting = False def do376(self, irc, msg): msgs = conf.supybot.networks.get(irc.network).channels.joins() if msgs: for msg in msgs: irc.queueMsg(msg) do422 = do377 = do376 def setFloodQueueTimeout(self, *args, **kwargs): self.commands.timeout = conf.supybot.abuse.flood.interval() def doBatch(self, irc, msg): if not conf.supybot.protocols.irc.experimentalExtensions(): return batch = msg.tagged('batch') # Always not-None on a BATCH message if msg.args[0].startswith('+'): # Start of a batch, we're not interested yet. return if batch.type != 'draft/multiline': # This is not a multiline batch, also not interested. return assert msg.args[0].startswith("-"), ( "BATCH's first argument should start with either - or +, but " "it is %s.") % msg.args[0] # End of multiline batch. It may be a long command. payloads = [] first_privmsg = None for message in batch.messages: if message.command != "PRIVMSG": # We're only interested in PRIVMSGs for the payloads. # (eg. exclude NOTICE) continue elif not payloads: # This is the first PRIVMSG of the batch first_privmsg = message payloads.append(message.args[1]) elif 'draft/multiline-concat' in message.server_tags: # This message is not a new line, but the continuation # of the previous one. payloads.append(message.args[1]) else: # New line; stop here. We're not processing extra lines # either as the rest of the command or as new commands. # This may change in the future. break payload = ''.join(payloads) if not payload: self.log.error( 'Got empty multiline payload. This is a bug, please ' 'report it along with logs.') return assert first_privmsg, "This shouldn't be None unless payload is empty" # Let's build a synthetic message from the various parts of the # batch, to look like the multiline batch was a single (large) # PRIVMSG: # * copy the tags and server tags of the 'BATCH +' command, # * copy the prefix and channel of any of the PRIVMSGs # inside the batch # * create a new args[1] target = first_privmsg.args[0] synthetic_msg = ircmsgs.IrcMsg( msg=batch.messages[0], # tags, server_tags, time prefix=first_privmsg.prefix, command='PRIVMSG', args=(target, payload)) self._doPrivmsgs(irc, synthetic_msg) def doPrivmsg(self, irc, msg): if 'batch' in msg.server_tags: parent_batches = irc.state.getParentBatches(msg) parent_batch_types = [batch.type for batch in parent_batches] if 'draft/multiline' in parent_batch_types \ and conf.supybot.protocols.irc.experimentalExtensions(): # We will handle the message in doBatch when the entire # batch ends. return if 'chathistory' in parent_batch_types: # Either sent automatically by the server upon join, # or triggered by a plugin (why?!) # Either way, replying to commands from the history would # look weird, because it may have been sent a while ago, # and we may have already answered to it. return self._doPrivmsgs(irc, msg) def _doPrivmsgs(self, irc, msg): """If the given message is a command, triggers Limnoria's command-dispatching for that command. Takes the same arguments as ``doPrivmsg`` would, but ``msg`` can potentially be an artificial message synthesized in doBatch from a multiline batch. Usually, a command is a single message, so ``payload=msg.params[0]`` However, when ``msg`` is part of a multiline message, the payload is the concatenation of multiple messages. See <https://ircv3.net/specs/extensions/multiline>. """ assert self is irc.callbacks[0], \ 'Owner isn\'t first callback: %r' % irc.callbacks if ircmsgs.isCtcp(msg): return s = callbacks.addressed(irc, msg) if s: ignored = ircdb.checkIgnored(msg.prefix) if ignored: self.log.info('Ignoring command from %s.', msg.prefix) return maximum = conf.supybot.abuse.flood.command.maximum() self.commands.enqueue(msg) if conf.supybot.abuse.flood.command() \ and self.commands.len(msg) > maximum \ and not ircdb.checkCapability(msg.prefix, 'trusted'): punishment = conf.supybot.abuse.flood.command.punishment() banmask = conf.supybot.protocols.irc.banmask \ .makeBanmask(msg.prefix) self.log.info( 'Ignoring %s for %s seconds due to an apparent ' 'command flood.', banmask, punishment) ircdb.ignores.add(banmask, time.time() + punishment) if conf.supybot.abuse.flood.command.notify(): irc.reply('You\'ve given me %s commands within the last ' '%i seconds; I\'m now ignoring you for %s.' % (maximum, conf.supybot.abuse.flood.interval(), utils.timeElapsed(punishment, seconds=False))) return try: tokens = callbacks.tokenize(s, channel=msg.channel, network=irc.network) self.Proxy(irc, msg, tokens) except SyntaxError as e: if conf.supybot.reply.error.detailed(): irc.error(str(e)) else: irc.replyError(msg=msg) self.log.info('Syntax error: %s', e) def logmark(self, irc, msg, args, text): """<text> Logs <text> to the global Supybot log at critical priority. Useful for marking logfiles for later searching. """ self.log.critical(text) irc.replySuccess() logmark = wrap(logmark, ['text']) def announce(self, irc, msg, args, text): """<text> Sends <text> to all channels the bot is currently on and not lobotomized in. """ u = ircdb.users.getUser(msg.prefix) template = self.registryValue('announceFormat') text = ircutils.standardSubstitute(irc, msg, template, env={ 'owner': u.name, 'text': text }) for channel in irc.state.channels: c = ircdb.channels.getChannel(channel) if not c.lobotomized: irc.queueMsg(ircmsgs.privmsg(channel, text)) irc.noReply() announce = wrap(announce, ['text']) def defaultplugin(self, irc, msg, args, optlist, command, plugin): """[--remove] <command> [<plugin>] Sets the default plugin for <command> to <plugin>. If --remove is given, removes the current default plugin for <command>. If no plugin is given, returns the current default plugin set for <command>. See also, supybot.commands.defaultPlugins.importantPlugins. """ remove = False for (option, arg) in optlist: if option == 'remove': remove = True (_, cbs) = irc.findCallbacksForArgs([command]) if remove: try: conf.supybot.commands.defaultPlugins.unregister(command) irc.replySuccess() except registry.NonExistentRegistryEntry: s = 'I don\'t have a default plugin set for that command.' irc.error(s) elif not cbs: irc.errorInvalid('command', command) elif plugin: if not plugin.isCommand(command): irc.errorInvalid('command in the %s plugin' % plugin.name(), command) registerDefaultPlugin(command, plugin.name()) irc.replySuccess() else: try: irc.reply(conf.supybot.commands.defaultPlugins.get(command)()) except registry.NonExistentRegistryEntry: s = 'I don\'t have a default plugin set for that command.' irc.error(s) defaultplugin = wrap( defaultplugin, [getopts({'remove': ''}), 'commandName', additional('plugin')]) def ircquote(self, irc, msg, args, s): """<string to be sent to the server> Sends the raw string given to the server. """ try: m = ircmsgs.IrcMsg(s) except Exception as e: irc.error(utils.exnToString(e)) else: irc.queueMsg(m) irc.noReply() ircquote = wrap(ircquote, ['text']) def quit(self, irc, msg, args, text): """[<text>] Exits the bot with the QUIT message <text>. If <text> is not given, the default quit message (supybot.plugins.Owner.quitMsg) will be used. If there is no default quitMsg set, your nick will be used. The standard substitutions ($version, $nick, etc.) are all handled appropriately. """ text = text or self.registryValue('quitMsg') or msg.nick text = ircutils.standardSubstitute(irc, msg, text) irc.noReply() m = ircmsgs.quit(text) world.upkeep() for irc in world.ircs[:]: irc.queueMsg(m) irc.die() quit = wrap(quit, [additional('text')]) def flush(self, irc, msg, args): """takes no arguments Runs all the periodic flushers in world.flushers. This includes flushing all logs and all configuration changes to disk. """ world.flush() irc.replySuccess() flush = wrap(flush) def upkeep(self, irc, msg, args, level): """[<level>] Runs the standard upkeep stuff (flushes and gc.collects()). If given a level, runs that level of upkeep (currently, the only supported level is "high", which causes the bot to flush a lot of caches as well as do normal upkeep stuff). """ L = [] if level == 'high': L.append( format('Regexp cache flushed: %n cleared.', (len(re._cache), 'regexp'))) re.purge() L.append( format('Pattern cache flushed: %n cleared.', (len(ircutils._patternCache), 'compiled pattern'))) ircutils._patternCache.clear() L.append( format('hostmaskPatternEqual cache flushed: %n cleared.', (len(ircutils._hostmaskPatternEqualCache), 'result'))) ircutils._hostmaskPatternEqualCache.clear() L.append( format( 'ircdb username cache flushed: %n cleared.', (len(ircdb.users._nameCache), 'username to id mapping'))) ircdb.users._nameCache.clear() L.append( format('ircdb hostmask cache flushed: %n cleared.', (len( ircdb.users._hostmaskCache), 'hostmask to id mapping'))) ircdb.users._hostmaskCache.clear() L.append( format('linecache line cache flushed: %n cleared.', (len(linecache.cache), 'line'))) linecache.clearcache() if minisix.PY2: sys.exc_clear() collected = world.upkeep() if gc.garbage: L.append('Garbage! %r.' % gc.garbage) if collected is not None: # Some time between 5.2 and 7.1, Pypy (3?) started returning None # when gc.collect() is called. L.append(format('%n collected.', (collected, 'object'))) if L: irc.reply(' '.join(L)) else: irc.replySuccess() upkeep = wrap(upkeep, [additional(('literal', ['high']))]) def load(self, irc, msg, args, optlist, name): """[--deprecated] <plugin> Loads the plugin <plugin> from any of the directories in conf.supybot.directories.plugins; usually this includes the main installed directory and 'plugins' in the current directory. --deprecated is necessary if you wish to load deprecated plugins. """ ignoreDeprecation = False for (option, argument) in optlist: if option == 'deprecated': ignoreDeprecation = True if name.endswith('.py'): name = name[:-3] if irc.getCallback(name): irc.error('%s is already loaded.' % name.capitalize()) return try: module = plugin.loadPluginModule(name, ignoreDeprecation) except plugin.Deprecated: irc.error('%s is deprecated. Use --deprecated ' 'to force it to load.' % name.capitalize()) return except ImportError as e: if str(e).endswith(name): irc.error('No plugin named %s exists.' % utils.str.dqrepr(name)) elif "No module named 'config'" in str(e): irc.error( 'This plugin may be incompatible with your current Python ' 'version. Try running 2to3 on it.') else: irc.error(str(e)) return cb = plugin.loadPluginClass(irc, module) name = cb.name() # Let's normalize this. conf.registerPlugin(name, True) irc.replySuccess() load = wrap(load, [getopts({'deprecated': ''}), 'something']) def reload(self, irc, msg, args, name): """<plugin> Unloads and subsequently reloads the plugin by name; use the 'list' command to see a list of the currently loaded plugins. """ if ircutils.strEqual(name, self.name()): irc.error('You can\'t reload the %s plugin.' % name) return callbacks = irc.removeCallback(name) if callbacks: module = sys.modules[callbacks[0].__module__] if hasattr(module, 'reload'): x = module.reload() try: module = plugin.loadPluginModule(name) if hasattr(module, 'reload') and 'x' in locals(): module.reload(x) if hasattr(module, 'config'): from importlib import reload reload(module.config) for callback in callbacks: callback.die() del callback gc.collect() # This makes sure the callback is collected. callback = plugin.loadPluginClass(irc, module) irc.replySuccess() except ImportError: for callback in callbacks: irc.addCallback(callback) irc.error('No plugin named %s exists.' % name) else: irc.error('There was no plugin %s.' % name) reload = wrap(reload, ['something']) def unload(self, irc, msg, args, name): """<plugin> Unloads the callback by name; use the 'list' command to see a list of the currently loaded plugins. Obviously, the Owner plugin can't be unloaded. """ if ircutils.strEqual(name, self.name()): irc.error('You can\'t unload the %s plugin.' % name) return # Let's do this so even if the plugin isn't currently loaded, it doesn't # stay attempting to load. old_callback = irc.getCallback(name) if old_callback: # Normalize the plugin case to prevent duplicate registration # entries, https://github.com/progval/Limnoria/issues/1295 name = old_callback.name() conf.registerPlugin(name, False) callbacks = irc.removeCallback(name) if callbacks: for callback in callbacks: callback.die() del callback gc.collect() irc.replySuccess() return irc.error('There was no plugin %s.' % name) unload = wrap(unload, ['something']) def defaultcapability(self, irc, msg, args, action, capability): """{add|remove} <capability> Adds or removes (according to the first argument) <capability> from the default capabilities given to users (the configuration variable supybot.capabilities stores these). """ if action == 'add': conf.supybot.capabilities().add(capability) irc.replySuccess() elif action == 'remove': try: conf.supybot.capabilities().remove(capability) irc.replySuccess() except KeyError: if ircdb.isAntiCapability(capability): irc.error('That capability wasn\'t in ' 'supybot.capabilities.') else: anticap = ircdb.makeAntiCapability(capability) conf.supybot.capabilities().add(anticap) irc.replySuccess() defaultcapability = wrap(defaultcapability, [('literal', ['add', 'remove']), 'capability']) def disable(self, irc, msg, args, plugin, command): """[<plugin>] <command> Disables the command <command> for all users (including the owners). If <plugin> is given, only disables the <command> from <plugin>. If you want to disable a command for most users but not for yourself, set a default capability of -plugin.command or -command (if you want to disable the command in all plugins). """ if command in ('enable', 'identify'): irc.error('You can\'t disable %s.' % command) return if plugin: if plugin.isCommand(command): pluginCommand = '%s.%s' % (plugin.name(), command) conf.supybot.commands.disabled().add(pluginCommand) plugin._disabled.add(command, plugin.name()) else: irc.error('%s is not a command in the %s plugin.' % (command, plugin.name())) return else: conf.supybot.commands.disabled().add(command) self._disabled.add(command) irc.replySuccess() disable = wrap(disable, [optional('plugin'), 'commandName']) def enable(self, irc, msg, args, plugin, command): """[<plugin>] <command> Enables the command <command> for all users. If <plugin> if given, only enables the <command> from <plugin>. This command is the inverse of disable. """ try: if plugin: plugin._disabled.remove(command, plugin.name()) command = '%s.%s' % (plugin.name(), command) else: self._disabled.remove(command) conf.supybot.commands.disabled().remove(command) irc.replySuccess() except KeyError: irc.error('That command wasn\'t disabled.') enable = wrap(enable, [optional('plugin'), 'commandName']) def rename(self, irc, msg, args, command_plugin, command, newName): """<plugin> <command> <new name> Renames <command> in <plugin> to the <new name>. """ if not command_plugin.isCommand(command): what = 'command in the %s plugin' % command_plugin.name() irc.errorInvalid(what, command) if hasattr(command_plugin, newName): irc.error('The %s plugin already has an attribute named %s.' % (command_plugin, newName)) return plugin.registerRename(command_plugin.name(), command, newName) plugin.renameCommand(command_plugin, command, newName) irc.replySuccess() rename = wrap(rename, ['plugin', 'commandName', 'commandName']) def unrename(self, irc, msg, args, plugin): """<plugin> Removes all renames in <plugin>. The plugin will be reloaded after this command is run. """ try: conf.supybot.commands.renames.unregister(plugin.name()) except registry.NonExistentRegistryEntry: irc.errorInvalid('plugin', plugin.name()) self.reload(irc, msg, [plugin.name()]) # This makes the replySuccess. unrename = wrap(unrename, ['plugin']) def reloadlocale(self, irc, msg, args): """takes no argument Reloads the locale of the bot.""" i18n.reloadLocales() irc.replySuccess()
class Wikifetch(callbacks.Plugin): """Grabs data from Wikipedia and other MediaWiki-powered sites.""" threaded = True def _wiki(self, irc, msg, search, baseurl): """Fetches and replies content from a MediaWiki-powered website.""" reply = '' # Different instances of MediaWiki use different URLs... This tries # to make the parser work for most sites, but still use resonable defaults # such as filling in http:// and appending /wiki to links... # Special cases: Wikia, Wikipedia, Wikimedia (i.e. Wikimedia Commons), Arch Linux Wiki if any(sitename in baseurl for sitename in ('wikia.com', 'wikipedia.org', 'wikimedia.org')): baseurl += '/wiki' elif 'wiki.archlinux.org' in baseurl: baseurl += '/index.php' if not baseurl.lower().startswith(('http://', 'https://')): baseurl = 'http://' + baseurl # first, we get the page addr = '%s/Special:Search?search=%s' % \ (baseurl, quote_plus(search)) self.log.debug('Wikifetch: using URL %s', addr) article = utils.web.getUrl(addr) if sys.version_info[0] >= 3: article = article.decode() # parse the page tree = lxml.html.document_fromstring(article) # check if it gives a "Did you mean..." redirect didyoumean = tree.xpath('//div[@class="searchdidyoumean"]/a' '[@title="Special:Search"]') if didyoumean: redirect = didyoumean[0].text_content().strip() if sys.version_info[0] < 3: if isinstance(redirect, unicode): redirect = redirect.encode('utf-8', 'replace') if isinstance(search, unicode): search = search.encode('utf-8', 'replace') if self.registryValue('showRedirects', msg.args[0]): reply += _('I didn\'t find anything for "%s". ' 'Did you mean "%s"? ') % (search, redirect) addr = "%s/%s" % (baseurl, didyoumean[0].get('href')) article = utils.web.getUrl(addr) if sys.version_info[0] >= 3: article = article.decode() tree = lxml.html.document_fromstring(article) search = redirect # check if it's a page of search results (rather than an article), and # if so, retrieve the first result searchresults = tree.xpath('//div[@class="searchresults"]/ul/li/a') if searchresults: redirect = searchresults[0].text_content().strip() if self.registryValue('showRedirects', msg.args[0]): reply += _('I didn\'t find anything for "%s", but here\'s the ' 'result for "%s": ') % (search, redirect) addr = self.registryValue('url', msg.args[0]) + \ searchresults[0].get('href') article = utils.web.getUrl(addr) if sys.version_info[0] >= 3: article = article.decode() tree = lxml.html.document_fromstring(article) search = redirect # otherwise, simply return the title and whether it redirected elif self.registryValue('showRedirects', msg.args[0]): redirect = re.search( '\(%s <a href=[^>]*>([^<]*)</a>\)' % _('Redirected from'), article) if redirect: try: redirect = tree.xpath( '//span[@class="mw-redirectedfrom"]/a')[0] redirect = redirect.text_content().strip() title = tree.xpath('//*[@class="firstHeading"]') title = title[0].text_content().strip() if sys.version_info[0] < 3: if isinstance(title, unicode): title = title.encode('utf-8', 'replace') if isinstance(redirect, unicode): redirect = redirect.encode('utf-8', 'replace') reply += '"%s" (Redirected from "%s"): ' % (title, redirect) except IndexError: pass # extract the address we got it from - most sites have the perm link # inside the page itself try: addr = tree.find(".//div[@class='printfooter']/a").attrib['href'] addr = re.sub('([&?]|(amp;)?)oldid=\d+$', '', addr) except: pass # check if it's a disambiguation page disambig = tree.xpath('//table[@id="disambigbox"]') or \ tree.xpath('//table[@id="setindexbox"]') if disambig: disambig = tree.xpath('//div[@id="bodyContent"]/div/ul/li') # Hackishly bold all <a> tags r = [] for item in disambig: for link in item.findall('a'): if link.text is not None: link.text = "%s" % link.text item = item.text_content().replace('', '\x02') # Normalize and strip whitespace, to prevent newlines and such # from corrupting the display. item = utils.str.normalizeWhitespace(item).strip() r.append(item) reply += format( _('%u is a disambiguation page. ' 'Possible results include: %L'), addr, r) # or just as bad, a page listing events in that year elif re.search( _('This article is about the year [\d]*\. ' 'For the [a-zA-Z ]* [\d]*, see'), article): reply += _('"%s" is a page full of events that happened in that ' 'year. If you were looking for information about the ' 'number itself, try searching for "%s_(number)", but ' 'don\'t expect anything useful...') % (search, search) # Catch talk pages elif 'ns-talk' in tree.find("body").attrib['class']: reply += format(_('This article appears to be a talk page: %u'), addr) else: p = tree.xpath("//div[@id='mw-content-text']/p[1]") if len(p) == 0 or 'wiki/Special:Search' in addr: if 'wikipedia:wikiproject' in addr.lower(): reply += format( _('This page appears to be a WikiProject page, ' 'but it is too complex for us to parse: %u'), addr) else: irc.error(_('Not found, or page malformed.'), Raise=True) else: p = p[0] # Replace <b> tags with IRC-style bold, this has to be # done indirectly because unescaped '\x02' is invalid in XML for b_tag in p.xpath('//b'): b_tag.text = "%s" % (b_tag.text or '') p = p.text_content() p = p.replace('', '\x02') # Get rid of newlines, etc., that can corrupt the output. p = utils.str.normalizeWhitespace(p) p = p.strip() if sys.version_info[0] < 3: if isinstance(p, unicode): p = p.encode('utf-8', 'replace') if isinstance(reply, unicode): reply = reply.encode('utf-8', 'replace') reply += format('%s %s %u', p, _('Retrieved from'), addr) reply = reply.replace('&', '&') # Remove inline citations (text[1][2][3], etc.) reply = re.sub('\[\d+\]', '', reply) return reply @internationalizeDocstring @wrap([getopts({'site': 'somethingWithoutSpaces'}), 'text']) def wiki(self, irc, msg, args, optlist, search): """[--site <site>] <search term> Returns the first paragraph of a wiki article. Optionally, a --site argument can be given to override the default (usually Wikipedia) - try using '--site lyrics.wikia.com' or '--site wiki.archlinux.org'.""" optlist = dict(optlist) baseurl = optlist.get('site') or self.registryValue('url', msg.args[0]) text = self._wiki(irc, msg, search, baseurl) irc.reply(text) @internationalizeDocstring @wrap([additional('somethingWithoutSpaces')]) def random(self, irc, msg, args, site): """[<site>] Returns the first paragraph of a random wiki article. Optionally, the --site argument can be given to override the default (usually Wikipedia).""" baseurl = site or self.registryValue('url', msg.args[0]) text = self._wiki(irc, msg, 'Special:Random', baseurl) irc.reply(text)
class Wikifetch(callbacks.Plugin): """Grabs data from Wikipedia and other MediaWiki-powered sites.""" threaded = True # This defines a series of suffixes this should be added after the domain name. SPECIAL_URLS = { 'wikia.com': '/wiki', 'wikipedia.org': '/wiki', 'wiki.archlinux.org': '/index.php', 'wiki.gentoo.org': '/wiki', 'mediawiki.org': '/wiki', 'wikimedia.org': '/wiki', } def _get_article_tree(self, baseurl, query, use_mw_parsing=True): """ Returns a wiki article tree given the base URL and search query. baseurl can be None, in which case, searching is skipped and the search query will be treated as a raw address. """ if baseurl is None: addr = query else: # Different instances of MediaWiki use different URLs... This tries # to make the parser work for most sites, but still use resonable defaults # such as filling in http:// and appending /wiki to links... baseurl = baseurl.lower() for match, suffix in self.SPECIAL_URLS.items(): if match in baseurl: baseurl += suffix break # Add http:// to the URL if a scheme isn't specified if not baseurl.startswith(('http://', 'https://')): baseurl = 'http://' + baseurl if use_mw_parsing: # first, we get the page addr = '%s/Special:Search?search=%s' % \ (baseurl, quote_plus(query)) else: addr = '%s/%s' % (baseurl, query) self.log.debug('Wikifetch: using URL %s', addr) try: article = utils.web.getUrl(addr, timeout=3) except utils.web.Error: self.log.exception('Failed to fetch link %s', addr) raise article = article.decode() tree = lxml.html.document_fromstring(article) return (tree, article, addr) def _wiki(self, irc, msg, search, baseurl, use_mw_parsing=True): """Fetches and replies content from a MediaWiki-powered website.""" reply = '' # First, fetch and parse the page tree, article, addr = self._get_article_tree( baseurl, search, use_mw_parsing=use_mw_parsing) # check if it gives a "Did you mean..." redirect didyoumean = tree.xpath('//div[@class="searchdidyoumean"]/a' '[@title="Special:Search"]') if didyoumean: redirect = didyoumean[0].text_content().strip() if self.registryValue('showRedirects', msg.args[0]): reply += _('I didn\'t find anything for "%s". ' 'Did you mean "%s"? ') % (search, redirect) tree, article, addr = self._get_article_tree( baseurl, didyoumean[0].get('href')) search = redirect # check if it's a page of search results (rather than an article), and # if so, retrieve the first result searchresults = tree.xpath('//div[@class="searchresults"]/ul/li/a') or \ tree.xpath('//article/ul/li/a') # Special case for Wikia (2017-01-27) self.log.debug('Wikifetch: got search results %s', searchresults) if searchresults: redirect = searchresults[0].text_content().strip() if self.registryValue('showRedirects', msg.args[0]): reply += _('I didn\'t find anything for "%s", but here\'s the ' 'result for "%s": ') % (search, redirect) # Follow the search result and fetch that article. Note: use the original # base url to prevent prefixes like "/wiki" from being added twice. self.log.debug('Wikifetch: following search result:') tree, article, addr = self._get_article_tree( None, searchresults[0].get('href')) search = redirect # otherwise, simply return the title and whether it redirected elif self.registryValue('showRedirects', msg.args[0]): redirect = re.search( r'\(%s <a href=[^>]*>([^<]*)</a>\)' % _('Redirected from'), article) if redirect: try: redirect = tree.xpath( '//span[@class="mw-redirectedfrom"]/a')[0] redirect = redirect.text_content().strip() title = tree.xpath('//*[@class="firstHeading"]') title = title[0].text_content().strip() reply += '"%s" (Redirected from "%s"): ' % (title, redirect) except IndexError: pass # extract the address we got it from - most sites have the perm link # inside the page itself try: addr = tree.find(".//link[@rel='canonical']").attrib['href'] except (ValueError, AttributeError): self.log.debug( 'Wikifetch: failed <link rel="canonical"> link extraction, skipping' ) try: addr = tree.find( ".//div[@class='printfooter']/a").attrib['href'] addr = re.sub(r'([&?]|(amp;)?)oldid=\d+$', '', addr) except (ValueError, AttributeError): self.log.debug( 'Wikifetch: failed printfooter link extraction, skipping') # If any of the above post-processing tricks fail, just ignore pass text_content = tree if use_mw_parsing: text_content = tree.xpath( "//div[@class='mw-parser-output']") or tree.xpath( "//div[@id='mw-content-text']") if text_content: text_content = text_content[0] self.log.debug('Wikifetch: Using %s as text_content', text_content) # check if it's a disambiguation page disambig = tree.xpath('//table[@id="disambigbox"]') or \ tree.xpath('//table[@id="setindexbox"]') or \ tree.xpath('//div[contains(@class, "disambig")]') # Wikia (2017-01-27) if disambig: reply += format(_('%u is a disambiguation page. '), addr) disambig = text_content.xpath('./ul/li') disambig_results = [] for item in disambig: for link in item.findall('a'): if link.text is not None: # Hackishly bold all <a> tags link.text = "%s" % link.text item = item.text_content().replace('', '\x02') # Normalize and strip whitespace, to prevent newlines and such # from corrupting the display. item = utils.str.normalizeWhitespace(item).strip() disambig_results.append(item) if disambig_results: reply += format(_('Possible results include: %s'), '; '.join(disambig_results)) # Catch talk pages elif 'ns-talk' in tree.find("body").attrib.get('class', ''): reply += format(_('This article appears to be a talk page: %u'), addr) else: # Get the first paragraph as text. paragraphs = [] for p in text_content.xpath("./p"): self.log.debug('Wikifetch: looking at paragraph %s', p.text_content()) # Skip geographic coordinates, e.g. on articles for countries if p.xpath(".//span[@class='geo-dec']"): continue # 2018-07-19: some articles have an empty p tag with this class and no content (why?) elif 'mw-empty-elt' in p.attrib.get('class', ''): continue # Skip <p> tags with no content, for obvious reasons elif not p.text_content().strip(): continue paragraphs.append(p) if (not paragraphs) or 'wiki/Special:Search' in addr: if 'wikipedia:wikiproject' in addr.lower(): reply += format( _('This page appears to be a WikiProject page, ' 'but it is too complex for us to parse: %u'), addr) else: irc.error(_('Not found, or page malformed.'), Raise=True) else: p = paragraphs[0] # Replace <b> tags with IRC-style bold, this has to be # done indirectly because unescaped '\x02' is invalid in XML for b_tag in p.xpath('//b'): b_tag.text = "%s" % (b_tag.text or '') p = p.text_content() p = p.replace('', '\x02') # Get rid of newlines, etc., that can corrupt the output. p = utils.str.normalizeWhitespace(p) p = p.strip() if not p: reply = _('<Page was too complex to parse>') reply += format('%s %s %u', p, _('Retrieved from'), addr) reply = reply.replace('&', '&') # Remove inline citations (text[1][2][3]) as well as inline notes (text[note 1]). reply = re.sub(r'\[[a-z ]*?\d+\]', '', reply) return reply @internationalizeDocstring @wrap([ getopts({ 'site': 'somethingWithoutSpaces', 'no-mw-parsing': '' }), 'text' ]) def wiki(self, irc, msg, args, optlist, search): """[--site <site>] [--no-mw-parsing] <search term> Returns the first paragraph of a wiki article. Optionally, a --site argument can be given to override the default (usually Wikipedia) - try using '--site lyrics.wikia.com' or '--site wiki.archlinux.org'. If the --no-mw-parsing option is given, MediaWiki-specific parsing is disabled. This has the following effects: 1) No attempt at searching for a relevant Wiki page is made, and an article with the same name as the search term is directly retrieved. 2) The plugin will retrieve the first <p> tag found on a page, regardless of where it's found, and print it as text. This may not work on all sites, as some use <p> for navbars and headings as well. """ optlist = dict(optlist) baseurl = optlist.get('site') or self.registryValue('url', msg.args[0]) text = self._wiki( irc, msg, search, baseurl, use_mw_parsing=not optlist.get('no-mw-parsing'), ) irc.reply(text) @internationalizeDocstring @wrap([additional('somethingWithoutSpaces')]) def random(self, irc, msg, args, site): """[<site>] Returns the first paragraph of a random wiki article. Optionally, the 'site' argument can be given to override the default (usually Wikipedia).""" baseurl = site or self.registryValue('url', msg.args[0]) text = self._wiki(irc, msg, 'Special:Random', baseurl) irc.reply(text)
class WunderWeather(callbacks.Plugin): """Uses the Wunderground XML API to get weather conditions for a given location. Always gets current conditions, and by default shows a 7-day forecast as well.""" threaded = True ########## GLOBAL VARIABLES ########## _weatherCurrentCondsURL = 'http://api.wunderground.com/auto/wui/geo/WXCurrentObXML/index.xml?query=%s' _weatherForecastURL = 'http://api.wunderground.com/auto/wui/geo/ForecastXML/index.xml?query=%s' ########## EXPOSED METHODS ########## def weather(self, irc, msg, args, options, location): """[--current|--forecast|--all] [US zip code | US/Canada city, state | Foreign city, country] Returns the approximate weather conditions for a given city from Wunderground. --current, --forecast, and --all control what kind of information the command shows. """ matchedLocation = self._commandSetup(irc, msg, location) locationName = self._getNodeValue(matchedLocation[0], 'full', 'Unknown Location') output = [] showCurrent = False showForecast = False if not options: # use default output showCurrent = self.registryValue('showCurrentByDefault', self.__channel) showForecast = self.registryValue('showForecastByDefault', self.__channel) else: for (type, arg) in options: if type == 'current': showCurrent = True elif type == 'forecast': showForecast = True elif type == 'all': showCurrent = True showForecast = True if showCurrent and showForecast: output.append(u('Weather for ') + locationName) elif showCurrent: output.append(u('Current weather for ') + locationName) elif showForecast: output.append(u('Forecast for ') + locationName) if showCurrent: output.append(self._getCurrentConditions(matchedLocation[0])) if showForecast: # _getForecast returns a list, so we have to call extend rather than append output.extend(self._getForecast(matchedLocation[1])) if not showCurrent and not showForecast: irc.error( "Something weird happened... I'm not supposed to show current conditions or a forecast!" ) irc.reply(self._formatUnicodeOutput(output)) weather = wrap(weather, [ getopts({ 'current': '', 'forecast': '', 'all': '' }), additional('text') ]) ########## SUPPORTING METHODS ########## def _checkLocation(self, location): if not location: location = self.userValue('lastLocation', self.__msg.prefix) if not location: raise callbacks.ArgumentError self.setUserValue('lastLocation', self.__msg.prefix, location, ignoreNoUser=True) # Check for shortforms, because Wunderground will attempt to check # for US locations without a full country name. # checkShortforms may return Unicode characters in the country name. # Need Latin 1 for Supybot's URL handlers to work webLocation = shortforms.checkShortforms(location) conditions = self._getDom(self._weatherCurrentCondsURL % utils.web.urlquote(webLocation)) observationLocation = conditions.getElementsByTagName( 'observation_location')[0] # if there's no city name in the XML, we didn't get a match if observationLocation.getElementsByTagName( 'city')[0].childNodes.length < 1: # maybe the country shortform given conflicts with a state shortform and wasn't replaced before webLocation = shortforms.checkConflictingShortforms(location) # if no conflicting short names match, we have the same query as before if webLocation == None: return None conditions = self._getDom(self._weatherCurrentCondsURL % utils.web.urlquote(webLocation)) observationLocation = conditions.getElementsByTagName( 'observation_location')[0] # if there's still no match, nothing more we can do if observationLocation.getElementsByTagName( 'city')[0].childNodes.length < 1: return None # if we get this far, we got a match. Return the DOM and location return (conditions, webLocation) def _commandSetup(self, irc, msg, location): channel = None if irc.isChannel(msg.args[0]): channel = msg.args[0] # set various variables for submethods use self.__irc = irc self.__msg = msg self.__channel = channel matchedLocation = self._checkLocation(location) if not matchedLocation: self._noLocation() return matchedLocation # format temperatures using _formatForMetricOrImperial def _formatCurrentConditionTemperatures(self, dom, string): tempC = self._getNodeValue(dom, string + '_c', u('N/A')) + u('\xb0C') tempF = self._getNodeValue(dom, string + '_f', u('N/A')) + u('\xb0F') return self._formatForMetricOrImperial(tempF, tempC) def _formatForecastTemperatures(self, dom, type): tempC = self._getNodeValue( dom.getElementsByTagName(type)[0], 'celsius', u('N/A')) + u('\xb0C') tempF = self._getNodeValue( dom.getElementsByTagName(type)[0], 'fahrenheit', u('N/A')) + u('\xb0F') return self._formatForMetricOrImperial(tempF, tempC) # formats any imperial or metric values according to the config def _formatForMetricOrImperial(self, imperial, metric): returnValues = [] if self.registryValue('imperial', self.__channel): returnValues.append(imperial) if self.registryValue('metric', self.__channel): returnValues.append(metric) if not returnValues: returnValues = (imperial, metric) return u(' / ').join(returnValues) def _formatPressures(self, dom): # lots of function calls, but it just divides pressure_mb by 10 and rounds it pressureKpa = str( round( float(self._getNodeValue(dom, 'pressure_mb', u('0'))) / 10, 1)) + 'kPa' pressureIn = self._getNodeValue(dom, 'pressure_in', u('0')) + 'in' return self._formatForMetricOrImperial(pressureIn, pressureKpa) def _formatSpeeds(self, dom, string): mphValue = float(self._getNodeValue(dom, string, u('0'))) speedM = u('%dmph') % round(mphValue) speedK = u('%dkph') % round( mphValue * 1.609344) # thanks Wikipedia for the conversion rate return self._formatForMetricOrImperial(speedM, speedK) def _formatUpdatedTime(self, dom): observationTime = self._getNodeValue(dom, 'observation_epoch', None) localTime = self._getNodeValue(dom, 'local_epoch', None) if not observationTime or not localTime: return self._getNodeValue(dom, 'observation_time', 'Unknown Time').lstrip( u('Last Updated on ')) seconds = int(localTime) - int(observationTime) minutes = int(seconds / 60) seconds -= minutes * 60 hours = int(minutes / 60) minutes -= hours * 60 if seconds == 1: seconds = '1 sec' else: seconds = '%d secs' % seconds if minutes == 1: minutes = '1 min' else: minutes = '%d mins' % minutes if hours == 1: hours = '1 hr' else: hours = '%d hrs' % hours if hours == '0 hrs': if minutes == '0 mins': return '%s ago' % seconds return '%s, %s ago' % (minutes, seconds) return '%s, %s, %s ago' % (hours, minutes, seconds) def _getCurrentConditions(self, dom): output = [] temp = self._formatCurrentConditionTemperatures(dom, 'temp') if self._getNodeValue(dom, 'heat_index_string') != 'NA': temp += u(' (Heat Index: %s)' ) % self._formatCurrentConditionTemperatures( dom, 'heat_index') if self._getNodeValue(dom, 'windchill_string') != 'NA': temp += u(' (Wind Chill: %s)' ) % self._formatCurrentConditionTemperatures( dom, 'windchill') output.append(u('Temperature: ') + temp) output.append( u('Humidity: ') + self._getNodeValue(dom, 'relative_humidity', u('N/A%'))) if self.registryValue('showPressure', self.__channel): output.append(u('Pressure: ') + self._formatPressures(dom)) output.append( u('Conditions: ') + self._getNodeValue(dom, 'weather').capitalize()) output.append( u('Wind: ') + self._getNodeValue(dom, 'wind_dir', u('None')).capitalize() + ', ' + self._formatSpeeds(dom, 'wind_mph')) output.append(u('Updated: ') + self._formatUpdatedTime(dom)) return u('; ').join(output) def _getDom(self, url): try: xmlString = utils.web.getUrl(url) return dom.parseString(xmlString) except utils.web.Error as e: error = e.args[0].capitalize() if error[-1] != '.': error = error + '.' self.__irc.error(error, Raise=True) def _getForecast(self, location): dom = self._getDom(self._weatherForecastURL % utils.web.urlquote(location)) output = [] count = 0 max = self.registryValue('forecastDays', self.__channel) forecast = dom.getElementsByTagName('simpleforecast')[0] for day in forecast.getElementsByTagName('forecastday'): if count >= max and max != 0: break forecastOutput = [] forecastOutput.append( 'Forecast for ' + self._getNodeValue(day, 'weekday').capitalize() + ': ' + self._getNodeValue(day, 'conditions').capitalize()) forecastOutput.append( 'High of ' + self._formatForecastTemperatures(day, 'high')) forecastOutput.append('Low of ' + self._formatForecastTemperatures(day, 'low')) output.append('; '.join(forecastOutput)) count += 1 return output ########## STATIC METHODS ########## def _formatUnicodeOutput(output): # UTF-8 encoding is required for Supybot to handle \xb0 (degrees) and other special chars # We can't (yet) pass it a Unicode string on its own (an oddity, to be sure) s = u(' | ').join(output) if sys.version_info[0] < 3: s = s.encode('utf-8') return s _formatUnicodeOutput = staticmethod(_formatUnicodeOutput) def _getNodeValue(dom, value, default=u('Unknown')): subTag = dom.getElementsByTagName(value) if len(subTag) < 1: return default subTag = subTag[0].firstChild if subTag == None: return default return subTag.nodeValue _getNodeValue = staticmethod(_getNodeValue) def _noLocation(): raise NoLocation(noLocationError) _noLocation = staticmethod(_noLocation)
class Wikifetch(callbacks.Plugin): """Grabs data from Wikipedia and other MediaWiki-powered sites.""" threaded = True # This defines a series of suffixes this should be added after the domain name. SPECIAL_URLS = { 'wikia.com': '/wiki', 'wikipedia.org': '/wiki', 'wiki.archlinux.org': '/index.php', 'wiki.gentoo.org': '/wiki', 'mediawiki.org': '/wiki', 'wikimedia.org': '/wiki', } def _get_article_tree(self, baseurl, search): """ Returns the article tree given the base URL and search query. baseurl can be None, in which case, the search query will be treated as a raw string. """ if baseurl is None: addr = search else: # Different instances of MediaWiki use different URLs... This tries # to make the parser work for most sites, but still use resonable defaults # such as filling in http:// and appending /wiki to links... # Special cases: Wikia, Wikipedia, Wikimedia (i.e. Wikimedia Commons), Arch Linux Wiki if '/' not in search: baseurl = baseurl.lower() for match, suffix in self.SPECIAL_URLS.items(): if match in baseurl: baseurl += suffix break # Add http:// to the URL if a scheme isn't specified if not baseurl.startswith(('http://', 'https://')): baseurl = 'http://' + baseurl # first, we get the page addr = '%s/Special:Search?search=%s' % \ (baseurl, quote_plus(search)) self.log.debug('Wikifetch: using URL %s', addr) try: article = utils.web.getUrl(addr, timeout=3) except utils.web.Error: self.log.exception('Failed to fetch link %s', addr) raise if sys.version_info[0] >= 3: article = article.decode() tree = lxml.html.document_fromstring(article) return (tree, article, addr) def _wiki(self, irc, msg, search, baseurl): """Fetches and replies content from a MediaWiki-powered website.""" reply = '' # First, fetch and parse the page tree, article, addr = self._get_article_tree(baseurl, search) # check if it gives a "Did you mean..." redirect didyoumean = tree.xpath('//div[@class="searchdidyoumean"]/a' '[@title="Special:Search"]') if didyoumean: redirect = didyoumean[0].text_content().strip() if sys.version_info[0] < 3: if isinstance(redirect, unicode): redirect = redirect.encode('utf-8', 'replace') if isinstance(search, unicode): search = search.encode('utf-8', 'replace') if self.registryValue('showRedirects', msg.args[0]): reply += _('I didn\'t find anything for "%s". ' 'Did you mean "%s"? ') % (search, redirect) tree, article, addr = self._get_article_tree( baseurl, didyoumean[0].get('href')) search = redirect # check if it's a page of search results (rather than an article), and # if so, retrieve the first result searchresults = tree.xpath('//div[@class="searchresults"]/ul/li/a') or \ tree.xpath('//article/ul/li/a') # Special case for Wikia (2017-01-27) self.log.debug('Wikifetch: got search results %s', searchresults) if searchresults: redirect = searchresults[0].text_content().strip() if self.registryValue('showRedirects', msg.args[0]): reply += _('I didn\'t find anything for "%s", but here\'s the ' 'result for "%s": ') % (search, redirect) # Follow the search result and fetch that article. Note: use the original # base url to prevent prefixes like "/wiki" from being added twice. self.log.debug('Wikifetch: following search result:') tree, article, addr = self._get_article_tree( None, searchresults[0].get('href')) search = redirect # otherwise, simply return the title and whether it redirected elif self.registryValue('showRedirects', msg.args[0]): redirect = re.search( '\(%s <a href=[^>]*>([^<]*)</a>\)' % _('Redirected from'), article) if redirect: try: redirect = tree.xpath( '//span[@class="mw-redirectedfrom"]/a')[0] redirect = redirect.text_content().strip() title = tree.xpath('//*[@class="firstHeading"]') title = title[0].text_content().strip() if sys.version_info[0] < 3: if isinstance(title, unicode): title = title.encode('utf-8', 'replace') if isinstance(redirect, unicode): redirect = redirect.encode('utf-8', 'replace') reply += '"%s" (Redirected from "%s"): ' % (title, redirect) except IndexError: pass # extract the address we got it from - most sites have the perm link # inside the page itself try: addr = tree.find(".//div[@class='printfooter']/a").attrib['href'] addr = re.sub('([&?]|(amp;)?)oldid=\d+$', '', addr) except: # If any of the above post-processing tricks fail, just ignore pass # check if it's a disambiguation page disambig = tree.xpath('//table[@id="disambigbox"]') or \ tree.xpath('//table[@id="setindexbox"]') or \ tree.xpath('//div[contains(@class, "disambig")]') # Wikia (2017-01-27) if disambig: reply += format(_('%u is a disambiguation page. '), addr) disambig = tree.xpath('//div[@id="bodyContent"]/div/ul/li') disambig_results = [] for item in disambig: for link in item.findall('a'): if link.text is not None: # Hackishly bold all <a> tags link.text = "%s" % link.text item = item.text_content().replace('', '\x02') # Normalize and strip whitespace, to prevent newlines and such # from corrupting the display. item = utils.str.normalizeWhitespace(item).strip() disambig_results.append(item) if disambig_results: reply += format(_('Possible results include: %L'), disambig_results) # Catch talk pages elif 'ns-talk' in tree.find("body").attrib.get('class', ''): reply += format(_('This article appears to be a talk page: %u'), addr) else: p = tree.xpath("//div[@id='mw-content-text']/p[1]") if len(p) == 0 or 'wiki/Special:Search' in addr: if 'wikipedia:wikiproject' in addr.lower(): reply += format( _('This page appears to be a WikiProject page, ' 'but it is too complex for us to parse: %u'), addr) else: irc.error(_('Not found, or page malformed.'), Raise=True) else: p = p[0] # Replace <b> tags with IRC-style bold, this has to be # done indirectly because unescaped '\x02' is invalid in XML for b_tag in p.xpath('//b'): b_tag.text = "%s" % (b_tag.text or '') p = p.text_content() p = p.replace('', '\x02') # Get rid of newlines, etc., that can corrupt the output. p = utils.str.normalizeWhitespace(p) p = p.strip() if sys.version_info[0] < 3: if isinstance(p, unicode): p = p.encode('utf-8', 'replace') if isinstance(reply, unicode): reply = reply.encode('utf-8', 'replace') if not p: reply = _('<Page was too complex to parse>') reply += format('%s %s %u', p, _('Retrieved from'), addr) reply = reply.replace('&', '&') # Remove inline citations (text[1][2][3], etc.) reply = re.sub('\[\d+\]', '', reply) return reply @internationalizeDocstring @wrap([getopts({'site': 'somethingWithoutSpaces'}), 'text']) def wiki(self, irc, msg, args, optlist, search): """[--site <site>] <search term> Returns the first paragraph of a wiki article. Optionally, a --site argument can be given to override the default (usually Wikipedia) - try using '--site lyrics.wikia.com' or '--site wiki.archlinux.org'.""" optlist = dict(optlist) baseurl = optlist.get('site') or self.registryValue('url', msg.args[0]) text = self._wiki(irc, msg, search, baseurl) irc.reply(text) @internationalizeDocstring @wrap([additional('somethingWithoutSpaces')]) def random(self, irc, msg, args, site): """[<site>] Returns the first paragraph of a random wiki article. Optionally, the --site argument can be given to override the default (usually Wikipedia).""" baseurl = site or self.registryValue('url', msg.args[0]) text = self._wiki(irc, msg, 'Special:Random', baseurl) irc.reply(text)
class Dice(callbacks.Plugin): """This plugin supports rolling the dice using !roll 4d20+3 as well as automatically rolling such combinations it sees in the channel (if autoRoll option is enabled for that channel) or query (if autoRollInPrivate option is enabled). """ rollReStandard = re.compile( r"((?P<rolls>\d+)#)?(?P<spec>[+-]?(\d*d\d+|\d+)([+-](\d*d\d+|\d+))*)$") rollReSR = re.compile(r"(?P<rolls>\d+)#sd$") rollReSRX = re.compile(r"(?P<rolls>\d+)#sdx$") rollReSRE = re.compile(r"(?P<pool>\d+),(?P<thr>\d+)#sde$") rollRe7Sea = re.compile( r"((?P<count>\d+)#)?(?P<prefix>[-+])?(?P<rolls>\d+)(?P<k>k{1,2})(?P<keep>\d+)(?P<mod>[+-]\d+)?$" ) rollRe7Sea2ed = re.compile( r"(?P<rolls>([-+]|\d)+)s(?P<skill>\d)(?P<vivre>-)?(l(?P<lashes>\d+))?(?P<explode>ex)?(?P<cursed>r15)?$" ) rollReWoD = re.compile(r"(?P<rolls>\d+)w(?P<explode>\d|-)?$") rollReDH = re.compile(r"(?P<rolls>\d*)vs\((?P<thr>([-+]|\d)+)\)$") rollReWG = re.compile(r"(?P<rolls>\d+)#wg$") validationDH = re.compile(r"^[+\-]?\d{1,4}([+\-]\d{1,4})*$") validation7sea2ed = re.compile(r"^[+\-]?\d{1,2}([+\-]\d{1,2})*$") MAX_DICE = 1000 MIN_SIDES = 2 MAX_SIDES = 100 MAX_ROLLS = 30 def __init__(self, irc): super(Dice, self).__init__(irc) self.deck = Deck() def _roll(self, dice, sides, mod=0): """ Roll a die several times, return sum of the results plus the static modifier. Arguments: dice -- number of dice rolled; sides -- number of sides each die has; mod -- number added to the total result (optional); """ res = int(mod) for _ in range(dice): res += random.randrange(1, sides + 1) return res def _rollMultiple(self, dice, sides, rolls=1, mod=0): """ Roll several dice several times, return a list of results. Specified number of dice with specified sides is rolled several times. Each time the sum of results is calculated, with optional modifier added. The list of these sums is returned. Arguments: dice -- number of dice rolled each time; sides -- number of sides each die has; rolls -- number of times dice are rolled; mod -- number added to the each total result (optional); """ return [self._roll(dice, sides, mod) for i in range(rolls)] @staticmethod def _formatMod(mod): """ Format a numeric modifier for printing expressions such as 1d20+3. Nonzero numbers are formatted with a sign, zero is formatted as an empty string. """ return "%+d" % mod if mod != 0 else "" def _process(self, irc, text): """ Process a message and reply with roll results, if any. The message is split to the words and each word is checked against all known expression forms (first applicable form is used). All results are printed together in the IRC reply. """ checklist = [ (self.rollReStandard, self._parseStandardRoll), (self.rollReSR, self._parseShadowrunRoll), (self.rollReSRX, self._parseShadowrunXRoll), (self.rollReSRE, self._parseShadowrunExtRoll), (self.rollRe7Sea, self._parse7SeaRoll), (self.rollRe7Sea2ed, self._parse7Sea2edRoll), (self.rollReWoD, self._parseWoDRoll), (self.rollReDH, self._parseDHRoll), (self.rollReWG, self._parseWGRoll), ] results = [] for word in text.split(): for expr, parser in checklist: m = expr.match(word) if m: r = parser(m) if r: results.append(r) break if results: irc.reply("; ".join(results)) def _parseStandardRoll(self, m): """ Parse rolls such as 3#2d6+1d4+2. This is a roll (or several rolls) of several dice with optional static modifiers. It yields one number (the sum of results and modifiers) for each roll series. """ rolls = int(m.group("rolls") or 1) spec = m.group("spec") if not spec[0] in "+-": spec = "+" + spec r = re.compile( r"(?P<sign>[+-])((?P<dice>\d*)d(?P<sides>\d+)|(?P<mod>\d+))") totalMod = 0 totalDice = {} for m in r.finditer(spec): if not m.group("mod") is None: totalMod += int(m.group("sign") + m.group("mod")) continue dice = int(m.group("dice") or 1) sides = int(m.group("sides")) if dice > self.MAX_DICE or sides > self.MAX_SIDES or sides < self.MIN_SIDES: return if m.group("sign") == "-": sides *= -1 totalDice[sides] = totalDice.get(sides, 0) + dice if len(totalDice) == 0: return results = [] for _ in range(rolls): result = totalMod for sides, dice in totalDice.items(): if sides > 0: result += self._roll(dice, sides) else: result -= self._roll(dice, -sides) results.append(result) specFormatted = "" self.log.debug(repr(totalDice)) for sides, dice in sorted(list(totalDice.items()), key=itemgetter(0), reverse=True): if sides > 0: if len(specFormatted) > 0: specFormatted += "+" specFormatted += "%dd%d" % (dice, sides) else: specFormatted += "-%dd%d" % (dice, -sides) specFormatted += self._formatMod(totalMod) return "[%s] %s" % (specFormatted, ", ".join([str(i) for i in results])) def _parseShadowrunRoll(self, m): """ Parse Shadowrun-specific roll such as 3#sd. """ rolls = int(m.group("rolls")) if rolls < 1 or rolls > self.MAX_DICE: return L = self._rollMultiple(1, 6, rolls) self.log.debug(format("%L", [str(i) for i in L])) return self._processSRResults(L, rolls) def _parseShadowrunXRoll(self, m): """ Parse Shadowrun-specific 'exploding' roll such as 3#sdx. """ rolls = int(m.group("rolls")) if rolls < 1 or rolls > self.MAX_DICE: return L = self._rollMultiple(1, 6, rolls) self.log.debug(format("%L", [str(i) for i in L])) reroll = L.count(6) while reroll: rerolled = self._rollMultiple(1, 6, reroll) self.log.debug(format("%L", [str(i) for i in rerolled])) L.extend([r for r in rerolled if r >= 5]) reroll = rerolled.count(6) return self._processSRResults(L, rolls, True) @staticmethod def _processSRResults(results, pool, isExploding=False): hits = results.count(6) + results.count(5) ones = results.count(1) isHit = hits > 0 isGlitch = ones >= (pool + 1) / 2 explStr = ", exploding" if isExploding else "" if isHit: hitsStr = format("%n", (hits, "hit")) glitchStr = ", glitch" if isGlitch else "" return "(pool %d%s) %s%s" % (pool, explStr, hitsStr, glitchStr) if isGlitch: return "(pool %d%s) critical glitch!" % (pool, explStr) return "(pool %d%s) 0 hits" % (pool, explStr) def _parseShadowrunExtRoll(self, m): """ Parse Shadowrun-specific Extended test roll such as 14,3#sde. """ pool = int(m.group("pool")) if pool < 1 or pool > self.MAX_DICE: return threshold = int(m.group("thr")) if threshold < 1 or threshold > self.MAX_DICE: return result = 0 passes = 0 glitches = [] critGlitch = None while result < threshold: L = self._rollMultiple(1, 6, pool) self.log.debug(format("%L", [str(i) for i in L])) hits = L.count(6) + L.count(5) result += hits passes += 1 isHit = hits > 0 isGlitch = L.count(1) >= (pool + 1) / 2 if isGlitch: if not isHit: critGlitch = passes break glitches.append(ordinal(passes)) glitchStr = format(", glitch at %L", glitches) if len(glitches) > 0 else "" if critGlitch is None: return format( "(pool %i, threshold %i) %n, %n%s", pool, threshold, (passes, "pass"), (result, "hit"), glitchStr, ) else: return format( "(pool %i, threshold %i) critical glitch at %s pass%s, %n so far", pool, threshold, ordinal(critGlitch), glitchStr, (result, "hit"), ) def _parse7Sea2edRoll(self, m): """ Parse 7th Sea 2ed roll (4s2 is its simplest form). Full spec: https://redd.it/80l7jm """ rolls = m.group("rolls") if rolls is None: return # additional validation if not re.match(self.validation7sea2ed, rolls): return roll_count = eval(rolls) if roll_count < 1 or roll_count > self.MAX_ROLLS: return skill = int(m.group("skill")) vivre = m.group("vivre") == "-" explode = m.group("explode") == "ex" lashes = 0 if m.group("lashes") is None else int(m.group("lashes")) cursed = m.group("cursed") is not None self.log.debug( format( "7sea2ed: %i (%s) dices at %i skill. lashes = %i. explode is %s. vivre" " is %s", roll_count, str(rolls), skill, lashes, "enabled" if explode else "disabled", "enabled" if vivre else "disabled", )) roller = SevenSea2EdRaiseRoller( lambda x: self._rollMultiple(1, 10, x), skill_rank=skill, explode=explode, lash_count=lashes, joie_de_vivre=vivre, raise_target=15 if cursed else 10, ) return "[%s]: %s" % (m.group(0), str( roller.roll_and_count(roll_count))) def _parse7SeaRoll(self, m): """ Parse 7th Sea-specific roll (4k2 is its simplest form). """ rolls = int(m.group("rolls")) if rolls < 1 or rolls > self.MAX_ROLLS: return count = int(m.group("count") or 1) keep = int(m.group("keep")) mod = int(m.group("mod") or 0) prefix = m.group("prefix") k = m.group("k") explode = prefix != "-" if keep < 1 or keep > self.MAX_ROLLS: return if keep > rolls: keep = rolls if rolls > 10: keep += rolls - 10 rolls = 10 if keep > 10: mod += (keep - 10) * 10 keep = 10 unkept = (prefix == "+" or k == "kk") and keep < rolls explodeStr = ", not exploding" if not explode else "" results = [] for _ in range(count): L = self._rollMultiple(1, 10, rolls) if explode: for i in range(len(L)): if L[i] == 10: while True: rerolled = self._roll(1, 10) L[i] += rerolled if rerolled < 10: break self.log.debug(format("%L", [str(i) for i in L])) L.sort(reverse=True) keptDice, unkeptDice = L[:keep], L[keep:] unkeptStr = (" | %s" % ", ".join([str(i) for i in unkeptDice]) if unkept else "") keptStr = ", ".join([str(i) for i in keptDice]) results.append("(%d) %s%s" % (sum(keptDice) + mod, keptStr, unkeptStr)) return "[%dk%d%s%s] %s" % ( rolls, keep, self._formatMod(mod), explodeStr, "; ".join(results), ) def _parseWoDRoll(self, m): """ Parse New World of Darkness roll (5w) """ rolls = int(m.group("rolls")) if rolls < 1 or rolls > self.MAX_ROLLS: return if m.group("explode") == "-": explode = 0 elif m.group("explode") is not None and m.group("explode").isdigit(): explode = int(m.group("explode")) if explode < 8 or explode > 10: explode = 10 else: explode = 10 L = self._rollMultiple(1, 10, rolls) self.log.debug(format("%L", [str(i) for i in L])) successes = len([x for x in L if x >= 8]) if explode: for i in range(len(L)): if L[i] >= explode: while True: rerolled = self._roll(1, 10) self.log.debug(str(rerolled)) if rerolled >= 8: successes += 1 if rerolled < explode: break if explode == 0: explStr = ", not exploding" elif explode != 10: explStr = ", %d-again" % explode else: explStr = "" result = format("%n", (successes, "success")) if successes > 0 else "FAIL" return "(%d%s) %s" % (rolls, explStr, result) def _parseDHRoll(self, m): """ Parse Dark Heresy roll (3vs(20+30-10)) """ rolls = int(m.group("rolls") or 1) if rolls < 1 or rolls > self.MAX_ROLLS: return thresholdExpr = m.group("thr") # additional validation if not re.match(self.validationDH, thresholdExpr): return threshold = eval(thresholdExpr) rollResults = self._rollMultiple(1, 100, rolls) results = [threshold - roll for roll in rollResults] return "%s (%s vs %d)" % ( ", ".join([str(i) for i in results]), ", ".join([str(i) for i in rollResults]), threshold, ) def _parseWGRoll(self, m): """ Parse WH40K: Wrath & Glory roll (10#wg) """ rolls = int(m.group("rolls") or 1) if rolls < 1 or rolls > self.MAX_ROLLS: return L = self._rollMultiple(1, 6, rolls) self.log.debug(format("%L", [str(i) for i in L])) return self._processWGResults(L, rolls) @staticmethod def _processWGResults(results, pool): wrathstrings = ["❶", "❷", "❸", "❹", "❺", "❻"] strTag = "" wrathDie = results.pop(0) n6 = results.count(6) n5 = results.count(5) n4 = results.count(4) icons = 2 * n6 + n5 + n4 Glory = wrathDie == 6 Complication = wrathDie == 1 iconssymb = wrathstrings[wrathDie - 1] + " " if Glory: strTag += "| Glory" icons += 2 elif wrathDie > 3: icons += 1 elif Complication: strTag += "| Complication" iconssymb += n6 * "➅ " + n5 * "5 " + n4 * "4 " isNonZero = icons > 0 if isNonZero: iconsStr = str(icons) + " icon(s): " + iconssymb + strTag return "[pool %d] %s" % (pool, iconsStr) def _autoRollEnabled(self, irc, channel): """ Check if automatic rolling is enabled for this context. """ return (irc.isChannel(channel) and self.registryValue( "autoRoll", channel)) or (not irc.isChannel(channel) and self.registryValue("autoRollInPrivate")) def roll(self, irc, msg, args, text): """<dice>d<sides>[<modifier>] Rolls a die with <sides> number of sides <dice> times, summarizes the results and adds optional modifier <modifier> For example, 2d6 will roll 2 six-sided dice; 10d10-3 will roll 10 ten-sided dice and subtract 3 from the total result. """ if self._autoRollEnabled(irc, msg.args[0]): return self._process(irc, text) roll = wrap(roll, ["somethingWithoutSpaces"]) def shuffle(self, irc, msg, args): """takes no arguments Restores and shuffles the deck. """ self.deck.shuffle() irc.reply("shuffled") shuffle = wrap(shuffle) def draw(self, irc, msg, args, count): """[<count>] Draws <count> cards (1 if omitted) from the deck and shows them. """ cards = [next(self.deck) for i in range(count)] irc.reply(", ".join(cards)) draw = wrap(draw, [additional("positiveInt", 1)]) deal = draw def doPrivmsg(self, irc, msg): if not self._autoRollEnabled(irc, msg.args[0]): return if ircmsgs.isAction(msg): text = ircmsgs.unAction(msg) else: text = msg.args[1] self._process(irc, text)