Maki is a Discord bot that does things. Written in Python 3 and relies on Discord.py API implementation.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

372 lines
12KB

  1. # Maki
  2. # ----
  3. # Discord bot by MrDetonia
  4. #
  5. # Copyright 2016 Zac Herd
  6. # Licensed under BSD 3-clause License, see LICENSE.md for more info
  7. # IMPORTS
  8. import discord
  9. import asyncio
  10. import os
  11. import io
  12. import urllib3
  13. from html.parser import HTMLParser
  14. import sys
  15. import shlex
  16. import subprocess
  17. import time
  18. import datetime
  19. import random
  20. import re
  21. import json
  22. import logging
  23. import markov
  24. # file in this directory called "secret.py" should contain these variables
  25. from secret import token, lfmkey
  26. # CONFIGURATION
  27. # bot version
  28. version = "v0.17.2"
  29. # text shown by .help command
  30. helptext = """I am a Discord bot written in Python
  31. My commands are:
  32. ```
  33. .help - displays this text
  34. .info - prints bot info
  35. .upskirt - show a link to my source
  36. .whoami - displays your user info
  37. .whois <user> - displays another user's info
  38. .seen <user> - prints when user was last seen
  39. .say <msg> - say something
  40. .sayy <msg> - say something a e s t h e t i c a l l y
  41. .markov [<user>] - generate markov chain over chat history for you or another user
  42. .roll <x>d<y> - roll x number of y sided dice
  43. .qr <msg> - generate a QR code
  44. .np [<user>] - fetch now playing from last.fm for you or a specific username
  45. ```"""
  46. # IDs of admin users
  47. admins = ['116883900688629761']
  48. # GLOBALS
  49. # log of users' last messages and timestamps
  50. history = {}
  51. if os.path.isfile('hist.json'):
  52. with open('hist.json', 'r') as fp:
  53. history = json.load(fp)
  54. # this instance of the Discord client
  55. client = discord.Client()
  56. # logging setup
  57. logger = logging.getLogger('discord')
  58. logger.setLevel(logging.DEBUG)
  59. handler = logging.FileHandler(filename='discord.log', encoding='utf-8', mode='w')
  60. handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s'))
  61. logger.addHandler(handler)
  62. # init urllib3 pool manager
  63. http = urllib3.PoolManager()
  64. # FUNCTIONS
  65. # converts a datetime to a string
  66. def strfromdt(dt):
  67. return dt.strftime('%Y-%m-%d %H:%M:%S')
  68. # converts a timestamp to a datetime
  69. def dtfromts(ts):
  70. return datetime.datetime.fromtimestamp(ts)
  71. # gets now playing information from last.fm
  72. def lastfm_np(username):
  73. # sanitise username
  74. cleanusername = re.sub(r'[^a-zA-Z0-9_-]', '', username, 0)
  75. # fetch xml from last.fm
  76. r = http.request("GET", "https://ws.audioscrobbler.com/2.0/?method=user.getRecentTracks&user=" + cleanusername + "&limit=1&api_key=" + lfmkey)
  77. if r.status != 200:
  78. return "Couldn't get last.fm data for " + username
  79. xml = r.data.decode('utf-8')
  80. # un-fuck text
  81. h = HTMLParser()
  82. xml = h.unescape(xml)
  83. # isolate fields
  84. username = xml.split('" page="')[0].split('<recenttracks user="')[1]
  85. artist = xml.split('</artist>')[0].split('>')[-1]
  86. song = xml.split('</name>')[0].split('<name>')[1]
  87. album = xml.split('</album>')[0].split('>')[-1]
  88. # grammar
  89. if album != "":
  90. albumtext = "` from the album `" + album + "`"
  91. else:
  92. albumtext = "`"
  93. if xml.find("track nowplaying=\"true\">") == -1:
  94. nowplaying = " last listened"
  95. else:
  96. nowplaying = " is listening"
  97. # construct string
  98. return username + nowplaying + " to `" + song + "` by `" + artist + albumtext
  99. # EVENT HANDLERS
  100. # called when client ready
  101. @client.event
  102. @asyncio.coroutine
  103. def on_ready():
  104. # info on terminal
  105. print('Connected')
  106. print('User: ' + client.user.name)
  107. print('ID: ' + client.user.id)
  108. # set "Now Playing" to print version
  109. game = discord.Game(name = version)
  110. yield from client.change_presence(game=game)
  111. # called when message received
  112. @client.event
  113. @asyncio.coroutine
  114. def on_message(message):
  115. # print messages to terminal for info
  116. timestr = time.strftime('%Y-%m-%d-%H:%M:%S: ')
  117. try:
  118. print(timestr + message.server.name + ' ' + message.channel.name + ' ' + message.author.name + ': ' + message.content)
  119. except AttributeError:
  120. print(timestr + 'PRIV ' + message.author.name + ': ' + message.content)
  121. # do not parse own messages or private messages
  122. if message.author != client.user and type(message.channel) is not discord.PrivateChannel:
  123. # response to send to channel
  124. response = ''
  125. # parse messages for commands
  126. if message.content.startswith('.info'):
  127. # print bot info
  128. pyver = str(sys.version_info[0]) + '.' + str(sys.version_info[1]) + '.' + str(sys.version_info[2])
  129. appinfo = yield from client.application_info()
  130. response = 'I am ' + appinfo.name + ', a Discord bot by ' + appinfo.owner.name + ' | ' + version + ' | Python ' + pyver + ' | discord.py ' + discord.__version__
  131. elif message.content.startswith('.help'):
  132. # print command list
  133. response = helptext
  134. elif message.content.startswith('.upskirt'):
  135. # link to source code
  136. response = 'No, don\'t look at my pantsu! Baka! <https://gitla.in/MrDetonia/maki>'
  137. elif message.content.startswith('.die'):
  138. if message.author.id in admins:
  139. # exit discord and kill bot
  140. print('INFO: Accepting .die from ' + message.author.name)
  141. run = False
  142. yield from client.send_message(message.channel, 'But will I dream? ;_;')
  143. yield from client.logout()
  144. else:
  145. # user not admin, refuse
  146. response = 'Don\'t be so rude! >:('
  147. elif message.content.startswith('.whoami'):
  148. # show info about user
  149. response = 'User: ' + message.author.name + ' ID: ' + message.author.id + ' Discriminator: ' + message.author.discriminator + '\nAccount Created: ' + strfromdt(message.author.created_at)
  150. elif message.content.startswith('.whois '):
  151. # show info about another user
  152. tmp = message.content[7:]
  153. user = message.server.get_member_named(tmp)
  154. if user == None:
  155. response = 'I can\'t find ' + tmp
  156. else:
  157. response = 'User: ' + user.name + ' ID: ' + user.id + ' Discriminator: ' + user.discriminator + '\nAccount Created: ' + strfromdt(user.created_at)
  158. elif message.content.startswith('.seen '):
  159. # print when user was last seen
  160. try:
  161. target = message.server.get_member_named(message.content[6:]).id
  162. except AttributeError:
  163. response = "user not seen yet"
  164. target = ""
  165. if target in history:
  166. # user logged, print last message and time
  167. response = 'user ' + message.content[6:] + ' was last seen saying "' + history[target][0] + '" at ' + strfromdt(dtfromts(history[target][1]))
  168. elif message.content[6:] == 'Maki':
  169. # Maki doesn't need to be .seen
  170. response = 'I\'m right here!'
  171. else:
  172. # user not logged
  173. response = "user not seen yet"
  174. elif message.content.startswith('.say '):
  175. # delete calling message for effect
  176. yield from client.delete_message(message)
  177. # echo message
  178. response = message.content[5:]
  179. elif message.content.startswith('.sayy '):
  180. # delete calling message
  181. yield from client.delete_message(message)
  182. # echo aesthetic message
  183. response = ' '.join(message.content[6:])
  184. elif message.content.startswith('.markov'):
  185. # send typing signal to discord
  186. for attempt in range(5):
  187. try:
  188. yield from client.send_typing(message.channel)
  189. except discord.errors.HTTPException:
  190. continue
  191. else:
  192. break
  193. else:
  194. print('ERROR: Failed to send typing signal to discord after 5 attempts')
  195. # generate a markov chain sentence based on the user's chat history
  196. tmp = message.content[8:]
  197. target = ''
  198. # if no user provided, markov the author
  199. if tmp == '':
  200. target = message.server.id + '-' + message.author.id
  201. else:
  202. try:
  203. target = message.server.id + '-' + message.server.get_member_named(tmp).id
  204. except AttributeError:
  205. response = "I can't find that user!"
  206. if os.path.isfile('./markovs/' + target) and target != '':
  207. mc = markov.Markov(open('./markovs/' + target))
  208. response = mc.generate_text(random.randint(20,40))
  209. elif target != '':
  210. response = "I haven't seen them speak yet!"
  211. elif message.content.startswith('.roll '):
  212. # DnD style dice roll
  213. tmp = message.content[6:]
  214. #check syntax is valid
  215. pattern = re.compile('^([0-9]+)d([0-9]+)$')
  216. if pattern.match(tmp):
  217. # extract numbers
  218. nums = [int(s) for s in re.findall(r'\d+', message.content)]
  219. # limit range
  220. if nums[0] < 1: nums[0] = 1
  221. if nums[1] < 1: nums[1] = 1
  222. if nums[0] > 100: nums[0] = 100
  223. if nums[1] > 1000000: nums[1] = 1000000
  224. # roll dice multiple times and sum
  225. rollsum = 0
  226. for i in range(nums[0]):
  227. rollsum += random.randint(1, nums[1])
  228. response = 'Using ' + str(nums[0]) + 'd' + str(nums[1]) + ' you rolled: ' + str(rollsum)
  229. else:
  230. response = 'you did it wrong!'
  231. elif message.content.startswith('.np'):
  232. tmp = message.content[4:]
  233. if tmp == '':
  234. response = lastfm_np(message.author.name)
  235. else:
  236. response = lastfm_np(tmp)
  237. elif message.content.startswith('.qr '):
  238. # generate QR code - DANGEROUS, CHECK CAREFULLY HERE
  239. tmp = message.content[4:]
  240. # send typing signal to discord
  241. for attempt in range(5):
  242. try:
  243. yield from client.send_typing(message.channel)
  244. except discord.errors.HTTPException:
  245. continue
  246. else:
  247. break
  248. else:
  249. print('ERROR: Failed to send typing signal to discord after 5 attempts')
  250. # make sure there are no nasty characters
  251. msg = re.sub(r'[^a-zA-Z0-9_ -]', '', tmp, 0)
  252. # echo message
  253. cmd = 'echo "\'' + msg + '\'"'
  254. args = shlex.split(cmd)
  255. echo = subprocess.Popen(args, stdout=subprocess.PIPE)
  256. # generate QR code
  257. cmd = 'qrencode -t png -o -'
  258. args = shlex.split(cmd)
  259. qr = subprocess.Popen(args, stdin=echo.stdout, stdout=subprocess.PIPE)
  260. # upload file with curl and get URL
  261. cmd = 'curl -F upload=@- https://w1r3.net'
  262. args = shlex.split(cmd)
  263. out = subprocess.check_output(args, stdin=qr.stdout)
  264. # run piped commands
  265. echo.wait()
  266. # send response
  267. response = out.decode('utf-8').strip()
  268. # Stuff that happens when message is not a bot command:
  269. else:
  270. # log each message against users
  271. if message.content != "":
  272. history[message.author.id] = (message.content, time.time())
  273. with open('hist.json', 'w') as fp:
  274. json.dump(history, fp)
  275. # log user messages for markov chains, ignoring messages with certain substrings
  276. filters = ['`', 'http://', 'https://']
  277. if not any(x in message.content for x in filters):
  278. try:
  279. with open('./markovs/' + message.server.id + '-' + message.author.id, 'a') as fp:
  280. fp.write('\n' + message.content)
  281. except PermissionError:
  282. pass
  283. # someone noticed me! <3
  284. if bool(re.search(r'\b[Mm][Aa][Kk][Ii]\b', message.content)):
  285. yield from client.add_reaction(message, '\N{BLACK HEART SUIT}')
  286. # send response to channel if needed:
  287. if response is not '':
  288. for attempt in range(5):
  289. try:
  290. yield from client.send_message(message.channel, response)
  291. except discord.errors.HTTPException:
  292. continue
  293. else:
  294. break
  295. else:
  296. print('ERROR: Failed to send message to discord after 5 attempts')
  297. # Run the client
  298. client.run(token)
  299. # finish execution
  300. exit(0)