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.

576 lines
23KB

  1. # -*- coding: utf-8 -*-
  2. # MIT License
  3. #
  4. # Copyright (c) 2017-2018 Roxanne Gibson
  5. #
  6. # Permission is hereby granted, free of charge, to any person obtaining a copy
  7. # of this software and associated documentation files (the "Software"), to deal
  8. # in the Software without restriction, including without limitation the rights
  9. # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  10. # copies of the Software, and to permit persons to whom the Software is
  11. # furnished to do so, subject to the following conditions:
  12. #
  13. # The above copyright notice and this permission notice shall be included in all
  14. # copies or substantial portions of the Software.
  15. #
  16. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  17. # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  18. # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  19. # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  20. # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  21. # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  22. # SOFTWARE.
  23. import asyncio
  24. import datetime
  25. import os
  26. import string
  27. import typing
  28. import discord
  29. import youtube_dl
  30. from discord.ext import commands
  31. import roxbot
  32. from roxbot.db import *
  33. class LoggingSingle(db.Entity):
  34. enabled = Required(bool, default=False)
  35. logging_channel_id = Optional(int, nullable=True, size=64)
  36. guild_id = Required(int, unique=True, size=64)
  37. class Blacklist(db.Entity):
  38. user_id = Required(int, unique=True, size=64)
  39. class Roxbot(commands.Bot):
  40. """Modified client for Roxbot"""
  41. def __init__(self, **kwargs):
  42. super().__init__(**kwargs)
  43. @staticmethod
  44. def blacklisted(user):
  45. """Checks if given user is blacklisted from the bot.
  46. Params
  47. =======
  48. user: discord.User
  49. Returns
  50. =======
  51. If the user is blacklisted: bool"""
  52. with db_session:
  53. return select(u for u in Blacklist if u.user_id == user.id).exists()
  54. async def delete_option(self, message, delete_emoji=None, timeout=20):
  55. """Utility function that allows for you to add a delete option to the end of a command.
  56. This makes it easier for users to control the output of commands, esp handy for random output ones.
  57. Params
  58. =======
  59. message: discord.Message
  60. Output message from Roxbot
  61. delete_emoji: discord.Emoji or str if unicode emoji
  62. Used as the reaction for the user to click on.
  63. timeout: int (Optional)
  64. Amount of time in seconds for the bot to wait for the reaction. Deletes itself after the timer runes out.
  65. Set to 20 by default
  66. """
  67. if not delete_emoji:
  68. delete_emoji = "❌"
  69. def check(r, u):
  70. return str(r) == str(delete_emoji) and u != message.author and r.message.id == message.id
  71. await message.add_reaction(delete_emoji)
  72. try:
  73. await self.wait_for("reaction_add", timeout=timeout, check=check)
  74. await message.remove_reaction(delete_emoji, self.user)
  75. try:
  76. await message.remove_reaction(delete_emoji, message.author)
  77. except discord.Forbidden:
  78. pass
  79. await message.edit(content="{} requested output be deleted.".format(message.author), embed=None)
  80. except asyncio.TimeoutError:
  81. await message.remove_reaction(delete_emoji, self.user)
  82. async def log(self, guild, command_name, **kwargs):
  83. """Logs activity internally for Roxbot. Will only do anything if the server enables internal logging.
  84. This is mostly used for logging when certain commands are used that can be an issue for admins. Esp when Roxbot outputs
  85. something that could break the rules, then deletes their message.
  86. Params
  87. =======
  88. guild: discord.Guild
  89. Used to check if the guild has logging enabled
  90. channel: discord.TextChannel
  91. command_name: str
  92. kwargs: dict
  93. All kwargs and two other params will be added to the logging embed as fields, allowing you to customise the output
  94. """
  95. if guild:
  96. with db_session:
  97. logging = LoggingSingle.get(guild_id=guild.id)
  98. if logging.enabled and logging.logging_channel_id:
  99. channel = self.get_channel(logging.logging_channel_id)
  100. embed = discord.Embed(title="{} command logging".format(command_name), colour=roxbot.EmbedColours.pink)
  101. for key, value in kwargs.items():
  102. embed.add_field(name=key, value=value)
  103. return await channel.send(embed=embed)
  104. class Core(commands.Cog):
  105. """Core bot cog. Includes management commands, logging, error handling, and backups."""
  106. COMMANDONCOOLDOWN = "This command is on cooldown, please wait {:.2f} seconds before trying again."
  107. CHECKFAILURE = "You do not have permission to do this. Back off, thot!"
  108. TOOMANYARGS = "Too many arguments given."
  109. DISABLEDCOMMAND = "This command is disabled."
  110. COGSETTINGDISABLED = "{} is disabled on this server."
  111. NODMS = "This command cannot be used in private messages."
  112. YTDLDOWNLOADERROR = "Video could not be downloaded: {}"
  113. def __init__(self, bot_client):
  114. self.bot = bot_client
  115. # Error Handling
  116. #self.bot.add_listener(self.error_handle, "command_error")
  117. self.dev = roxbot.dev_mode
  118. # Backup setup
  119. if roxbot.backup_enabled:
  120. self.backup_task = self.bot.loop.create_task(self.auto_backups())
  121. # Logging Setup
  122. self.bot.add_listener(self.cleanup_logging_settings, "on_guild_channel_delete")
  123. self.bot.add_listener(self.log_member_join, "on_member_join")
  124. self.bot.add_listener(self.log_member_remove, "on_member_remove")
  125. self.autogen_db = LoggingSingle
  126. @staticmethod
  127. def command_not_found_check(ctx, error):
  128. try:
  129. # Sadly this is the only part that makes a cog not modular. I have tried my best though to make it usable without the cog.
  130. try:
  131. with roxbot.db.db_session:
  132. is_custom_command = roxbot.db.db.exists('SELECT * FROM CCCommands WHERE name = "{}" AND type IN (1, 2) AND guild_id = {}'.format(ctx.invoked_with, ctx.guild.id))
  133. except OperationalError:
  134. # Table doesn't exist
  135. is_custom_command = False
  136. is_emoticon_face = bool(any(x in string.punctuation for x in ctx.message.content.strip(ctx.prefix)[0]))
  137. is_too_short = bool(len(ctx.message.content) <= 2)
  138. if is_emoticon_face:
  139. return None
  140. if is_custom_command or is_too_short:
  141. return None
  142. else:
  143. return error.args[0]
  144. except AttributeError:
  145. # AttributeError if a command invoked via DM
  146. return error.args[0]
  147. def command_cooldown_output(self, error):
  148. try:
  149. return self.COMMANDONCOOLDOWN.format(error.retry_after)
  150. except AttributeError:
  151. return ""
  152. @staticmethod
  153. def role_case_check(error):
  154. if "Role" in error.args[0]:
  155. out = error.args[0]
  156. out += " Roles are case-sensitive, please make sure it was typed correctly."
  157. return out
  158. else:
  159. return error.args[0]
  160. @commands.Cog.listener()
  161. async def on_command_error(self, ctx, error):
  162. if self.dev:
  163. raise error
  164. else:
  165. user_error_cases = {
  166. commands.MissingRequiredArgument: error.args[0],
  167. commands.BadArgument: self.role_case_check(error),
  168. commands.TooManyArguments: self.TOOMANYARGS,
  169. roxbot.UserError: error.args[0],
  170. }
  171. cases = {
  172. commands.NoPrivateMessage: self.NODMS,
  173. commands.DisabledCommand: self.DISABLEDCOMMAND,
  174. roxbot.CogSettingDisabled: self.COGSETTINGDISABLED.format(error.args[0]),
  175. commands.CommandNotFound: self.command_not_found_check(ctx, error),
  176. commands.BotMissingPermissions: "{}".format(error.args[0].replace("Bot", "Roxbot")),
  177. commands.MissingPermissions: "{}".format(error.args[0]),
  178. commands.CommandOnCooldown: self.command_cooldown_output(error),
  179. commands.CheckFailure: self.CHECKFAILURE,
  180. commands.NotOwner: self.CHECKFAILURE,
  181. }
  182. user_error_case = user_error_cases.get(type(error), None)
  183. case = cases.get(type(error), None)
  184. # ActualErrorHandling
  185. embed = discord.Embed(colour=roxbot.EmbedColours.red)
  186. if case:
  187. embed.description = case + "\n\n*If you are having trouble, don't be afraid to use* `{}help`".format(ctx.prefix)
  188. elif user_error_case:
  189. embed.description = user_error_case
  190. embed.colour = roxbot.EmbedColours.orange
  191. embed.description += "\n\n*If you are having trouble, don't be afraid to use* `{0}help` *or* `{0}help {1}` *if you need help with this certain command.*".format(ctx.prefix, ctx.invoked_with)
  192. elif isinstance(error, commands.CommandInvokeError):
  193. # YOUTUBE_DL ERROR HANDLING
  194. if isinstance(error.original, youtube_dl.utils.GeoRestrictedError):
  195. embed.description = self.YTDLDOWNLOADERROR.format("Video is GeoRestricted.")
  196. elif isinstance(error.original, youtube_dl.utils.DownloadError):
  197. embed.description = self.YTDLDOWNLOADERROR.format(error.original.exc_info[1])
  198. # Final catches for errors undocumented.
  199. else:
  200. roxbot.logger.error(str(error))
  201. embed = discord.Embed(title='Command Error', colour=roxbot.EmbedColours.dark_red)
  202. embed.description = str(error)
  203. embed.add_field(name='User', value=ctx.author)
  204. embed.add_field(name='Message', value=ctx.message.content)
  205. embed.timestamp = datetime.datetime.utcnow()
  206. elif isinstance(error, commands.CommandError) and not bool(case is None and user_error_case is None):
  207. embed.description = "Error: {}".format(error.args[0])
  208. roxbot.logger.error(embed.description)
  209. else:
  210. roxbot.logger.error(str(error))
  211. if embed.description:
  212. embed.colour = roxbot.EmbedColours.dark_red
  213. await ctx.send(embed=embed)
  214. #############
  215. # Logging #
  216. #############
  217. @staticmethod
  218. async def cleanup_logging_settings(channel):
  219. """Cleans up settings on removal of stored IDs."""
  220. with db_session:
  221. settings = LoggingSingle.get(guild_id=channel.guild.id)
  222. if settings.logging_channel_id == channel.id:
  223. settings.logging_channel_id = None
  224. async def log_member_join(self, member):
  225. with db_session:
  226. settings = LoggingSingle.get(guild_id=member.guild.id)
  227. if settings.enabled:
  228. channel = member.guild.get_channel(settings.logging_channel_id)
  229. embed = discord.Embed(title="{} joined the server".format(member), colour=roxbot.EmbedColours.pink)
  230. embed.add_field(name="ID", value=member.id)
  231. embed.add_field(name="Mention", value=member.mention)
  232. embed.add_field(name="Date Account Created", value=roxbot.datetime.format(member.created_at))
  233. embed.add_field(name="Date Joined", value=roxbot.datetime.format(member.joined_at))
  234. embed.set_thumbnail(url=member.avatar_url)
  235. try:
  236. return await channel.send(embed=embed)
  237. except AttributeError:
  238. pass
  239. async def log_member_remove(self, member):
  240. # TODO: Add some way of detecting whether a user left/was kicked or was banned.
  241. if member == self.bot.user:
  242. return
  243. with db_session:
  244. settings = LoggingSingle.get(guild_id=member.guild.id)
  245. if settings.enabled:
  246. channel = member.guild.get_channel(settings.logging_channel_id)
  247. embed = discord.Embed(description="{} left the server".format(member), colour=roxbot.EmbedColours.pink)
  248. try:
  249. return await channel.send(embed=embed)
  250. except AttributeError:
  251. pass
  252. @commands.has_permissions(manage_channels=True)
  253. @commands.guild_only()
  254. @commands.command(aliases=["log"])
  255. async def logging(self, ctx, setting, *, channel: typing.Optional[discord.TextChannel] = None):
  256. """Edits the logging settings.
  257. Options:
  258. enable/disable: Enable/disables logging.
  259. channel: sets the channel.
  260. """
  261. setting = setting.lower()
  262. with db_session:
  263. settings = LoggingSingle.get(guild_id=ctx.guild.id)
  264. if setting == "enable":
  265. settings.enabled = 1
  266. return await ctx.send("'logging' was enabled!")
  267. elif setting == "disable":
  268. settings.enabled = 0
  269. return await ctx.send("'logging' was disabled :cry:")
  270. elif setting == "channel":
  271. if not channel:
  272. channel = ctx.channel
  273. settings.enabled = channel.id
  274. return await ctx.send("{} has been set as the logging channel!".format(channel.mention))
  275. else:
  276. return await ctx.send("No valid option given.")
  277. #############
  278. # Backups #
  279. #############
  280. async def auto_backups(self):
  281. await self.bot.wait_until_ready()
  282. while not self.bot.is_closed():
  283. time = datetime.datetime.now()
  284. filename = "{}/roxbot/settings/backups/{:%Y.%m.%d %H:%M:%S} Auto Backup.sql".format(os.getcwd(), time)
  285. con = sqlite3.connect(os.getcwd() + "/roxbot/settings/db.sqlite")
  286. with open(filename, 'w') as f:
  287. for line in con.iterdump():
  288. f.write('%s\n' % line)
  289. con.close()
  290. await asyncio.sleep(roxbot.backup_rate)
  291. @commands.command(enabled=roxbot.backup_enabled)
  292. @commands.is_owner()
  293. async def backup(self, ctx):
  294. """Creates a backup of all server's settings manually. This will make a folder in `settings/backups/`.
  295. The name of the folder will be outputted when you use the command.
  296. Using only this and not the automatic backups is not recommend.
  297. """
  298. time = datetime.datetime.now()
  299. filename = "{}/roxbot/settings/backups/{:%Y.%m.%d %H:%M:%S} Auto Backup.sql".format(os.getcwd(), time)
  300. con = sqlite3.connect(os.getcwd() + "/roxbot/settings/db.sqlite")
  301. with open(filename, 'w') as f:
  302. for line in con.iterdump():
  303. f.write('%s\n' % line)
  304. con.close()
  305. return await ctx.send("Settings file backed up as a folder named '{}".format(filename.split("/")[-1]))
  306. ############################
  307. # Bot Managment Commands #
  308. ############################
  309. @commands.command()
  310. @commands.is_owner()
  311. async def blacklist(self, ctx, option, users: commands.Greedy[discord.User]):
  312. """ Manage the global blacklist for Roxbot.
  313. Options:
  314. - `option` - This is whether to add or subtract users from the blacklist. `+` or `add` for add and `-` or `remove` for remove.
  315. - `users` - A name, ID, or mention of a user. This allows multiple users to be mentioned.
  316. Examples:
  317. # Add three users to the blacklist
  318. ;blacklist add @ProblemUser1 ProblemUser2#4742 1239274620373
  319. # Remove one user from the blacklist
  320. ;blacklist - @GoodUser
  321. """
  322. blacklist_amount = 0
  323. if option not in ['+', '-', 'add', 'remove']:
  324. raise commands.BadArgument("Invalid option.")
  325. for user in users:
  326. if user.id == roxbot.owner:
  327. await ctx.send("The owner cannot be blacklisted.")
  328. users.remove(user)
  329. with db_session:
  330. if option in ['+', 'add']:
  331. for user in users:
  332. try:
  333. Blacklist(user_id=user.id)
  334. blacklist_amount += 1
  335. except TransactionIntegrityError:
  336. await ctx.send("{} is already in the blacklist.".format(user))
  337. return await ctx.send('{} user(s) have been added to the blacklist'.format(blacklist_amount))
  338. elif option in ['-', 'remove']:
  339. for user in users:
  340. u = Blacklist.get(user_id=user.id)
  341. if u:
  342. u.delete()
  343. blacklist_amount += 1
  344. else:
  345. await ctx.send("{} isn't in the blacklist".format(user))
  346. return await ctx.send('{} user(s) have been removed from the blacklist'.format(blacklist_amount))
  347. @commands.command(aliases=["setavatar"])
  348. @commands.is_owner()
  349. async def changeavatar(self, ctx, url=None):
  350. """
  351. Changes the avatar of the bot account. This cannot be a gif due to Discord limitations.
  352. Options:
  353. - `image` - This can either be uploaded as an attachment or linked after the command.
  354. Example:
  355. # Change avatar to linked image
  356. ;changeavatar https://i.imgur.com/yhRVl9e.png
  357. """
  358. avaimg = 'avaimg'
  359. if ctx.message.attachments:
  360. await ctx.message.attachments[0].save(avaimg)
  361. else:
  362. url = url.strip('<>')
  363. await roxbot.http.download_file(url, avaimg)
  364. with open(avaimg, 'rb') as f:
  365. await self.bot.user.edit(avatar=f.read())
  366. os.remove(avaimg)
  367. await asyncio.sleep(2)
  368. return await ctx.send(":ok_hand:")
  369. @commands.command(aliases=["nick", "nickname"])
  370. @commands.is_owner()
  371. @commands.guild_only()
  372. @commands.bot_has_permissions(change_nickname=True)
  373. async def changenickname(self, ctx, *, nick=None):
  374. """Changes the nickname of Roxbot in the guild this command is executed in.
  375. Options:
  376. - `name` - OPTIONAL: If not given, Roxbot's nickname will be reset.
  377. Example:
  378. # Make Roxbot's nickname "Best Bot 2k18"
  379. ;nick Best Bot 2k18
  380. # Reset Roxbot's nickname
  381. ;nick
  382. """
  383. await ctx.guild.me.edit(nick=nick, reason=";nick command invoked.")
  384. return await ctx.send(":thumbsup:")
  385. @commands.command(aliases=["activity"])
  386. @commands.is_owner()
  387. async def changeactivity(self, ctx, *, game: str):
  388. """Changes the activity that Roxbot is doing. This will be added as a game. "none" can be passed to remove an activity from Roxbot.
  389. Options:
  390. - `text` - Either text to be added as the "game" or none to remove the activity.
  391. Examples:
  392. # Change activity to "with the command line" so that it displays "Playing with the command line"
  393. ;activity "with the command line"
  394. # Stop displaying any activity
  395. ;activity none
  396. """
  397. if game.lower() == "none":
  398. game = None
  399. else:
  400. game = discord.Game(game)
  401. await self.bot.change_presence(activity=game)
  402. return await ctx.send(":ok_hand: Activity set to {}".format(str(game)))
  403. @commands.command(aliases=["status"])
  404. @commands.is_owner()
  405. async def changestatus(self, ctx, status: str):
  406. """Changes the status of the bot account.
  407. Options:
  408. - `status` - There are four different options to choose. `online`, `away`, `dnd` (do not disturb), and `offline`
  409. Examples:
  410. # Set Roxbot to offline
  411. ;changestatus offline
  412. # Set Roxbot to online
  413. ;changestatus online
  414. """
  415. status = status.lower()
  416. if status == 'offline' or status == 'invisible':
  417. discord_status = discord.Status.invisible
  418. elif status == 'idle':
  419. discord_status = discord.Status.idle
  420. elif status == 'dnd':
  421. discord_status = discord.Status.dnd
  422. else:
  423. discord_status = discord.Status.online
  424. await self.bot.change_presence(status=discord_status)
  425. await ctx.send("**:ok:** Status set to {}".format(discord_status))
  426. @commands.guild_only()
  427. @commands.command(aliases=["printsettingsraw"])
  428. @commands.has_permissions(manage_guild=True)
  429. async def printsettings(self, ctx, option=None):
  430. """Prints settings for the cogs in this guild.
  431. Options:
  432. - cog - OPTIONAL. If given, this will only show the setting of the cog given. This has to be the name the printsettings command gives.
  433. Examples:
  434. # Print the settings for the guild
  435. ;printsettings
  436. # print settings just for the Admin cog.
  437. ;printsettings Admin
  438. """
  439. if option:
  440. option = option.lower()
  441. entities = {}
  442. for name, cog in self.bot.cogs.items():
  443. try:
  444. entities[name.lower()] = cog.autogen_db
  445. except AttributeError:
  446. pass
  447. paginator = commands.Paginator(prefix="```py")
  448. paginator.add_line("{} settings for {}.\n".format(self.bot.user.name, ctx.message.guild.name))
  449. if option in entities:
  450. #raw = bool(ctx.invoked_with == "printsettingsraw")
  451. with db_session:
  452. settings = entities[option].get(guild_id=ctx.guild.id).to_dict()
  453. settings.pop("id")
  454. settings.pop("guild_id")
  455. paginator.add_line("@{}".format(option))
  456. paginator.add_line(str(settings))
  457. for page in paginator.pages:
  458. await ctx.send(page)
  459. else:
  460. with db_session:
  461. for name, entity in entities.items():
  462. settings = entity.get(guild_id=ctx.guild.id).to_dict()
  463. settings.pop("id")
  464. settings.pop("guild_id")
  465. #raw = bool(ctx.invoked_with == "printsettingsraw")
  466. paginator.add_line("@{}".format(name))
  467. paginator.add_line(str(settings))
  468. for page in paginator.pages:
  469. await ctx.send(page)
  470. @commands.command()
  471. @commands.is_owner()
  472. async def shutdown(self, ctx):
  473. """Shuts down the bot."""
  474. await ctx.send(":wave:")
  475. await self.bot.logout()
  476. @commands.command()
  477. async def invite(self, ctx):
  478. """Returns an invite link to invite the bot to your server."""
  479. link = discord.utils.oauth_url(self.bot.user.id, discord.Permissions(1983245558))
  480. return await ctx.send("Invite me to your server! <{}>\n\n Disclaimer: {} requests all permissions it requires to run all commands. Some of these can be disabled but some commands may lose functionality.".format(link, self.bot.user.name))
  481. @commands.command()
  482. @commands.is_owner()
  483. async def echo(self, ctx, channel: discord.TextChannel, *, message: str):
  484. """Echos the given string to a given channel.
  485. Example:
  486. # Post the message "Hello World" to the channel #general
  487. ;echo #general Hello World
  488. """
  489. await channel.send(message)
  490. return await ctx.send(":point_left:")
  491. def setup(bot_client):
  492. bot_client.add_cog(Core(bot_client))