# Maki # ---- # Discord bot by MrDetonia # # Copyright 2016 Zac Herd # Licensed under BSD 3-clause License, see LICENSE.md for more info # IMPORTS import discord import asyncio import os import io import requests import sys import shlex import subprocess import time import datetime import random import re import json import logging import markov # file in this directory called "secret.py" should contain these variables from secret import token, lfmkey, steamkey # CONFIGURATION # bot version version = "v0.19.2" # text shown by .help command helptext = """I am a Discord bot written in Python My commands are: ``` .help - displays this text .info - prints bot info .upskirt - show a link to my source .whoami - displays your user info .whois - displays another user's info .seen - prints when user was last seen .say - say something .sayy - say something a e s t h e t i c a l l y .markov [] - generate markov chain over chat history for you or another user .roll d - roll x number of y sided dice .qr - generate a QR code .np [] - fetch now playing from last.fm for you or a specific username .steam [] - fetch steam status for you or a specific vanityname ```""" # IDs of admin users admins = ['116883900688629761'] # GLOBALS # log of users' last messages and timestamps history = {} if os.path.isfile('hist.json'): with open('hist.json', 'r') as fp: history = json.load(fp) # quiet modes quiet = {} # this instance of the Discord client client = discord.Client() # logging setup logger = logging.getLogger('discord') logger.setLevel(logging.DEBUG) handler = logging.FileHandler(filename='discord.log', encoding='utf-8', mode='w') handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s')) logger.addHandler(handler) # FUNCTIONS # converts a datetime to a string def strfromdt(dt): return dt.strftime('%Y-%m-%d %H:%M:%S') # converts a timestamp to a datetime def dtfromts(ts): return datetime.datetime.fromtimestamp(ts) # gets now playing information from last.fm def lastfm_np(username): # sanitise username cleanusername = re.sub(r'[^a-zA-Z0-9_-]', '', username, 0) # fetch JSON from last.fm payload = {'format': 'json', 'method': 'user.getRecentTracks', 'user': cleanusername, 'limit': '1', 'api_key': lfmkey} r = requests.get("http://ws.audioscrobbler.com/2.0/", params=payload) # read json data np = r.json() # check we got a valid response if 'error' in np: return "I couldn't get last.fm data for " + username # get fields try: username = np['recenttracks']['@attr']['user'] track = np['recenttracks']['track'][0] album = track['album']['#text'] artist = track['artist']['#text'] song = track['name'] nowplaying = '@attr' in track except IndexError: return "It looks like " + username + " hasn't played anything recently." # grammar if album != "": albumtext = "` from the album `" + album + "`" else: albumtext = "`" if nowplaying == True: nowplaying = " is listening" else: nowplaying = " last listened" # construct string return username + nowplaying + " to `" + song + "` by `" + artist + albumtext # gets general steam user info from a vanityurl name def steamdata(vanityname): # sanitise username cleanvanityname = re.sub(r'[^a-zA-Z0-9_-]', '', vanityname, 0) resolveurl = 'http://api.steampowered.com/ISteamUser/ResolveVanityURL/v0001/?key=' dataurl = 'http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=' # fetch json from steam try: idresponse = requests.get(resolveurl + steamkey + '&vanityurl=' + vanityname).json()['response'] except: return 'I can\'t connect to Steam' # check if user was found and extract steamid if idresponse['success'] is not 1: return ' I couldn\'t find ' + vanityname else: steamid = idresponse['steamid'] # fetch steam user info try: dataresponse = requests.get(dataurl + steamkey + '&steamids=' + steamid).json()['response']['players'][0] except: return 'Can\'t find info on ' + vanityname personastates = ['Offline', 'Online', 'Busy', 'Away', 'Snoozed', 'Looking to trade', 'Looking to play'] if 'personaname' in dataresponse: namestr = dataresponse['personaname'] else: namestr = '' if 'personastate' in dataresponse: statestr = personastates[dataresponse['personastate']] else: statestr = '' if 'gameextrainfo' in dataresponse: gamestr = ' playing ' + dataresponse['gameextrainfo'] else: gamestr = '' responsetext = [(namestr + ' is ' + statestr + gamestr).replace(' ', ' ')] return '\n'.join(responsetext) # EVENT HANDLERS # called when client ready @client.event @asyncio.coroutine def on_ready(): # info on terminal print('Connected') print('User: ' + client.user.name) print('ID: ' + client.user.id) # set "Now Playing" to print version game = discord.Game(name = version) yield from client.change_presence(game=game) # called when message received @client.event @asyncio.coroutine def on_message(message): # print messages to terminal for info timestr = time.strftime('%Y-%m-%d-%H:%M:%S: ') try: print(timestr + message.server.name + ' ' + message.channel.name + ' ' + message.author.name + ': ' + message.content) except AttributeError: print(timestr + 'PRIV ' + message.author.name + ': ' + message.content) # do not parse own messages or private messages if message.author != client.user and type(message.channel) is not discord.PrivateChannel: # response to send to channel response = '' # parse messages for commands if message.content.startswith('.info'): # print bot info pyver = str(sys.version_info[0]) + '.' + str(sys.version_info[1]) + '.' + str(sys.version_info[2]) appinfo = yield from client.application_info() response = 'I am ' + appinfo.name + ', a Discord bot by ' + appinfo.owner.name + ' | ' + version + ' | Python ' + pyver + ' | discord.py ' + discord.__version__ elif message.content.startswith('.help'): # print command list response = helptext elif message.content.startswith('.upskirt'): # link to source code response = 'No, don\'t look at my pantsu! Baka! | ' elif message.content.startswith('.die'): if message.author.id in admins: # exit discord and kill bot print('INFO: Accepting .die from ' + message.author.name) run = False yield from client.send_message(message.channel, 'But will I dream? ;_;') yield from client.logout() if message.content[5:] == 'reload': # touch a file called 'reload' which signals we should restart with open('reload', 'a'): os.utime('reload', None) else: # user not admin, refuse response = 'Don\'t be so rude! >:(' elif message.content.startswith('.whoami'): # show info about user response = 'User: `' + message.author.name + '` ID: `' + message.author.id + '` Discriminator: `' + message.author.discriminator + '`\nAccount Created: `' + strfromdt(message.author.created_at) + '`' elif message.content.startswith('.whois '): # show info about another user tmp = message.content[7:] user = message.server.get_member_named(tmp) if user == None: response = 'I can\'t find ' + tmp else: response = 'User: `' + user.name + '` ID: `' + user.id + '` Discriminator: `' + user.discriminator + '`\nAccount Created: `' + strfromdt(user.created_at) + '`' elif message.content.startswith('.seen '): # print when user was last seen try: target = message.server.id + message.server.get_member_named(message.content[6:]).id except AttributeError: response = "I can't find that user!" target = "" if target in history and history[target][0] == message.server.id: # user logged, print last message and time response = 'user ' + message.content[6:] + ' was last seen saying "' + history[target][2] + '" at ' + strfromdt(dtfromts(history[target][1])) elif message.content[6:] == 'Maki': # Maki doesn't need to be .seen response = 'I\'m right here!' else: # user not logged response = "user not seen yet" elif message.content.startswith('.say '): # delete calling message for effect yield from client.delete_message(message) # echo message response = message.content[5:] elif message.content.startswith('.sayy '): # delete calling message yield from client.delete_message(message) # echo aesthetic message response = ' '.join(message.content[6:]) elif message.content.startswith('.markov'): # send typing signal to discord for attempt in range(5): try: yield from client.send_typing(message.channel) except discord.errors.HTTPException: continue else: break else: print('ERROR: Failed to send typing signal to discord after 5 attempts') # generate a markov chain sentence based on the user's chat history tmp = message.content[8:] target = '' if tmp == 'Maki': response = "My markovs always say the same thing." else: # if no user provided, markov the author if tmp == '': target = message.server.id + '-' + message.author.id else: try: target = message.server.id + '-' + message.server.get_member_named(tmp).id except AttributeError: response = "I can't find that user!" if os.path.isfile('./markovs/' + target) and target != '': mc = markov.Markov(open('./markovs/' + target)) response = mc.generate_text(random.randint(20,40)) elif target != '': response = "I haven't seen them speak yet!" elif message.content.startswith('.roll '): # DnD style dice roll tmp = message.content[6:] #check syntax is valid pattern = re.compile('^([0-9]+)d([0-9]+)$') if pattern.match(tmp): # extract numbers nums = [int(s) for s in re.findall(r'\d+', message.content)] # limit range if nums[0] < 1: nums[0] = 1 if nums[1] < 1: nums[1] = 1 if nums[0] > 100: nums[0] = 100 if nums[1] > 1000000: nums[1] = 1000000 # roll dice multiple times and sum rollsum = 0 for i in range(nums[0]): rollsum += random.randint(1, nums[1]) response = 'Using ' + str(nums[0]) + 'd' + str(nums[1]) + ' you rolled: ' + str(rollsum) else: response = 'you did it wrong!' elif message.content.startswith('.np'): # show now playing info from last.fm tmp = message.content[4:] if tmp == '': response = lastfm_np(message.author.name) else: response = lastfm_np(tmp) elif message.content.startswith('.steam'): # show steam status tmp = message.content[7:] if tmp == '': response = steamdata(message.author.name) else: response = steamdata(tmp) elif message.content.startswith('.qr '): # generate QR code - DANGEROUS, CHECK CAREFULLY HERE tmp = message.content[4:] # send typing signal to discord for attempt in range(5): try: yield from client.send_typing(message.channel) except discord.errors.HTTPException: continue else: break else: print('ERROR: Failed to send typing signal to discord after 5 attempts') # make sure there are no nasty characters msg = re.sub(r'[^a-zA-Z0-9_ -]', '', tmp, 0) # echo message cmd = 'echo "\'' + msg + '\'"' args = shlex.split(cmd) echo = subprocess.Popen(args, stdout=subprocess.PIPE) # generate QR code cmd = 'qrencode -t png -o -' args = shlex.split(cmd) qr = subprocess.Popen(args, stdin=echo.stdout, stdout=subprocess.PIPE) # upload file with curl and get URL cmd = 'curl -F upload=@- https://w1r3.net' args = shlex.split(cmd) out = subprocess.check_output(args, stdin=qr.stdout) # run piped commands echo.wait() # send response response = out.decode('utf-8').strip() elif message.content.startswith('.quiet'): if message.author.id in admins: quiet[message.server.id] = 1 else: response = "No, *you* be quiet!" elif message.content.startswith('.loud'): if message.server.id in quiet and message.author.id in admins: quiet.pop(message.server.id, None) # Stuff that happens when message is not a bot command: else: # log each message against users if message.content != "": history[message.server.id + message.author.id] = (message.server.id, time.time(), message.content) with open('hist.json', 'w') as fp: json.dump(history, fp) # log user messages for markov chains, ignoring messages with certain substrings filters = ['`', 'http://', 'https://'] if not any(x in message.content for x in filters): try: with open('./markovs/' + message.server.id + '-' + message.author.id, 'a') as fp: fp.write('\n' + message.content) except PermissionError: pass # someone noticed me! <3 if bool(re.search(r'\b[Mm][Aa][Kk][Ii]\b', message.content)): yield from client.add_reaction(message, '\N{BLACK HEART SUIT}') # butter if bool(re.search(r'\b[Bb][Uu][Tt][Tt][Ee][Rr]\b', message.content)): yield from client.add_reaction(message, '\N{PERSON WITH FOLDED HANDS}') # egg if bool(re.search(r'\b[Ee][Gg][Gg]\b', message.content)): yield from client.add_reaction(message, '\N{AUBERGINE}') # send response to channel if needed: if response is not '' and message.server.id not in quiet: for attempt in range(5): try: yield from client.send_message(message.channel, response) except discord.errors.HTTPException: continue else: break else: print('ERROR: Failed to send message to discord after 5 attempts') # Run the client client.run(token) # finish execution exit(0)