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.

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