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.

402 lines
14KB

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