Maki is a Discord bot that does things. Written in Python 3 and relies on Discord.py API implementation.
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

454 řádky
16KB

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