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.

382 lines
13KB

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