From 21df9b0df51b5a6d63534b72947905d6e1ad9685 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 10 Nov 2017 22:58:01 +0100 Subject: [PATCH] updated after a while because local stuff --- General.py | 30 +- Porno.py | 47 +- Shitpost.py | 84 +- Stroopwafel.py | 10 +- discord.py-rewrite/.gitignore | 11 + discord.py-rewrite/LICENSE | 21 + discord.py-rewrite/MANIFEST.in | 4 + discord.py-rewrite/README.rst | 106 + discord.py-rewrite/discord/__init__.py | 61 + discord.py-rewrite/discord/__main__.py | 284 +++ discord.py-rewrite/discord/abc.py | 995 ++++++++ discord.py-rewrite/discord/audit_logs.py | 340 +++ discord.py-rewrite/discord/backoff.py | 85 + discord.py-rewrite/discord/calls.py | 156 ++ discord.py-rewrite/discord/channel.py | 930 ++++++++ discord.py-rewrite/discord/client.py | 1055 +++++++++ discord.py-rewrite/discord/colour.py | 222 ++ discord.py-rewrite/discord/compat.py | 140 ++ .../discord/context_managers.py | 72 + discord.py-rewrite/discord/embeds.py | 478 ++++ discord.py-rewrite/discord/emoji.py | 239 ++ discord.py-rewrite/discord/enums.py | 221 ++ discord.py-rewrite/discord/errors.py | 166 ++ .../discord/ext/commands/__init__.py | 19 + .../discord/ext/commands/bot.py | 995 ++++++++ .../discord/ext/commands/context.py | 227 ++ .../discord/ext/commands/converter.py | 481 ++++ .../discord/ext/commands/cooldowns.py | 130 ++ .../discord/ext/commands/core.py | 1275 ++++++++++ .../discord/ext/commands/errors.py | 180 ++ .../discord/ext/commands/formatter.py | 346 +++ .../discord/ext/commands/view.py | 167 ++ discord.py-rewrite/discord/file.py | 75 + discord.py-rewrite/discord/game.py | 87 + discord.py-rewrite/discord/gateway.py | 699 ++++++ discord.py-rewrite/discord/guild.py | 1254 ++++++++++ discord.py-rewrite/discord/http.py | 759 ++++++ discord.py-rewrite/discord/invite.py | 158 ++ discord.py-rewrite/discord/iterators.py | 484 ++++ discord.py-rewrite/discord/member.py | 559 +++++ discord.py-rewrite/discord/message.py | 742 ++++++ discord.py-rewrite/discord/mixins.py | 42 + discord.py-rewrite/discord/object.py | 69 + discord.py-rewrite/discord/opus.py | 278 +++ discord.py-rewrite/discord/permissions.py | 608 +++++ discord.py-rewrite/discord/player.py | 330 +++ discord.py-rewrite/discord/reaction.py | 163 ++ discord.py-rewrite/discord/relationship.py | 82 + discord.py-rewrite/discord/role.py | 284 +++ discord.py-rewrite/discord/shard.py | 346 +++ discord.py-rewrite/discord/state.py | 955 ++++++++ discord.py-rewrite/discord/user.py | 617 +++++ discord.py-rewrite/discord/utils.py | 294 +++ discord.py-rewrite/discord/voice_client.py | 426 ++++ discord.py-rewrite/discord/webhook.py | 659 ++++++ discord.py-rewrite/docs/Makefile | 183 ++ discord.py-rewrite/docs/_static/custom.js | 31 + discord.py-rewrite/docs/_static/style.css | 542 +++++ .../docs/_templates/layout.html | 29 + .../docs/_templates/relations.html | 1 + discord.py-rewrite/docs/api.rst | 2047 +++++++++++++++++ discord.py-rewrite/docs/conf.py | 314 +++ discord.py-rewrite/docs/discord.rst | 97 + discord.py-rewrite/docs/ext/commands/api.rst | 225 ++ .../docs/ext/commands/commands.rst | 587 +++++ .../docs/ext/commands/index.rst | 14 + discord.py-rewrite/docs/extensions/details.py | 55 + discord.py-rewrite/docs/faq.rst | 333 +++ discord.py-rewrite/docs/index.rst | 59 + discord.py-rewrite/docs/intro.rst | 112 + discord.py-rewrite/docs/logging.rst | 46 + discord.py-rewrite/docs/make.bat | 263 +++ discord.py-rewrite/docs/migrating.rst | 1071 +++++++++ .../docs/migrating_to_async.rst | 322 +++ discord.py-rewrite/docs/quickstart.rst | 76 + discord.py-rewrite/docs/whats_new.rst | 364 +++ .../examples/background_task.py | 28 + discord.py-rewrite/examples/basic_bot.py | 65 + discord.py-rewrite/examples/basic_voice.py | 132 ++ discord.py-rewrite/examples/deleted.py | 21 + discord.py-rewrite/examples/edits.py | 20 + discord.py-rewrite/examples/guessing_game.py | 36 + discord.py-rewrite/examples/new_member.py | 15 + discord.py-rewrite/examples/playlist.py | 246 ++ discord.py-rewrite/examples/reply.py | 19 + discord.py-rewrite/requirements.txt | 2 + discord.py-rewrite/setup.py | 68 + phfhu.py | 43 + pi/General.py | 27 + pi/Music.py | 240 ++ pi/Porno.py | 36 + pi/Shitpost.py | 72 + pi/Stroopwafel.py | 20 + porn/neko/download.py | 58 + porn/neko/imgs_NekoIRL/side-top.jpg | Bin 0 -> 28963 bytes 95 files changed, 27441 insertions(+), 25 deletions(-) create mode 100644 discord.py-rewrite/.gitignore create mode 100644 discord.py-rewrite/LICENSE create mode 100644 discord.py-rewrite/MANIFEST.in create mode 100644 discord.py-rewrite/README.rst create mode 100644 discord.py-rewrite/discord/__init__.py create mode 100644 discord.py-rewrite/discord/__main__.py create mode 100644 discord.py-rewrite/discord/abc.py create mode 100644 discord.py-rewrite/discord/audit_logs.py create mode 100644 discord.py-rewrite/discord/backoff.py create mode 100644 discord.py-rewrite/discord/calls.py create mode 100644 discord.py-rewrite/discord/channel.py create mode 100644 discord.py-rewrite/discord/client.py create mode 100644 discord.py-rewrite/discord/colour.py create mode 100644 discord.py-rewrite/discord/compat.py create mode 100644 discord.py-rewrite/discord/context_managers.py create mode 100644 discord.py-rewrite/discord/embeds.py create mode 100644 discord.py-rewrite/discord/emoji.py create mode 100644 discord.py-rewrite/discord/enums.py create mode 100644 discord.py-rewrite/discord/errors.py create mode 100644 discord.py-rewrite/discord/ext/commands/__init__.py create mode 100644 discord.py-rewrite/discord/ext/commands/bot.py create mode 100644 discord.py-rewrite/discord/ext/commands/context.py create mode 100644 discord.py-rewrite/discord/ext/commands/converter.py create mode 100644 discord.py-rewrite/discord/ext/commands/cooldowns.py create mode 100644 discord.py-rewrite/discord/ext/commands/core.py create mode 100644 discord.py-rewrite/discord/ext/commands/errors.py create mode 100644 discord.py-rewrite/discord/ext/commands/formatter.py create mode 100644 discord.py-rewrite/discord/ext/commands/view.py create mode 100644 discord.py-rewrite/discord/file.py create mode 100644 discord.py-rewrite/discord/game.py create mode 100644 discord.py-rewrite/discord/gateway.py create mode 100644 discord.py-rewrite/discord/guild.py create mode 100644 discord.py-rewrite/discord/http.py create mode 100644 discord.py-rewrite/discord/invite.py create mode 100644 discord.py-rewrite/discord/iterators.py create mode 100644 discord.py-rewrite/discord/member.py create mode 100644 discord.py-rewrite/discord/message.py create mode 100644 discord.py-rewrite/discord/mixins.py create mode 100644 discord.py-rewrite/discord/object.py create mode 100644 discord.py-rewrite/discord/opus.py create mode 100644 discord.py-rewrite/discord/permissions.py create mode 100644 discord.py-rewrite/discord/player.py create mode 100644 discord.py-rewrite/discord/reaction.py create mode 100644 discord.py-rewrite/discord/relationship.py create mode 100644 discord.py-rewrite/discord/role.py create mode 100644 discord.py-rewrite/discord/shard.py create mode 100644 discord.py-rewrite/discord/state.py create mode 100644 discord.py-rewrite/discord/user.py create mode 100644 discord.py-rewrite/discord/utils.py create mode 100644 discord.py-rewrite/discord/voice_client.py create mode 100644 discord.py-rewrite/discord/webhook.py create mode 100644 discord.py-rewrite/docs/Makefile create mode 100644 discord.py-rewrite/docs/_static/custom.js create mode 100644 discord.py-rewrite/docs/_static/style.css create mode 100644 discord.py-rewrite/docs/_templates/layout.html create mode 100644 discord.py-rewrite/docs/_templates/relations.html create mode 100644 discord.py-rewrite/docs/api.rst create mode 100644 discord.py-rewrite/docs/conf.py create mode 100644 discord.py-rewrite/docs/discord.rst create mode 100644 discord.py-rewrite/docs/ext/commands/api.rst create mode 100644 discord.py-rewrite/docs/ext/commands/commands.rst create mode 100644 discord.py-rewrite/docs/ext/commands/index.rst create mode 100644 discord.py-rewrite/docs/extensions/details.py create mode 100644 discord.py-rewrite/docs/faq.rst create mode 100644 discord.py-rewrite/docs/index.rst create mode 100644 discord.py-rewrite/docs/intro.rst create mode 100644 discord.py-rewrite/docs/logging.rst create mode 100644 discord.py-rewrite/docs/make.bat create mode 100644 discord.py-rewrite/docs/migrating.rst create mode 100644 discord.py-rewrite/docs/migrating_to_async.rst create mode 100644 discord.py-rewrite/docs/quickstart.rst create mode 100644 discord.py-rewrite/docs/whats_new.rst create mode 100644 discord.py-rewrite/examples/background_task.py create mode 100644 discord.py-rewrite/examples/basic_bot.py create mode 100644 discord.py-rewrite/examples/basic_voice.py create mode 100644 discord.py-rewrite/examples/deleted.py create mode 100644 discord.py-rewrite/examples/edits.py create mode 100644 discord.py-rewrite/examples/guessing_game.py create mode 100644 discord.py-rewrite/examples/new_member.py create mode 100644 discord.py-rewrite/examples/playlist.py create mode 100644 discord.py-rewrite/examples/reply.py create mode 100644 discord.py-rewrite/requirements.txt create mode 100644 discord.py-rewrite/setup.py create mode 100644 phfhu.py create mode 100644 pi/General.py create mode 100644 pi/Music.py create mode 100644 pi/Porno.py create mode 100644 pi/Shitpost.py create mode 100644 pi/Stroopwafel.py create mode 100644 porn/neko/download.py create mode 100644 porn/neko/imgs_NekoIRL/side-top.jpg diff --git a/General.py b/General.py index 1c3c90a..fb966a4 100644 --- a/General.py +++ b/General.py @@ -1,5 +1,6 @@ import asyncio import discord +import sys, os from discord.ext import commands from sympy import latex, symbols, preview, Symbol #for LaTeX images @@ -11,17 +12,30 @@ class General(object): self.voice_states = {} def __check(self, ctx): #Todo: Is dit het porno kanaal? return True - @commands.command(pass_context=True) + @commands.command() @asyncio.coroutine - def tex(self, ctx, mathstring): + def tex(self, ctx, *, arg): """Geeft een mooi LaTeX plaatje terug. - Encapsuleer uw string in mooie \" Bijvoorbeeld: - !tex "a^b b^c \rightarrow d" + !tex Je moeder is lelijk! $a^b b^c \\rightarrow d$ """ - preamble = "\\documentclass[10pt]{article}\\pagestyle{empty}\\usepackage[margin=1in]{geometry}\\usepackage[T1]{fontenc}\\usepackage{CJKutf8}\\usepackage[english]{babel}\\usepackage{amsmath, amsfonts}\\begin{document}" + preamble = "\\documentclass[10pt]{article}\\pagestyle{empty}\\usepackage[margin=1in]{geometry}\\usepackage{amsmath, amsfonts}\\begin{document}" #\\usepackage{CJKutf8}\\usepackage[T1]{fontenc}\\usepackage[english]{babel} options = ["-T", "tight", "-z", "9", "--truecolor", "-D", "512"] - preview(r'$$'+mathstring+'$$', viewer='file', filename='test.png', dvioptions=options, preamble=preamble) - file = open('test.png', 'rb') - yield from self.bot.upload(file, filename ='test.png', content='Nerd!') + preview(r''+arg+'', viewer='file', filename='test.png', dvioptions=options, preamble=preamble) + #file = open('test.png', 'rb') + #yield from ctx.channel.send(file, filename ='test.png', content='') + try: + yield from ctx.message.delete() + except: + print("No permission to delete message") + yield from ctx.channel.send(ctx.author.mention, file=discord.File('test.png', 'tex.png')) + @commands.command(pass_context=True, hidden=True) + @asyncio.coroutine + def reboot(self, ctx, password=""): + """ Reboot.. """ + if password == "please": + yield from ctx.channel.send("Rebooting.. brb!") + os.execv(sys.executable, ['python3'] + sys.argv) + else: + yield from ctx.channel.send(ctx.author.mention + " What's the magic word?") diff --git a/Porno.py b/Porno.py index 614e85c..1df9280 100644 --- a/Porno.py +++ b/Porno.py @@ -2,6 +2,7 @@ import asyncio import discord from discord.ext import commands import os +import praw from random import randint class Porno(object): @@ -11,6 +12,12 @@ class Porno(object): self.bot = bot self.voice_states = {} self.fileIndex = {} + self.r = praw.Reddit( + user_agent='StroopwafelBot', + client_secret='Z4K17T8TC26cNAgDSRxCkE9jYR4', + client_id='aWeyKpzdHMtmJQ' + ) + self.r.read_only = True def __check(self, ctx): #Todo: Is dit het porno kanaal? return True @@ -29,8 +36,38 @@ class Porno(object): @commands.command(pass_context=True) @asyncio.coroutine - def ancilla(self, ctx): - """ :-) """ - filepath = self.randomJpgFromFolder('ancilla'); - file = open(filepath, 'rb') - yield from self.bot.upload(file, filename = filepath, content='Alsjeblieft!') \ No newline at end of file + def nsfw(self, ctx): + """ random post uit een nsfw subreddit, ... """ + print(type(ctx.message.channel)) + try: + if(not ctx.message.channel.is_nsfw()): + yield from ctx.channel.send("Alleen toegestaan in een NSFW kanaal") + return + except: + print("borf") + randnsfw = self.r.subreddit('randnsfw') + post = randnsfw.random() + try: + yield from ctx.message.delete() + except: + print("No permission to delete message") + yield from ctx.channel.send(ctx.author.mention + " Uit r/"+ randnsfw.display_name +": " + post.url) + + @commands.command(pass_context=True) + @asyncio.coroutine + def gay(self, ctx): + """ Gay & NSFW. Wat wilt een mens nog meer? """ + print(type(ctx.message.channel)) + try: + if(not ctx.message.channel.is_nsfw()): + yield from ctx.channel.send("Alleen toegestaan in een NSFW kanaal") + return + except: + print("borf") + gaynsfw = self.r.subreddit('autofellatio+Balls+baramanga+BHMGoneWild+bigbrotherbros+Bisexy+blackballed+blackcock+blackmale+Boners+BonersInPublic+broslikeus+Bulge+Bulges+CelebrityPenis+chesthairporn+ChubbySilverDaddies+circumcisions+cock+CockOutline+CubsGoneWild+CumCannonAddicts+CuteGuyButts+daddybears+DickSlips+FMN+foreskin+gayasshole+gaybears+gaybrosgonemild+GaybrosGoneWild+GayChubs+gaycumsluts+GayDaddiesPics+GayGifs+gayholdthemoan+gayincest+GayKink+gaymersgonewild+GayMuscleWorship+gaynsfw+gayotters+gayporn+gaypornhunters+gaypornornotgayporn+gaypornwithplot+GaySnapchatImages+gaystoriesgonewild+gaystrugglefuck+gayvideos+GayWatersports+gfur+gingerdudes+grower+guyskissing+HeSquats+Homosexual+hotguyswithtattoos+insanelyhairymen+jockstraps+jockwatch+ladybonersgw+leathermen+LegionairresGoneWild+LGBTGoneWild+LGBTreesGoneWild+lovegaymale+MaleArmpits+malemodels+malepornstars+malespandex+MaleUnderwear+manass+mangonewild+manlove+ManSex+MassiveCock+MensHighJinx+menslegs+MrBlowJob+nocategory+nopullingout+NSFW_GAY+NSFW_gay_porn+OhCumOn+penis+POCLadyBoners+PublicBoys+RedditorCum+SceneBoys+selfservice+Singlets+softies+speedos+TotallyStraight+TwinkLove+twinks+UniformedMen+VintageGayPics') + post = gaynsfw.random() + try: + yield from ctx.message.delete() + except: + print("No permission to delete message") + yield from ctx.channel.send(ctx.author.mention + " " + post.url) diff --git a/Shitpost.py b/Shitpost.py index b3096e2..c8321b8 100644 --- a/Shitpost.py +++ b/Shitpost.py @@ -15,13 +15,39 @@ class Shitpost(object): self.voice_states = {} def __check(self, ctx): #Todo: Is dit het shitpost kanaal? return True + + @commands.command(pass_context=True, hidden=True) + @asyncio.coroutine + def mark(self, ctx, boven: str, onder: str): + """Maakt een mark meem. + Bijvoorbeeld: + !mark \"je moeder\" \"is een hoer\" + """ + params = dict( + template_id = '112898841', #de marku + username = 'Stroopwafel', + password = 'ruyter118', + text0 = boven, + text1 = onder + ) + #self.bot.say("deed ik het ding?") + resp = requests.get(url='https://api.imgflip.com/caption_image', params=params) + #todo check for success + photourl = resp.json()['data']['url'] + em = discord.Embed(title='De Mark heeft gesproken.', type='photo') + em.set_image(url=photourl) + try: + yield from ctx.message.delete() + except: + print("No permission to delete message") + yield from ctx.channel.send(ctx.author.mention, embed=em) - @commands.command(pass_context=True) + @commands.command(pass_context=True, hidden=True) @asyncio.coroutine def eriku(self, ctx, boven: str, onder: str): """Maakt een erik meem. Bijvoorbeeld: - @Stroopwafel eriku \"je moeder\" \"is een hoer\" + !eriku \"je moeder\" \"is een hoer\" """ params = dict( template_id = '93296473', #de eriku @@ -30,18 +56,50 @@ class Shitpost(object): text0 = boven, text1 = onder ) - self.bot.say("deed ik het ding?") + #self.bot.say("deed ik het ding?") resp = requests.get(url='https://api.imgflip.com/caption_image', params=params) #todo check for success photourl = resp.json()['data']['url'] em = discord.Embed(title='De Eriku heeft gesproken. Prijs hem!', type='photo') em.set_image(url=photourl) - yield from self.bot.say(embed=em) + try: + yield from ctx.message.delete() + except: + print("No permission to delete message") + yield from ctx.channel.send(ctx.author.mention, embed=em) + + + @commands.command(pass_context=True, hidden=True) + @asyncio.coroutine + def zout(self, ctx, boven: str, onder: str): + """Maakt een martijn-zout meem. + Bijvoorbeeld: + !zout \"je moeder\" \"is een hoer\" + """ + params = dict( + template_id = '116231806', #de martijn + username = 'Stroopwafel', + password = 'ruyter118', + text0 = boven, + text1 = onder + ) + #self.bot.say("deed ik het ding?") + resp = requests.get(url='https://api.imgflip.com/caption_image', params=params) + #todo check for success + photourl = resp.json()['data']['url'] + em = discord.Embed(title='De Martijn heeft gesproken.', type='photo') + em.set_image(url=photourl) + try: + yield from ctx.message.delete() + except: + print("No permission to delete message") + yield from ctx.channel.send(ctx.author.mention, embed=em) @commands.command(pass_context=True, hidden=True) @asyncio.coroutine def nihao(self,ctx): - yield from self.bot.say("Kankerlauw") + #yield from self.bot.say("Kankerlauw") + yield from ctx.channel.send("kankerlauw") @commands.command(pass_context=True) @asyncio.coroutine @@ -49,7 +107,7 @@ class Shitpost(object): """Vertelt je hoe laat het is.. Misschien geeft het ook wel een leuke meem terug. Wie weet. - Probeer het eens op verschillende tijdstippen + Probeer het eens op verschillende tijdstippen. """ time=datetime.now().strftime('%H%M') @@ -64,9 +122,15 @@ class Shitpost(object): filepath = filejpg elif os.path.isfile(filepng): filepath = filepng - + try: + yield from ctx.message.delete() + except: + print("No permission to delete message") if filepath is not '': - file = open(filepath, 'rb') - yield from self.bot.upload(file, filename = filepath) + #file = open(filepath, 'rb') + #yield from self.bot.upload(file, filename = filepath) + yield from ctx.channel.send(ctx.author.mention + ' Het is nu ' + datetime.now().strftime('%H:%M'), file=discord.File(filepath, 'tijd.png')) else: - yield from self.bot.say('Het is nu ' + datetime.now().strftime('%H:%M')) \ No newline at end of file + yield from ctx.channel.send(ctx.author.mention + ' Het is nu ' + datetime.now().strftime('%H:%M')) +def setup(bot): + bot.add_cog(Shitpost(bot)) \ No newline at end of file diff --git a/Stroopwafel.py b/Stroopwafel.py index 33c9622..3878f6d 100644 --- a/Stroopwafel.py +++ b/Stroopwafel.py @@ -1,14 +1,20 @@ import asyncio import discord -import Music +#import Music import Porno import Shitpost import General +import logging +import importlib + from discord.ext import commands +logging.basicConfig(level=logging.INFO) + bot = commands.Bot(command_prefix=commands.when_mentioned_or('!'), description='Stroopwafel. Shitpost bot extraordinaire.', pm_help=True) -bot.add_cog(Music.Music(bot)) +#bot.add_cog(Music.Music(bot)) bot.add_cog(Shitpost.Shitpost(bot)) +#importlib.import_module('Shitpost') bot.add_cog(Porno.Porno(bot)) bot.add_cog(General.General(bot)) diff --git a/discord.py-rewrite/.gitignore b/discord.py-rewrite/.gitignore new file mode 100644 index 0000000..ed880af --- /dev/null +++ b/discord.py-rewrite/.gitignore @@ -0,0 +1,11 @@ +*.json +*.pyc +*.log +docs/_build +*.buildinfo +*.mp3 +*.m4a +*.wav +*.png +*.jpg +*.flac diff --git a/discord.py-rewrite/LICENSE b/discord.py-rewrite/LICENSE new file mode 100644 index 0000000..f7befea --- /dev/null +++ b/discord.py-rewrite/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/discord.py-rewrite/MANIFEST.in b/discord.py-rewrite/MANIFEST.in new file mode 100644 index 0000000..4857737 --- /dev/null +++ b/discord.py-rewrite/MANIFEST.in @@ -0,0 +1,4 @@ +include README.md +include LICENSE +include requirements.txt +include discord/bin/*.dll diff --git a/discord.py-rewrite/README.rst b/discord.py-rewrite/README.rst new file mode 100644 index 0000000..825a9aa --- /dev/null +++ b/discord.py-rewrite/README.rst @@ -0,0 +1,106 @@ +discord.py +========== + +.. image:: https://img.shields.io/pypi/v/discord.py.svg + :target: https://pypi.python.org/pypi/discord.py +.. image:: https://img.shields.io/pypi/pyversions/discord.py.svg + :target: https://pypi.python.org/pypi/discord.py + +discord.py is an API wrapper for Discord written in Python. + +This was written to allow easier writing of bots or chat logs. Make sure to familiarise yourself with the API using the `documentation `__. + +Breaking Changes +--------------- + +The discord API is constantly changing and the wrapper API is as well. There will be no effort to keep backwards compatibility in versions before ``v1.0.0``. + +I recommend that you follow the discussion in the `unofficial Discord API discord channel `__ and update your installation periodically. I will attempt to make note of breaking changes in the API channel so make sure to subscribe to library news by typing ``?sub news`` in the channel. + +Installing +---------- + +To install the library without full voice support, you can just run the following command: + +.. code:: sh + + python3 -m pip install -U discord.py + +Otherwise to get voice support you should run the following command: + +.. code:: sh + + python3 -m pip install -U discord.py[voice] + + +To install the development version, do the following: + +.. code:: sh + + python3 -m pip install -U https://github.com/Rapptz/discord.py/archive/master.zip#egg=discord.py[voice] + +or the more long winded from cloned source: + +.. code:: sh + + $ git clone https://github.com/Rapptz/discord.py + $ cd discord.py + $ python3 -m pip install -U .[voice] + +Please note that on Linux installing voice you must install the following packages via your favourite package manager (e.g. ``apt``, ``yum``, etc) before running the above command: + +* libffi-dev (or ``libffi-devel`` on some systems) +* python-dev (e.g. ``python3.5-dev`` for Python 3.5) + +Quick Example +------------ + +.. code:: py + + import discord + import asyncio + + class MyClient(discord.Client): + async def on_ready(self): + print('Logged in as') + print(self.user.name) + print(self.user.id) + print('------') + + async def on_message(self, message): + # don't respond to ourselves + if message.author == self.user: + return + if message.content.startswith('!test'): + counter = 0 + tmp = await message.channel.send('Calculating messages...') + async for msg in message.channel.history(limit=100): + if msg.author == message.author: + counter += 1 + + await tmp.edit(content='You have {} messages.'.format(counter)) + elif message.content.startswith('!sleep'): + with message.channel.typing(): + await asyncio.sleep(5.0) + await message.channel.send('Done sleeping.') + + client = MyClient() + client.run('token') + +Note that in Python 3.4 you use ``@asyncio.coroutine`` instead of ``async def`` and ``yield from`` instead of ``await``. + +You can find examples in the examples directory. + +Requirements +------------ + +* Python 3.4.2+ +* ``aiohttp`` library +* ``websockets`` library +* ``PyNaCl`` library (optional, for voice only) + + - On Linux systems this requires the ``libffi`` library. You can install in + debian based systems by doing ``sudo apt-get install libffi-dev``. + +Usually ``pip`` will handle these for you. + diff --git a/discord.py-rewrite/discord/__init__.py b/discord.py-rewrite/discord/__init__.py new file mode 100644 index 0000000..7973cd4 --- /dev/null +++ b/discord.py-rewrite/discord/__init__.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- + +""" +Discord API Wrapper +~~~~~~~~~~~~~~~~~~~ + +A basic wrapper for the Discord API. + +:copyright: (c) 2015-2017 Rapptz +:license: MIT, see LICENSE for more details. + +""" + +__title__ = 'discord' +__author__ = 'Rapptz' +__license__ = 'MIT' +__copyright__ = 'Copyright 2015-2017 Rapptz' +__version__ = '1.0.0a' + +from .client import Client, AppInfo +from .user import User, ClientUser, Profile +from .game import Game +from .emoji import Emoji, PartialReactionEmoji +from .channel import * +from .guild import Guild +from .relationship import Relationship +from .member import Member, VoiceState +from .message import Message, Attachment +from .errors import * +from .calls import CallMessage, GroupCall +from .permissions import Permissions, PermissionOverwrite +from .role import Role +from .file import File +from .colour import Color, Colour +from .invite import Invite +from .object import Object +from .reaction import Reaction +from . import utils, opus, compat, abc +from .enums import * +from collections import namedtuple +from .embeds import Embed +from .shard import AutoShardedClient +from .player import * +from .webhook import * +from .voice_client import VoiceClient +from .audit_logs import AuditLogChanges, AuditLogEntry, AuditLogDiff + +import logging + +VersionInfo = namedtuple('VersionInfo', 'major minor micro releaselevel serial') + +version_info = VersionInfo(major=1, minor=0, micro=0, releaselevel='alpha', serial=0) + +try: + from logging import NullHandler +except ImportError: + class NullHandler(logging.Handler): + def emit(self, record): + pass + +logging.getLogger(__name__).addHandler(NullHandler()) diff --git a/discord.py-rewrite/discord/__main__.py b/discord.py-rewrite/discord/__main__.py new file mode 100644 index 0000000..d40d404 --- /dev/null +++ b/discord.py-rewrite/discord/__main__.py @@ -0,0 +1,284 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import discord +import argparse +import sys +from pathlib import Path +import os +import re + +def core(parser, args): + pass + +bot_template = """#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from discord.ext import commands +import discord +import config + +class Bot(commands.{base}): + def __init__(self, **kwargs): + super().__init__(command_prefix=commands.when_mentioned_or('{prefix}'), **kwargs) + for cog in config.cogs: + try: + self.load_extension(cog) + except Exception as e: + print('Could not load extension {{0}} due to {{1.__class__.__name__}}: {{1}}'.format(cog, e)) + + async def on_ready(self): + print('Logged on as {{0}} (ID: {{0.id}})'.format(self.user)) + + +bot = Bot() + +# write general commands here + +bot.run(config.token) +""" + +gitignore_template = """# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# Our configuration files +config.py +""" + +cog_template = '''# -*- coding: utf-8 -*- + +from discord.ext import commands +import discord + +class {name}: + """The description for {name} goes here.""" + + def __init__(self, bot): + self.bot = bot +{extra} +def setup(bot): + bot.add_cog({name}(bot)) +''' + +cog_extras = ''' + def __unload(self): + # clean up logic goes here + pass + + async def __local_check(self, ctx): + # checks that apply to every command in here + return True + + async def __global_check(self, ctx): + # checks that apply to every command to the bot + return True + + async def __global_check_once(self, ctx): + # check that apply to every command but is guaranteed to be called only once + return True + + async def __error(self, ctx, error): + # error handling to every command in here + pass + + async def __before_invoke(self, ctx): + # called before a command is called here + pass + + async def __after_invoke(self, ctx): + # called after a command is called here + pass + +''' + + +# certain file names and directory names are forbidden +# see: https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247%28v=vs.85%29.aspx +# although some of this doesn't apply to Linux, we might as well be consistent +_base_table = { + '<': '-', + '>': '-', + ':': '-', + '"': '-', + # '/': '-', these are fine + # '\\': '-', + '|': '-', + '?': '-', + '*': '-', +} + +# +_base_table.update((chr(i), None) for i in range(32)) + +translation_table = str.maketrans(_base_table) + +def to_path(parser, name, *, replace_spaces=False): + if isinstance(name, Path): + return name + + if sys.platform == 'win32': + forbidden = ('CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', \ + 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9') + if len(name) <= 4 and name.upper() in forbidden: + parser.error('invalid directory name given, use a different one') + + name = name.translate(translation_table) + if replace_spaces: + name = name.replace(' ', '-') + return Path(name) + +def newbot(parser, args): + if sys.version_info < (3, 5): + parser.error('python version is older than 3.5, consider upgrading.') + + new_directory = to_path(parser, args.directory) / to_path(parser, args.name) + + # as a note exist_ok for Path is a 3.5+ only feature + # since we already checked above that we're >3.5 + try: + new_directory.mkdir(exist_ok=True, parents=True) + except OSError as e: + parser.error('could not create our bot directory ({})'.format(e)) + + cogs = new_directory / 'cogs' + + try: + cogs.mkdir(exist_ok=True) + init = cogs / '__init__.py' + init.touch() + except OSError as e: + print('warning: could not create cogs directory ({})'.format(e)) + + try: + with open(str(new_directory / 'config.py'), 'w', encoding='utf-8') as fp: + fp.write('token = "place your token here"\ncogs = []\n') + except OSError as e: + parser.error('could not create config file ({})'.format(e)) + + try: + with open(str(new_directory / 'bot.py'), 'w', encoding='utf-8') as fp: + base = 'Bot' if not args.sharded else 'AutoShardedBot' + fp.write(bot_template.format(base=base, prefix=args.prefix)) + except OSError as e: + parser.error('could not create bot file ({})'.format(e)) + + if not args.no_git: + try: + with open(str(new_directory / '.gitignore'), 'w', encoding='utf-8') as fp: + fp.write(gitignore_template) + except OSError as e: + print('warning: could not create .gitignore file ({})'.format(e)) + + print('successfully made bot at', new_directory) + +def newcog(parser, args): + if sys.version_info < (3, 5): + parser.error('python version is older than 3.5, consider upgrading.') + + cog_dir = to_path(parser, args.directory) + try: + cog_dir.mkdir(exist_ok=True) + except OSError as e: + print('warning: could not create cogs directory ({})'.format(e)) + + directory = cog_dir / to_path(parser, args.name) + directory = directory.with_suffix('.py') + try: + with open(str(directory), 'w', encoding='utf-8') as fp: + extra = cog_extras if args.full else '' + if args.class_name: + name = args.class_name + else: + name = str(directory.stem) + if '-' in name: + name = name.replace('-', ' ').title().replace(' ', '') + else: + name = name.title() + fp.write(cog_template.format(name=name, extra=extra)) + except OSError as e: + parser.error('could not create cog file ({})'.format(e)) + else: + print('successfully made cog at', directory) + +def add_newbot_args(subparser): + parser = subparser.add_parser('newbot', help='creates a command bot project quickly') + parser.set_defaults(func=newbot) + + parser.add_argument('name', help='the bot project name') + parser.add_argument('directory', help='the directory to place it in (default: .)', nargs='?', default=Path.cwd()) + parser.add_argument('--prefix', help='the bot prefix (default: $)', default='$', metavar='') + parser.add_argument('--sharded', help='whether to use AutoShardedBot', action='store_true') + parser.add_argument('--no-git', help='do not create a .gitignore file', action='store_true', dest='no_git') + +def add_newcog_args(subparser): + parser = subparser.add_parser('newcog', help='creates a new cog template quickly') + parser.set_defaults(func=newcog) + + parser.add_argument('name', help='the cog name') + parser.add_argument('directory', help='the directory to place it in (default: cogs)', nargs='?', default=Path('cogs')) + parser.add_argument('--class-name', help='the class name of the cog (default: )', dest='class_name') + parser.add_argument('--full', help='add all special methods as well', action='store_true') + +def parse_args(): + parser = argparse.ArgumentParser(prog='discord', description='Tools for helping with discord.py') + + version = 'discord.py v{0.__version__} for Python {1[0]}.{1[1]}.{1[2]}'.format(discord, sys.version_info) + parser.add_argument('-v', '--version', action='version', version=version, help='shows the library version') + parser.set_defaults(func=core) + + subparser = parser.add_subparsers(dest='subcommand', title='subcommands') + add_newbot_args(subparser) + add_newcog_args(subparser) + return parser, parser.parse_args() + +def main(): + parser, args = parse_args() + args.func(parser, args) + +main() diff --git a/discord.py-rewrite/discord/abc.py b/discord.py-rewrite/discord/abc.py new file mode 100644 index 0000000..e836dda --- /dev/null +++ b/discord.py-rewrite/discord/abc.py @@ -0,0 +1,995 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import abc +import copy +import asyncio + +from collections import namedtuple + +from .iterators import HistoryIterator +from .context_managers import Typing +from .errors import InvalidArgument, ClientException +from .permissions import PermissionOverwrite, Permissions +from .role import Role +from .invite import Invite +from .file import File +from .voice_client import VoiceClient +from . import utils, compat + +class _Undefined: + def __repr__(self): + return 'see-below' + +_undefined = _Undefined() + +class Snowflake(metaclass=abc.ABCMeta): + """An ABC that details the common operations on a Discord model. + + Almost all :ref:`Discord models ` meet this + abstract base class. + + Attributes + ----------- + id: int + The model's unique ID. + """ + __slots__ = () + + @property + @abc.abstractmethod + def created_at(self): + """Returns the model's creation time in UTC.""" + raise NotImplementedError + + @classmethod + def __subclasshook__(cls, C): + if cls is Snowflake: + mro = C.__mro__ + for attr in ('created_at', 'id'): + for base in mro: + if attr in base.__dict__: + break + else: + return NotImplemented + return True + return NotImplemented + +class User(metaclass=abc.ABCMeta): + """An ABC that details the common operations on a Discord user. + + The following implement this ABC: + + - :class:`User` + - :class:`ClientUser` + - :class:`Member` + + This ABC must also implement :class:`abc.Snowflake`. + + Attributes + ----------- + name: str + The user's username. + discriminator: str + The user's discriminator. + avatar: Optional[str] + The avatar hash the user has. + bot: bool + If the user is a bot account. + """ + __slots__ = () + + @property + @abc.abstractmethod + def display_name(self): + """Returns the user's display name.""" + raise NotImplementedError + + @property + @abc.abstractmethod + def mention(self): + """Returns a string that allows you to mention the given user.""" + raise NotImplementedError + + @classmethod + def __subclasshook__(cls, C): + if cls is User: + if Snowflake.__subclasshook__(C) is NotImplemented: + return NotImplemented + + mro = C.__mro__ + for attr in ('display_name', 'mention', 'name', 'avatar', 'discriminator', 'bot'): + for base in mro: + if attr in base.__dict__: + break + else: + return NotImplemented + return True + return NotImplemented + +class PrivateChannel(metaclass=abc.ABCMeta): + """An ABC that details the common operations on a private Discord channel. + + The following implement this ABC: + + - :class:`DMChannel` + - :class:`GroupChannel` + + This ABC must also implement :class:`abc.Snowflake`. + + Attributes + ----------- + me: :class:`ClientUser` + The user presenting yourself. + """ + __slots__ = () + + @classmethod + def __subclasshook__(cls, C): + if cls is PrivateChannel: + if Snowflake.__subclasshook__(C) is NotImplemented: + return NotImplemented + + mro = C.__mro__ + for base in mro: + if 'me' in base.__dict__: + return True + return NotImplemented + return NotImplemented + +_Overwrites = namedtuple('_Overwrites', 'id allow deny type') + +class GuildChannel: + """An ABC that details the common operations on a Discord guild channel. + + The following implement this ABC: + + - :class:`TextChannel` + - :class:`VoiceChannel` + - :class:`CategoryChannel` + + This ABC must also implement :class:`abc.Snowflake`. + + Attributes + ----------- + name: str + The channel name. + guild: :class:`Guild` + The guild the channel belongs to. + position: int + The position in the channel list. This is a number that starts at 0. + e.g. the top channel is position 0. + """ + __slots__ = () + + def __str__(self): + return self.name + + @asyncio.coroutine + def _move(self, position, parent_id=None, lock_permissions=False, *, reason): + if position < 0: + raise InvalidArgument('Channel position cannot be less than 0.') + + http = self._state.http + cls = type(self) + channels = [c for c in self.guild.channels if isinstance(c, cls)] + + if position >= len(channels): + raise InvalidArgument('Channel position cannot be greater than {}'.format(len(channels) - 1)) + + channels.sort(key=lambda c: c.position) + + try: + # remove ourselves from the channel list + channels.remove(self) + except ValueError: + # not there somehow lol + return + else: + # add ourselves at our designated position + channels.insert(position, self) + + payload = [] + for index, c in enumerate(channels): + d = {'id': c.id, 'position': index} + if parent_id is not _undefined and c.id == self.id: + d.update(parent_id=parent_id, lock_permissions=lock_permissions) + payload.append(d) + + yield from http.bulk_channel_update(self.guild.id, payload, reason=reason) + self.position = position + if parent_id is not _undefined: + self.category_id = int(parent_id) if parent_id else None + + @asyncio.coroutine + def _edit(self, options, reason): + try: + parent = options.pop('category') + except KeyError: + parent_id = _undefined + else: + parent_id = parent and parent.id + + lock_permissions = options.pop('sync_permissions', False) + + try: + position = options.pop('position') + except KeyError: + if parent_id is not _undefined: + yield from self._move(self.position, parent_id=parent_id, lock_permissions=lock_permissions, reason=reason) + elif lock_permissions and self.category_id is not None: + # if we're syncing permissions on a pre-existing channel category without changing it + # we need to update the permissions to point to the pre-existing category + category = self.guild.get_channel(self.category_id) + options['permission_overwrites'] = [c._asdict() for c in category._overwrites] + else: + yield from self._move(position, parent_id=parent_id, lock_permissions=lock_permissions, reason=reason) + + if options: + data = yield from self._state.http.edit_channel(self.id, reason=reason, **options) + self._update(self.guild, data) + + def _fill_overwrites(self, data): + self._overwrites = [] + everyone_index = 0 + everyone_id = self.guild.id + + for index, overridden in enumerate(data.get('permission_overwrites', [])): + overridden_id = int(overridden.pop('id')) + self._overwrites.append(_Overwrites(id=overridden_id, **overridden)) + + if overridden['type'] == 'member': + continue + + if overridden_id == everyone_id: + # the @everyone role is not guaranteed to be the first one + # in the list of permission overwrites, however the permission + # resolution code kind of requires that it is the first one in + # the list since it is special. So we need the index so we can + # swap it to be the first one. + everyone_index = index + + # do the swap + tmp = self._overwrites + if tmp: + tmp[everyone_index], tmp[0] = tmp[0], tmp[everyone_index] + + @property + def changed_roles(self): + """Returns a list of :class:`Roles` that have been overridden from + their default values in the :attr:`Guild.roles` attribute.""" + ret = [] + for overwrite in filter(lambda o: o.type == 'role', self._overwrites): + role = utils.get(self.guild.roles, id=overwrite.id) + if role is None: + continue + + role = copy.copy(role) + role.permissions.handle_overwrite(overwrite.allow, overwrite.deny) + ret.append(role) + return ret + + @property + def mention(self): + """str : The string that allows you to mention the channel.""" + return '<#%s>' % self.id + + @property + def created_at(self): + """Returns the channel's creation time in UTC.""" + return utils.snowflake_time(self.id) + + def overwrites_for(self, obj): + """Returns the channel-specific overwrites for a member or a role. + + Parameters + ----------- + obj + The :class:`Role` or :class:`abc.User` denoting + whose overwrite to get. + + Returns + --------- + :class:`PermissionOverwrite` + The permission overwrites for this object. + """ + + if isinstance(obj, User): + predicate = lambda p: p.type == 'member' + elif isinstance(obj, Role): + predicate = lambda p: p.type == 'role' + else: + predicate = lambda p: True + + for overwrite in filter(predicate, self._overwrites): + if overwrite.id == obj.id: + allow = Permissions(overwrite.allow) + deny = Permissions(overwrite.deny) + return PermissionOverwrite.from_pair(allow, deny) + + return PermissionOverwrite() + + @property + def overwrites(self): + """Returns all of the channel's overwrites. + + This is returned as a list of two-element tuples containing the target, + which can be either a :class:`Role` or a :class:`Member` and the overwrite + as the second element as a :class:`PermissionOverwrite`. + + Returns + -------- + List[Tuple[Union[:class:`Role`, :class:`Member`], :class:`PermissionOverwrite`]]: + The channel's permission overwrites. + """ + ret = [] + for ow in self._overwrites: + allow = Permissions(ow.allow) + deny = Permissions(ow.deny) + overwrite = PermissionOverwrite.from_pair(allow, deny) + + if ow.type == 'role': + # accidentally quadratic + target = utils.find(lambda r: r.id == ow.id, self.guild.roles) + elif ow.type == 'member': + target = self.guild.get_member(ow.id) + + ret.append((target, overwrite)) + return ret + + @property + def category(self): + """Optional[:class:`CategoryChannel`]: The category this channel belongs to. + + If there is no category then this is ``None``. + """ + return self.guild.get_channel(self.category_id) + + def permissions_for(self, member): + """Handles permission resolution for the current :class:`Member`. + + This function takes into consideration the following cases: + + - Guild owner + - Guild roles + - Channel overrides + - Member overrides + + Parameters + ---------- + member : :class:`Member` + The member to resolve permissions for. + + Returns + ------- + :class:`Permissions` + The resolved permissions for the member. + """ + + # The current cases can be explained as: + # Guild owner get all permissions -- no questions asked. Otherwise... + # The @everyone role gets the first application. + # After that, the applied roles that the user has in the channel + # (or otherwise) are then OR'd together. + # After the role permissions are resolved, the member permissions + # have to take into effect. + # After all that is done.. you have to do the following: + + # If manage permissions is True, then all permissions are set to True. + + # The operation first takes into consideration the denied + # and then the allowed. + + o = self.guild.owner + if o is not None and member.id == o.id: + return Permissions.all() + + default = self.guild.default_role + base = Permissions(default.permissions.value) + + # Apply guild roles that the member has. + for role in member.roles: + base.value |= role.permissions.value + + # Guild-wide Administrator -> True for everything + # Bypass all channel-specific overrides + if base.administrator: + return Permissions.all() + + # Apply @everyone allow/deny first since it's special + try: + maybe_everyone = self._overwrites[0] + if maybe_everyone.id == self.guild.id: + base.handle_overwrite(allow=maybe_everyone.allow, deny=maybe_everyone.deny) + remaining_overwrites = self._overwrites[1:] + else: + remaining_overwrites = self._overwrites + except IndexError: + remaining_overwrites = self._overwrites + + member_role_ids = set(map(lambda r: r.id, member.roles)) + denies = 0 + allows = 0 + + # Apply channel specific role permission overwrites + for overwrite in remaining_overwrites: + if overwrite.type == 'role' and overwrite.id in member_role_ids: + denies |= overwrite.deny + allows |= overwrite.allow + + base.handle_overwrite(allow=allows, deny=denies) + + # Apply member specific permission overwrites + for overwrite in remaining_overwrites: + if overwrite.type == 'member' and overwrite.id == member.id: + base.handle_overwrite(allow=overwrite.allow, deny=overwrite.deny) + break + + # if you can't send a message in a channel then you can't have certain + # permissions as well + if not base.send_messages: + base.send_tts_messages = False + base.mention_everyone = False + base.embed_links = False + base.attach_files = False + + # if you can't read a channel then you have no permissions there + if not base.read_messages: + denied = Permissions.all_channel() + base.value &= ~denied.value + + return base + + @asyncio.coroutine + def delete(self, *, reason=None): + """|coro| + + Deletes the channel. + + You must have Manage Channel permission to use this. + + Parameters + ----------- + reason: Optional[str] + The reason for deleting this channel. + Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have proper permissions to delete the channel. + NotFound + The channel was not found or was already deleted. + HTTPException + Deleting the channel failed. + """ + yield from self._state.http.delete_channel(self.id, reason=reason) + + @asyncio.coroutine + def set_permissions(self, target, *, overwrite=_undefined, reason=None, **permissions): + """|coro| + + Sets the channel specific permission overwrites for a target in the + channel. + + The ``target`` parameter should either be a :class:`Member` or a + :class:`Role` that belongs to guild. + + The ``overwrite`` parameter, if given, must either be ``None`` or + :class:`PermissionOverwrite`. For convenience, you can pass in + keyword arguments denoting :class:`Permissions` attributes. If this is + done, then you cannot mix the keyword arguments with the ``overwrite`` + parameter. + + If the ``overwrite`` parameter is ``None``, then the permission + overwrites are deleted. + + You must have :attr:`Permissions.manage_roles` permission to use this. + + Examples + ---------- + + Setting allow and deny: :: + + await message.channel.set_permissions(message.author, read_messages=True, + send_messages=False) + + Deleting overwrites :: + + await channel.set_permissions(member, overwrite=None) + + Using :class:`PermissionOverwrite` :: + + overwrite = PermissionOverwrite() + overwrite.send_messages = False + overwrite.read_messages = True + await channel.set_permissions(member, overwrite=overwrite) + + Parameters + ----------- + target + The :class:`Member` or :class:`Role` to overwrite permissions for. + overwrite: :class:`PermissionOverwrite` + The permissions to allow and deny to the target. + \*\*permissions + A keyword argument list of permissions to set for ease of use. + Cannot be mixed with ``overwrite``. + reason: Optional[str] + The reason for doing this action. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have permissions to edit channel specific permissions. + HTTPException + Editing channel specific permissions failed. + InvalidArgument + The overwrite parameter invalid or the target type was not + :class:`Role` or :class:`Member`. + """ + + http = self._state.http + + if isinstance(target, User): + perm_type = 'member' + elif isinstance(target, Role): + perm_type = 'role' + else: + raise InvalidArgument('target parameter must be either Member or Role') + + if isinstance(overwrite, _Undefined): + if len(permissions) == 0: + raise InvalidArgument('No overwrite provided.') + try: + overwrite = PermissionOverwrite(**permissions) + except: + raise InvalidArgument('Invalid permissions given to keyword arguments.') + else: + if len(permissions) > 0: + raise InvalidArgument('Cannot mix overwrite and keyword arguments.') + + # TODO: wait for event + + if overwrite is None: + yield from http.delete_channel_permissions(self.id, target.id, reason=reason) + elif isinstance(overwrite, PermissionOverwrite): + (allow, deny) = overwrite.pair() + yield from http.edit_channel_permissions(self.id, target.id, allow.value, deny.value, perm_type, reason=reason) + else: + raise InvalidArgument('Invalid overwrite type provided.') + + @asyncio.coroutine + def create_invite(self, *, reason=None, **fields): + """|coro| + + Creates an instant invite. + + Parameters + ------------ + max_age : int + How long the invite should last. If it's 0 then the invite + doesn't expire. Defaults to 0. + max_uses : int + How many uses the invite could be used for. If it's 0 then there + are unlimited uses. Defaults to 0. + temporary : bool + Denotes that the invite grants temporary membership + (i.e. they get kicked after they disconnect). Defaults to False. + unique: bool + Indicates if a unique invite URL should be created. Defaults to True. + If this is set to False then it will return a previously created + invite. + reason: Optional[str] + The reason for creating this invite. Shows up on the audit log. + + Raises + ------- + HTTPException + Invite creation failed. + + Returns + -------- + :class:`Invite` + The invite that was created. + """ + + data = yield from self._state.http.create_invite(self.id, reason=reason, **fields) + return Invite.from_incomplete(data=data, state=self._state) + + @asyncio.coroutine + def invites(self): + """|coro| + + Returns a list of all active instant invites from this channel. + + You must have proper permissions to get this information. + + Raises + ------- + Forbidden + You do not have proper permissions to get the information. + HTTPException + An error occurred while fetching the information. + + Returns + ------- + List[:class:`Invite`] + The list of invites that are currently active. + """ + + state = self._state + data = yield from state.http.invites_from_channel(self.id) + result = [] + + for invite in data: + invite['channel'] = self + invite['guild'] = self.guild + result.append(Invite(state=state, data=invite)) + + return result + +class Messageable(metaclass=abc.ABCMeta): + """An ABC that details the common operations on a model that can send messages. + + The following implement this ABC: + + - :class:`TextChannel` + - :class:`DMChannel` + - :class:`GroupChannel` + - :class:`User` + - :class:`Member` + - :class:`~ext.commands.Context` + + This ABC must also implement :class:`abc.Snowflake`. + """ + + __slots__ = () + + @asyncio.coroutine + @abc.abstractmethod + def _get_channel(self): + raise NotImplementedError + + @asyncio.coroutine + def send(self, content=None, *, tts=False, embed=None, file=None, files=None, delete_after=None, nonce=None): + """|coro| + + Sends a message to the destination with the content given. + + The content must be a type that can convert to a string through ``str(content)``. + If the content is set to ``None`` (the default), then the ``embed`` parameter must + be provided. + + To upload a single file, the ``file`` parameter should be used with a + single :class:`File` object. To upload multiple files, the ``files`` + parameter should be used with a list of :class:`File` objects. + **Specifying both parameters will lead to an exception**. + + If the ``embed`` parameter is provided, it must be of type :class:`Embed` and + it must be a rich embed type. + + Parameters + ------------ + content + The content of the message to send. + tts: bool + Indicates if the message should be sent using text-to-speech. + embed: :class:`Embed` + The rich embed for the content. + file: :class:`File` + The file to upload. + files: List[:class:`File`] + A list of files to upload. Must be a maximum of 10. + nonce: int + The nonce to use for sending this message. If the message was successfully sent, + then the message will have a nonce with this value. + delete_after: float + If provided, the number of seconds to wait in the background + before deleting the message we just sent. If the deletion fails, + then it is silently ignored. + + Raises + -------- + HTTPException + Sending the message failed. + Forbidden + You do not have the proper permissions to send the message. + InvalidArgument + The ``files`` list is not of the appropriate size or + you specified both ``file`` and ``files``. + + Returns + --------- + :class:`Message` + The message that was sent. + """ + + channel = yield from self._get_channel() + state = self._state + content = str(content) if content is not None else None + if embed is not None: + embed = embed.to_dict() + + if file is not None and files is not None: + raise InvalidArgument('cannot pass both file and files parameter to send()') + + if file is not None: + if not isinstance(file, File): + raise InvalidArgument('file parameter must be File') + + try: + data = yield from state.http.send_files(channel.id, files=[(file.open_file(), file.filename)], + content=content, tts=tts, embed=embed, nonce=nonce) + finally: + file.close() + + elif files is not None: + if len(files) > 10: + raise InvalidArgument('files parameter must be a list of up to 10 elements') + + try: + param = [(f.open_file(), f.filename) for f in files] + data = yield from state.http.send_files(channel.id, files=param, content=content, tts=tts, + embed=embed, nonce=nonce) + finally: + for f in files: + f.close() + else: + data = yield from state.http.send_message(channel.id, content, tts=tts, embed=embed, nonce=nonce) + + ret = state.create_message(channel=channel, data=data) + if delete_after is not None: + @asyncio.coroutine + def delete(): + yield from asyncio.sleep(delete_after, loop=state.loop) + try: + yield from ret.delete() + except: + pass + compat.create_task(delete(), loop=state.loop) + return ret + + @asyncio.coroutine + def trigger_typing(self): + """|coro| + + Triggers a *typing* indicator to the destination. + + *Typing* indicator will go away after 10 seconds, or after a message is sent. + """ + + channel = yield from self._get_channel() + yield from self._state.http.send_typing(channel.id) + + def typing(self): + """Returns a context manager that allows you to type for an indefinite period of time. + + This is useful for denoting long computations in your bot. + + .. note:: + + This is both a regular context manager and an async context manager. + This means that both ``with`` and ``async with`` work with this. + + Example Usage: :: + + async with channel.typing(): + # do expensive stuff here + await channel.send('done!') + + """ + return Typing(self) + + @asyncio.coroutine + def get_message(self, id): + """|coro| + + Retrieves a single :class:`Message` from the destination. + + This can only be used by bot accounts. + + Parameters + ------------ + id: int + The message ID to look for. + + Returns + -------- + :class:`Message` + The message asked for. + + Raises + -------- + NotFound + The specified message was not found. + Forbidden + You do not have the permissions required to get a message. + HTTPException + Retrieving the message failed. + """ + + channel = yield from self._get_channel() + data = yield from self._state.http.get_message(channel.id, id) + return self._state.create_message(channel=channel, data=data) + + @asyncio.coroutine + def pins(self): + """|coro| + + Returns a list of :class:`Message` that are currently pinned. + + Raises + ------- + HTTPException + Retrieving the pinned messages failed. + """ + + channel = yield from self._get_channel() + state = self._state + data = yield from state.http.pins_from(channel.id) + return [state.create_message(channel=channel, data=m) for m in data] + + def history(self, *, limit=100, before=None, after=None, around=None, reverse=None): + """Return an :class:`AsyncIterator` that enables receiving the destination's message history. + + You must have :attr:`~Permissions.read_message_history` permissions to use this. + + All parameters are optional. + + Parameters + ----------- + limit: Optional[int] + The number of messages to retrieve. + If ``None``, retrieves every message in the channel. Note, however, + that this would make it a slow operation. + before: :class:`Message` or `datetime` + Retrieve messages before this date or message. + If a date is provided it must be a timezone-naive datetime representing UTC time. + after: :class:`Message` or `datetime` + Retrieve messages after this date or message. + If a date is provided it must be a timezone-naive datetime representing UTC time. + around: :class:`Message` or `datetime` + Retrieve messages around this date or message. + If a date is provided it must be a timezone-naive datetime representing UTC time. + When using this argument, the maximum limit is 101. Note that if the limit is an + even number then this will return at most limit + 1 messages. + reverse: bool + If set to true, return messages in oldest->newest order. If unspecified, + this defaults to ``False`` for most cases. However if passing in a + ``after`` parameter then this is set to ``True``. This avoids getting messages + out of order in the ``after`` case. + + Raises + ------ + Forbidden + You do not have permissions to get channel message history. + HTTPException + The request to get message history failed. + + Yields + ------- + :class:`Message` + The message with the message data parsed. + + Examples + --------- + + Usage :: + + counter = 0 + async for message in channel.history(limit=200): + if message.author == client.user: + counter += 1 + + Flattening into a list: :: + + messages = await channel.history(limit=123).flatten() + # messages is now a list of Message... + + Python 3.4 Usage :: + + count = 0 + iterator = channel.history(limit=200) + while True: + try: + message = yield from iterator.next() + except discord.NoMoreItems: + break + else: + if message.author == client.user: + counter += 1 + """ + return HistoryIterator(self, limit=limit, before=before, after=after, around=around, reverse=reverse) + + +class Connectable(metaclass=abc.ABCMeta): + """An ABC that details the common operations on a channel that can + connect to a voice server. + + The following implement this ABC: + + - :class:`VoiceChannel` + """ + __slots__ = () + + @abc.abstractmethod + def _get_voice_client_key(self): + raise NotImplementedError + + @abc.abstractmethod + def _get_voice_state_pair(self): + raise NotImplementedError + + @asyncio.coroutine + def connect(self, *, timeout=60.0, reconnect=True): + """|coro| + + Connects to voice and creates a :class:`VoiceClient` to establish + your connection to the voice server. + + Parameters + ----------- + timeout: float + The timeout in seconds to wait for the voice endpoint. + reconnect: bool + Whether the bot should automatically attempt + a reconnect if a part of the handshake fails + or the gateway goes down. + + Raises + ------- + asyncio.TimeoutError + Could not connect to the voice channel in time. + ClientException + You are already connected to a voice channel. + OpusNotLoaded + The opus library has not been loaded. + + Returns + ------- + :class:`VoiceClient` + A voice client that is fully connected to the voice server. + """ + key_id, key_name = self._get_voice_client_key() + state = self._state + + if state._get_voice_client(key_id): + raise ClientException('Already connected to a voice channel.') + + voice = VoiceClient(state=state, timeout=timeout, channel=self) + state._add_voice_client(key_id, voice) + + try: + yield from voice.connect(reconnect=reconnect) + except asyncio.TimeoutError as e: + try: + yield from voice.disconnect(force=True) + except: + # we don't care if disconnect failed because connection failed + pass + raise e # re-raise + + return voice diff --git a/discord.py-rewrite/discord/audit_logs.py b/discord.py-rewrite/discord/audit_logs.py new file mode 100644 index 0000000..c33f8c3 --- /dev/null +++ b/discord.py-rewrite/discord/audit_logs.py @@ -0,0 +1,340 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from . import utils, enums +from .object import Object +from .permissions import PermissionOverwrite, Permissions +from .colour import Colour +from .invite import Invite + +def _transform_verification_level(entry, data): + return enums.try_enum(enums.VerificationLevel, data) + +def _transform_explicit_content_filter(entry, data): + return enums.try_enum(enums.ContentFilter, data) + +def _transform_permissions(entry, data): + return Permissions(data) + +def _transform_color(entry, data): + return Colour(data) + +def _transform_snowflake(entry, data): + return int(data) + +def _transform_channel(entry, data): + if data is None: + return None + channel = entry.guild.get_channel(int(data)) or Object(id=data) + return channel + +def _transform_owner_id(entry, data): + if data is None: + return None + return entry._get_member(int(data)) + +def _transform_inviter_id(entry, data): + if data is None: + return None + return entry._get_member(int(data)) + +def _transform_overwrites(entry, data): + overwrites = [] + for elem in data: + allow = Permissions(elem['allow']) + deny = Permissions(elem['deny']) + ow = PermissionOverwrite.from_pair(allow, deny) + + ow_type = elem['type'] + ow_id = int(elem['id']) + if ow_type == 'role': + target = utils.find(lambda r: r.id == ow_id, entry.guild.roles) + else: + target = entry._get_member(ow_id) + + if target is None: + target = Object(id=ow_id) + + overwrites.append((target, ow)) + + return overwrites + +class AuditLogDiff: + def __len__(self): + return len(self.__dict__) + + def __iter__(self): + return iter(self.__dict__.items()) + + def __repr__(self): + return ''.format(tuple(self.__dict__)) + +class AuditLogChanges: + TRANSFORMERS = { + 'verification_level': (None, _transform_verification_level), + 'explicit_content_filter': (None, _transform_explicit_content_filter), + 'allow': (None, _transform_permissions), + 'deny': (None, _transform_permissions), + 'permissions': (None, _transform_permissions), + 'id': (None, _transform_snowflake), + 'color': ('colour', _transform_color), + 'owner_id': ('owner', _transform_owner_id), + 'inviter_id': ('inviter', _transform_inviter_id), + 'channel_id': ('channel', _transform_channel), + 'afk_channel_id': ('afk_channel', _transform_channel), + 'system_channel_id': ('system_channel', _transform_channel), + 'widget_channel_id': ('widget_channel', _transform_channel), + 'permission_overwrites': ('overwrites', _transform_overwrites), + 'splash_hash': ('splash', None), + 'icon_hash': ('icon', None), + 'avatar_hash': ('avatar', None), + } + + def __init__(self, entry, data): + self.before = AuditLogDiff() + self.after = AuditLogDiff() + + for elem in data: + attr = elem['key'] + + # special cases for role add/remove + if attr == '$add': + self._handle_role(self.before, self.after, entry, elem['new_value']) + continue + elif attr == '$remove': + self._handle_role(self.after, self.before, entry, elem['new_value']) + continue + + transformer = self.TRANSFORMERS.get(attr) + if transformer: + key, transformer = transformer + if key: + attr = key + + try: + before = elem['old_value'] + except KeyError: + before = None + else: + if transformer: + before = transformer(entry, before) + + setattr(self.before, attr, before) + + try: + after = elem['new_value'] + except KeyError: + after = None + else: + if transformer: + after = transformer(entry, after) + + setattr(self.after, attr, after) + + # add an alias + if hasattr(self.after, 'colour'): + self.after.color = self.after.colour + self.before.color = self.before.colour + + def _handle_role(self, first, second, entry, elem): + setattr(first, 'roles', []) + + data = [] + roles = entry.guild.roles + + for e in elem: + role_id = int(e['id']) + role = utils.find(lambda r: r.id == role_id, roles) + + if role is None: + role = Object(id=role_id) + role.name = e['name'] + + data.append(role) + + setattr(second, 'roles', data) + +class AuditLogEntry: + """Represents an Audit Log entry. + + You retrieve these via :meth:`Guild.audit_logs`. + + Attributes + ----------- + action: :class:`AuditLogAction` + The action that was done. + user: :class:`abc.User` + The user who initiated this action. Usually a :class:`Member`\, unless gone + then it's a :class:`User`. + id: int + The entry ID. + target: Any + The target that got changed. The exact type of this depends on + the action being done. + reason: Optional[str] + The reason this action was done. + extra: Any + Extra information that this entry has that might be useful. + For most actions, this is ``None``. However in some cases it + contains extra information. See :class:`AuditLogAction` for + which actions have this field filled out. + """ + + def __init__(self, *, users, data, guild): + self._state = guild._state + self.guild = guild + self._users = users + self._from_data(data) + + def _from_data(self, data): + self.action = enums.AuditLogAction(data['action_type']) + self.id = int(data['id']) + + # this key is technically not usually present + self.reason = data.get('reason') + self.extra = data.get('options') + + if self.extra: + if self.action is enums.AuditLogAction.member_prune: + # member prune has two keys with useful information + self.extra = type('_AuditLogProxy', (), {k: int(v) for k, v in self.extra.items()})() + elif self.action is enums.AuditLogAction.message_delete: + channel_id = int(self.extra['channel_id']) + elems = { + 'count': int(self.extra['count']), + 'channel': self.guild.get_channel(channel_id) or Object(id=channel_id) + } + self.extra = type('_AuditLogProxy', (), elems)() + elif self.action.name.startswith('overwrite_'): + # the overwrite_ actions have a dict with some information + instance_id = int(self.extra['id']) + the_type = self.extra.get('type') + if the_type == 'member': + self.extra = self._get_member(instance_id) + else: + role = utils.find(lambda r: r.id == instance_id, self.guild.roles) + if role is None: + role = Object(id=instance_id) + role.name = self.extra.get('role_name') + self.extra = role + + # this key is not present when the above is present, typically. + # It's a list of { new_value: a, old_value: b, key: c } + # where new_value and old_value are not guaranteed to be there depending + # on the action type, so let's just fetch it for now and only turn it + # into meaningful data when requested + self._changes = data.get('changes', []) + + self.user = self._get_member(utils._get_as_snowflake(data, 'user_id')) + self._target_id = utils._get_as_snowflake(data, 'target_id') + + def _get_member(self, user_id): + return self.guild.get_member(user_id) or self._users.get(user_id) + + def __repr__(self): + return ''.format(self) + + @utils.cached_property + def created_at(self): + """Returns the entry's creation time in UTC.""" + return utils.snowflake_time(self.id) + + @utils.cached_property + def target(self): + try: + converter = getattr(self, '_convert_target_' + self.action.target_type) + except AttributeError: + return Object(id=self._target_id) + else: + return converter(self._target_id) + + @utils.cached_property + def category(self): + """Optional[:class:`AuditLogActionCategory`]: The category of the action, if applicable.""" + return self.action.category + + @utils.cached_property + def changes(self): + """:class:`AuditLogChanges`: The list of changes this entry has.""" + obj = AuditLogChanges(self, self._changes) + del self._changes + return obj + + @utils.cached_property + def before(self): + """:class:`AuditLogDiff`: The target's prior state.""" + return self.changes.before + + @utils.cached_property + def after(self): + """:class:`AuditLogDiff`: The target's subsequent state.""" + return self.changes.after + + def _convert_target_guild(self, target_id): + return self.guild + + def _convert_target_channel(self, target_id): + ch = self.guild.get_channel(target_id) + if ch is None: + return Object(id=target_id) + return ch + + def _convert_target_user(self, target_id): + return self._get_member(target_id) + + def _convert_target_role(self, target_id): + role = utils.find(lambda r: r.id == target_id, self.guild.roles) + if role is None: + return Object(id=target_id) + return role + + def _convert_target_invite(self, target_id): + # invites have target_id set to null + # so figure out which change has the full invite data + changeset = self.before if self.action is enums.AuditLogAction.invite_delete else self.after + + fake_payload = { + 'max_age': changeset.max_age, + 'max_uses': changeset.max_uses, + 'code': changeset.code, + 'temporary': changeset.temporary, + 'channel': changeset.channel, + 'uses': changeset.uses, + 'guild': self.guild, + } + + obj = Invite(state=self._state, data=fake_payload) + try: + obj.inviter = changeset.inviter + except AttributeError: + pass + return obj + + def _convert_target_emoji(self, target_id): + return self._state.get_emoji(target_id) or Object(id=target_id) + + def _convert_target_message(self, target_id): + return self._get_member(target_id) diff --git a/discord.py-rewrite/discord/backoff.py b/discord.py-rewrite/discord/backoff.py new file mode 100644 index 0000000..3fa52d9 --- /dev/null +++ b/discord.py-rewrite/discord/backoff.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import time +import random + +class ExponentialBackoff: + """An implementation of the exponential backoff algorithm + + Provides a convenient interface to implement an exponential backoff + for reconnecting or retrying transmissions in a distributed network. + + Once instantiated, the delay method will return the next interval to + wait for when retrying a connection or transmission. The maximum + delay increases exponentially with each retry up to a maximum of + 2^10 * base, and is reset if no more attempts are needed in a period + of 2^11 * base seconds. + + Parameters + ---------- + base: int + The base delay in seconds. The first retry-delay will be up to + this many seconds. + integral: bool + Set to True if whole periods of base is desirable, otherwise any + number in between may be returned. + """ + + def __init__(self, base=1, *, integral=False): + self._base = base + + self._exp = 0 + self._max = 10 + self._reset_time = base * 2 ** 11 + self._last_invocation = time.monotonic() + + # Use our own random instance to avoid messing with global one + rand = random.Random() + rand.seed() + + self._randfunc = rand.randrange if integral else rand.uniform + + def delay(self): + """Compute the next delay + + Returns the next delay to wait according to the exponential + backoff algorithm. This is a value between 0 and base * 2^exp + where exponent starts off at 1 and is incremented at every + invocation of this method up to a maximum of 10. + + If a period of more than base * 2^11 has passed since the last + retry, the exponent is reset to 1. + """ + invocation = time.monotonic() + interval = invocation - self._last_invocation + self._last_invocation = invocation + + if interval > self._reset_time: + self._exp = 0 + + self._exp = min(self._exp + 1, self._max) + return self._randfunc(0, self._base * 2 ** self._exp) diff --git a/discord.py-rewrite/discord/calls.py b/discord.py-rewrite/discord/calls.py new file mode 100644 index 0000000..0ed5f23 --- /dev/null +++ b/discord.py-rewrite/discord/calls.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import datetime + +from . import utils +from .enums import VoiceRegion, try_enum +from .member import VoiceState + +class CallMessage: + """Represents a group call message from Discord. + + This is only received in cases where the message type is equivalent to + :attr:`MessageType.call`. + + Attributes + ----------- + ended_timestamp: Optional[datetime.datetime] + A naive UTC datetime object that represents the time that the call has ended. + participants: List[:class:`User`] + The list of users that are participating in this call. + message: :class:`Message` + The message associated with this call message. + """ + + def __init__(self, message, **kwargs): + self.message = message + self.ended_timestamp = utils.parse_time(kwargs.get('ended_timestamp')) + self.participants = kwargs.get('participants') + + @property + def call_ended(self): + """bool: Indicates if the call has ended.""" + return self.ended_timestamp is not None + + @property + def channel(self): + """:class:`GroupChannel`\: The private channel associated with this message.""" + return self.message.channel + + @property + def duration(self): + """Queries the duration of the call. + + If the call has not ended then the current duration will + be returned. + + Returns + --------- + datetime.timedelta + The timedelta object representing the duration. + """ + if self.ended_timestamp is None: + return datetime.datetime.utcnow() - self.message.timestamp + else: + return self.ended_timestamp - self.message.timestamp + +class GroupCall: + """Represents the actual group call from Discord. + + This is accompanied with a :class:`CallMessage` denoting the information. + + Attributes + ----------- + call: :class:`CallMessage` + The call message associated with this group call. + unavailable: bool + Denotes if this group call is unavailable. + ringing: List[:class:`User`] + A list of users that are currently being rung to join the call. + region: :class:`VoiceRegion` + The guild region the group call is being hosted on. + """ + + def __init__(self, **kwargs): + self.call = kwargs.get('call') + self.unavailable = kwargs.get('unavailable') + self._voice_states = {} + + for state in kwargs.get('voice_states', []): + self._update_voice_state(state) + + self._update(**kwargs) + + def _update(self, **kwargs): + self.region = try_enum(VoiceRegion, kwargs.get('region')) + lookup = {u.id: u for u in self.call.channel.recipients} + me = self.call.channel.me + lookup[me.id] = me + self.ringing = list(filter(None, map(lambda i: lookup.get(i), kwargs.get('ringing', [])))) + + def _update_voice_state(self, data): + user_id = int(data['user_id']) + # left the voice channel? + if data['channel_id'] is None: + self._voice_states.pop(user_id, None) + else: + self._voice_states[user_id] = VoiceState(data=data, channel=self.channel) + + @property + def connected(self): + """A property that returns the list of :class:`User` that are currently in this call.""" + ret = [u for u in self.channel.recipients if self.voice_state_for(u) is not None] + me = self.channel.me + if self.voice_state_for(me) is not None: + ret.append(me) + + return ret + + @property + def channel(self): + """:class:`GroupChannel`\: Returns the channel the group call is in.""" + return self.call.channel + + def voice_state_for(self, user): + """Retrieves the :class:`VoiceState` for a specified :class:`User`. + + If the :class:`User` has no voice state then this function returns + ``None``. + + Parameters + ------------ + user: :class:`User` + The user to retrieve the voice state for. + + Returns + -------- + Optional[:class:`VoiceState`] + The voice state associated with this user. + """ + + return self._voice_states.get(user.id) + diff --git a/discord.py-rewrite/discord/channel.py b/discord.py-rewrite/discord/channel.py new file mode 100644 index 0000000..363db25 --- /dev/null +++ b/discord.py-rewrite/discord/channel.py @@ -0,0 +1,930 @@ +# -*- coding: utf-8 -*- +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from .permissions import Permissions +from .enums import ChannelType, try_enum +from .mixins import Hashable +from . import utils +from .errors import ClientException, NoMoreItems +from .webhook import Webhook + +import discord.abc + +import time +import asyncio + +__all__ = ('TextChannel', 'VoiceChannel', 'DMChannel', 'CategoryChannel', 'GroupChannel', '_channel_factory') + +@asyncio.coroutine +def _single_delete_strategy(messages): + for m in messages: + yield from m.delete() + +class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): + """Represents a Discord guild text channel. + + .. container:: operations + + .. describe:: x == y + + Checks if two channels are equal. + + .. describe:: x != y + + Checks if two channels are not equal. + + .. describe:: hash(x) + + Returns the channel's hash. + + .. describe:: str(x) + + Returns the channel's name. + + Attributes + ----------- + name: str + The channel name. + guild: :class:`Guild` + The guild the channel belongs to. + id: int + The channel ID. + category_id: int + The category channel ID this channel belongs to. + topic: Optional[str] + The channel's topic. None if it doesn't exist. + position: int + The position in the channel list. This is a number that starts at 0. e.g. the + top channel is position 0. + """ + + __slots__ = ( 'name', 'id', 'guild', 'topic', '_state', 'nsfw', + 'category_id', 'position', '_overwrites' ) + + def __init__(self, *, state, guild, data): + self._state = state + self.id = int(data['id']) + self._update(guild, data) + + def __repr__(self): + return ''.format(self) + + def _update(self, guild, data): + self.guild = guild + self.name = data['name'] + self.category_id = utils._get_as_snowflake(data, 'parent_id') + self.topic = data.get('topic') + self.position = data['position'] + self.nsfw = data.get('nsfw', False) + self._fill_overwrites(data) + + @asyncio.coroutine + def _get_channel(self): + return self + + def permissions_for(self, member): + base = super().permissions_for(member) + + # text channels do not have voice related permissions + denied = Permissions.voice() + base.value &= ~denied.value + return base + + permissions_for.__doc__ = discord.abc.GuildChannel.permissions_for.__doc__ + + @property + def members(self): + """Returns a list of :class:`Member` that can see this channel.""" + return [m for m in self.guild.members if self.permissions_for(m).read_messages] + + def is_nsfw(self): + """Checks if the channel is NSFW.""" + n = self.name + return self.nsfw or n == 'nsfw' or n[:5] == 'nsfw-' + + @asyncio.coroutine + def edit(self, *, reason=None, **options): + """|coro| + + Edits the channel. + + You must have the :attr:`Permissions.manage_channel` permission to + use this. + + Parameters + ---------- + name: str + The new channel name. + topic: str + The new channel's topic. + position: int + The new channel's position. + nsfw: bool + To mark the channel as NSFW or not. + sync_permissions: bool + Whether to sync permissions with the channel's new or pre-existing + category. Defaults to ``False``. + category: Optional[:class:`CategoryChannel`] + The new category for this channel. Can be ``None`` to remove the + category. + reason: Optional[str] + The reason for editing this channel. Shows up on the audit log. + + Raises + ------ + InvalidArgument + If position is less than 0 or greater than the number of channels. + Forbidden + You do not have permissions to edit the channel. + HTTPException + Editing the channel failed. + """ + yield from self._edit(options, reason=reason) + + @asyncio.coroutine + def delete_messages(self, messages): + """|coro| + + Deletes a list of messages. This is similar to :meth:`Message.delete` + except it bulk deletes multiple messages. + + As a special case, if the number of messages is 0, then nothing + is done. If the number of messages is 1 then single message + delete is done. If it's more than two, then bulk delete is used. + + You cannot bulk delete more than 100 messages or messages that + are older than 14 days old. + + Usable only by bot accounts. + + Parameters + ----------- + messages: Iterable[:class:`abc.Snowflake`] + An iterable of messages denoting which ones to bulk delete. + + Raises + ------ + ClientException + The number of messages to delete was more than 100. + Forbidden + You do not have proper permissions to delete the messages or + you're not using a bot account. + HTTPException + Deleting the messages failed. + """ + if not isinstance(messages, (list, tuple)): + messages = list(messages) + + if len(messages) == 0: + return # do nothing + + if len(messages) == 1: + message_id = messages[0].id + yield from self._state.http.delete_message(self.id, message_id) + return + + if len(messages) > 100: + raise ClientException('Can only bulk delete messages up to 100 messages') + + message_ids = [m.id for m in messages] + yield from self._state.http.delete_messages(self.id, message_ids) + + @asyncio.coroutine + def purge(self, *, limit=100, check=None, before=None, after=None, around=None, reverse=False, bulk=True): + """|coro| + + Purges a list of messages that meet the criteria given by the predicate + ``check``. If a ``check`` is not provided then all messages are deleted + without discrimination. + + You must have :attr:`Permissions.manage_messages` permission to delete + messages even if they are your own (unless you are a user account). + The :attr:`Permissions.read_message_history` permission is also needed + to retrieve message history. + + Internally, this employs a different number of strategies depending + on the conditions met such as if a bulk delete is possible or if + the account is a user bot or not. + + Parameters + ----------- + limit: int + The number of messages to search through. This is not the number + of messages that will be deleted, though it can be. + check: predicate + The function used to check if a message should be deleted. + It must take a :class:`Message` as its sole parameter. + before + Same as ``before`` in :meth:`history`. + after + Same as ``after`` in :meth:`history`. + around + Same as ``around`` in :meth:`history`. + reverse + Same as ``reverse`` in :meth:`history`. + bulk: bool + If True, use bulk delete. bulk=False is useful for mass-deleting + a bot's own messages without manage_messages. When True, will fall + back to single delete if current account is a user bot, or if + messages are older than two weeks. + + Raises + ------- + Forbidden + You do not have proper permissions to do the actions required. + HTTPException + Purging the messages failed. + + Examples + --------- + + Deleting bot's messages :: + + def is_me(m): + return m.author == client.user + + deleted = await channel.purge(limit=100, check=is_me) + await channel.send('Deleted {} message(s)'.format(len(deleted))) + + Returns + -------- + list + The list of messages that were deleted. + """ + + if check is None: + check = lambda m: True + + iterator = self.history(limit=limit, before=before, after=after, reverse=reverse, around=around) + ret = [] + count = 0 + + minimum_time = int((time.time() - 14 * 24 * 60 * 60) * 1000.0 - 1420070400000) << 22 + strategy = self.delete_messages if self._state.is_bot and bulk else _single_delete_strategy + + while True: + try: + msg = yield from iterator.next() + except NoMoreItems: + # no more messages to poll + if count >= 2: + # more than 2 messages -> bulk delete + to_delete = ret[-count:] + yield from strategy(to_delete) + elif count == 1: + # delete a single message + yield from ret[-1].delete() + + return ret + else: + if count == 100: + # we've reached a full 'queue' + to_delete = ret[-100:] + yield from strategy(to_delete) + count = 0 + yield from asyncio.sleep(1) + + if check(msg): + if msg.id < minimum_time: + # older than 14 days old + if count == 1: + yield from ret[-1].delete() + elif count >= 2: + to_delete = ret[-count:] + yield from strategy(to_delete) + + count = 0 + strategy = _single_delete_strategy + + count += 1 + ret.append(msg) + + @asyncio.coroutine + def webhooks(self): + """|coro| + + Gets the list of webhooks from this channel. + + Requires :attr:`~.Permissions.manage_webhooks` permissions. + + Raises + ------- + Forbidden + You don't have permissions to get the webhooks. + + Returns + -------- + List[:class:`Webhook`] + The webhooks for this channel. + """ + + data = yield from self._state.http.channel_webhooks(self.id) + return [Webhook.from_state(d, state=self._state) for d in data] + + @asyncio.coroutine + def create_webhook(self, *, name=None, avatar=None): + """|coro| + + Creates a webhook for this channel. + + Requires :attr:`~.Permissions.manage_webhooks` permissions. + + Parameters + ------------- + name: Optional[str] + The webhook's name. + avatar: Optional[bytes] + A *bytes-like* object representing the webhook's default avatar. + This operates similarly to :meth:`~ClientUser.edit`. + + Raises + ------- + HTTPException + Creating the webhook failed. + Forbidden + You do not have permissions to create a webhook. + + Returns + -------- + :class:`Webhook` + The created webhook. + """ + + if avatar is not None: + avatar = utils._bytes_to_base64_data(avatar) + + if name is not None: + name = str(name) + + data = yield from self._state.http.create_webhook(self.id, name=name, avatar=avatar) + return Webhook.from_state(data, state=self._state) + +class VoiceChannel(discord.abc.Connectable, discord.abc.GuildChannel, Hashable): + """Represents a Discord guild voice channel. + + .. container:: operations + + .. describe:: x == y + + Checks if two channels are equal. + + .. describe:: x != y + + Checks if two channels are not equal. + + .. describe:: hash(x) + + Returns the channel's hash. + + .. describe:: str(x) + + Returns the channel's name. + + Attributes + ----------- + name: str + The channel name. + guild: :class:`Guild` + The guild the channel belongs to. + id: int + The channel ID. + category_id: int + The category channel ID this channel belongs to. + position: int + The position in the channel list. This is a number that starts at 0. e.g. the + top channel is position 0. + bitrate: int + The channel's preferred audio bitrate in bits per second. + user_limit: int + The channel's limit for number of members that can be in a voice channel. + """ + + __slots__ = ('name', 'id', 'guild', 'bitrate', 'user_limit', + '_state', 'position', '_overwrites', 'category_id' ) + + def __init__(self, *, state, guild, data): + self._state = state + self.id = int(data['id']) + self._update(guild, data) + + def __repr__(self): + return ''.format(self) + + def _get_voice_client_key(self): + return self.guild.id, 'guild_id' + + def _get_voice_state_pair(self): + return self.guild.id, self.id + + def _update(self, guild, data): + self.guild = guild + self.name = data['name'] + self.category_id = utils._get_as_snowflake(data, 'parent_id') + self.position = data['position'] + self.bitrate = data.get('bitrate') + self.user_limit = data.get('user_limit') + self._fill_overwrites(data) + + @property + def members(self): + """Returns a list of :class:`Member` that are currently inside this voice channel.""" + ret = [] + for user_id, state in self.guild._voice_states.items(): + if state.channel.id == self.id: + member = self.guild.get_member(user_id) + if member is not None: + ret.append(member) + return ret + + @asyncio.coroutine + def edit(self, *, reason=None, **options): + """|coro| + + Edits the channel. + + You must have the :attr:`Permissions.manage_channel` permission to + use this. + + Parameters + ---------- + bitrate: int + The new channel's bitrate. + user_limit: int + The new channel's user limit. + position: int + The new channel's position. + sync_permissions: bool + Whether to sync permissions with the channel's new or pre-existing + category. Defaults to ``False``. + category: Optional[:class:`CategoryChannel`] + The new category for this channel. Can be ``None`` to remove the + category. + reason: Optional[str] + The reason for editing this channel. Shows up on the audit log. + + Raises + ------ + Forbidden + You do not have permissions to edit the channel. + HTTPException + Editing the channel failed. + """ + + yield from self._edit(options, reason=reason) + +class CategoryChannel(discord.abc.GuildChannel, Hashable): + """Represents a Discord channel category. + + These are useful to group channels to logical compartments. + + .. container:: operations + + .. describe:: x == y + + Checks if two channels are equal. + + .. describe:: x != y + + Checks if two channels are not equal. + + .. describe:: hash(x) + + Returns the category's hash. + + .. describe:: str(x) + + Returns the category's name. + + Attributes + ----------- + name: str + The category name. + guild: :class:`Guild` + The guild the category belongs to. + id: int + The category channel ID. + position: int + The position in the category list. This is a number that starts at 0. e.g. the + top category is position 0. + """ + + __slots__ = ('name', 'id', 'guild', 'nsfw', '_state', 'position', '_overwrites', 'category_id') + + def __init__(self, *, state, guild, data): + self._state = state + self.id = int(data['id']) + self._update(guild, data) + + def __repr__(self): + return ''.format(self) + + def _update(self, guild, data): + self.guild = guild + self.name = data['name'] + self.category_id = utils._get_as_snowflake(data, 'parent_id') + self.nsfw = data.get('nsfw', False) + self.position = data['position'] + self._fill_overwrites(data) + + def is_nsfw(self): + """Checks if the category is NSFW.""" + n = self.name + return self.nsfw or n == 'nsfw' or n[:5] == 'nsfw-' + + @asyncio.coroutine + def edit(self, *, reason=None, **options): + """|coro| + + Edits the channel. + + You must have the :attr:`Permissions.manage_channel` permission to + use this. + + Parameters + ---------- + name: str + The new category's name. + position: int + The new category's position. + nsfw: bool + To mark the category as NSFW or not. + reason: Optional[str] + The reason for editing this category. Shows up on the audit log. + + Raises + ------ + InvalidArgument + If position is less than 0 or greater than the number of categories. + Forbidden + You do not have permissions to edit the category. + HTTPException + Editing the category failed. + """ + + try: + position = options.pop('position') + except KeyError: + pass + else: + yield from self._move(position, reason=reason) + self.position = position + + if options: + data = yield from self._state.http.edit_channel(self.id, reason=reason, **options) + self._update(self.guild, data) + + @property + def channels(self): + """List[:class:`abc.GuildChannel`]: Returns the channels that are under this category. + + These are sorted by the official Discord UI, which places voice channels below the text channels. + """ + def comparator(channel): + return (not isinstance(channel, TextChannel), channel.position) + + ret = [c for c in self.guild.channels if c.category_id == self.id] + ret.sort(key=comparator) + return ret + +class DMChannel(discord.abc.Messageable, Hashable): + """Represents a Discord direct message channel. + + .. container:: operations + + .. describe:: x == y + + Checks if two channels are equal. + + .. describe:: x != y + + Checks if two channels are not equal. + + .. describe:: hash(x) + + Returns the channel's hash. + + .. describe:: str(x) + + Returns a string representation of the channel + + Attributes + ---------- + recipient: :class:`User` + The user you are participating with in the direct message channel. + me: :class:`ClientUser` + The user presenting yourself. + id: int + The direct message channel ID. + """ + + __slots__ = ('id', 'recipient', 'me', '_state') + + def __init__(self, *, me, state, data): + self._state = state + self.recipient = state.store_user(data['recipients'][0]) + self.me = me + self.id = int(data['id']) + + @asyncio.coroutine + def _get_channel(self): + return self + + def __str__(self): + return 'Direct Message with %s' % self.recipient + + def __repr__(self): + return ''.format(self) + + @property + def created_at(self): + """Returns the direct message channel's creation time in UTC.""" + return utils.snowflake_time(self.id) + + def permissions_for(self, user=None): + """Handles permission resolution for a :class:`User`. + + This function is there for compatibility with other channel types. + + Actual direct messages do not really have the concept of permissions. + + This returns all the Text related permissions set to true except: + + - send_tts_messages: You cannot send TTS messages in a DM. + - manage_messages: You cannot delete others messages in a DM. + + Parameters + ----------- + user: :class:`User` + The user to check permissions for. This parameter is ignored + but kept for compatibility. + + Returns + -------- + :class:`Permissions` + The resolved permissions. + """ + + base = Permissions.text() + base.send_tts_messages = False + base.manage_messages = False + return base + +class GroupChannel(discord.abc.Messageable, Hashable): + """Represents a Discord group channel. + + .. container:: operations + + .. describe:: x == y + + Checks if two channels are equal. + + .. describe:: x != y + + Checks if two channels are not equal. + + .. describe:: hash(x) + + Returns the channel's hash. + + .. describe:: str(x) + + Returns a string representation of the channel + + Attributes + ---------- + recipients: list of :class:`User` + The users you are participating with in the group channel. + me: :class:`ClientUser` + The user presenting yourself. + id: int + The group channel ID. + owner: :class:`User` + The user that owns the group channel. + icon: Optional[str] + The group channel's icon hash if provided. + name: Optional[str] + The group channel's name if provided. + """ + + __slots__ = ('id', 'recipients', 'owner', 'icon', 'name', 'me', '_state') + + def __init__(self, *, me, state, data): + self._state = state + self.id = int(data['id']) + self.me = me + self._update_group(data) + + def _update_group(self, data): + owner_id = utils._get_as_snowflake(data, 'owner_id') + self.icon = data.get('icon') + self.name = data.get('name') + + try: + self.recipients = [self._state.store_user(u) for u in data['recipients']] + except KeyError: + pass + + if owner_id == self.me.id: + self.owner = self.me + else: + self.owner = utils.find(lambda u: u.id == owner_id, self.recipients) + + @asyncio.coroutine + def _get_channel(self): + return self + + def __str__(self): + if self.name: + return self.name + + if len(self.recipients) == 0: + return 'Unnamed' + + return ', '.join(map(lambda x: x.name, self.recipients)) + + def __repr__(self): + return ''.format(self) + + @property + def icon_url(self): + """Returns the channel's icon URL if available or an empty string otherwise.""" + if self.icon is None: + return '' + + return 'https://cdn.discordapp.com/channel-icons/{0.id}/{0.icon}.jpg'.format(self) + + @property + def created_at(self): + """Returns the channel's creation time in UTC.""" + return utils.snowflake_time(self.id) + + def permissions_for(self, user): + """Handles permission resolution for a :class:`User`. + + This function is there for compatibility with other channel types. + + Actual direct messages do not really have the concept of permissions. + + This returns all the Text related permissions set to true except: + + - send_tts_messages: You cannot send TTS messages in a DM. + - manage_messages: You cannot delete others messages in a DM. + + This also checks the kick_members permission if the user is the owner. + + Parameters + ----------- + user: :class:`User` + The user to check permissions for. + + Returns + -------- + :class:`Permissions` + The resolved permissions for the user. + """ + + base = Permissions.text() + base.send_tts_messages = False + base.manage_messages = False + base.mention_everyone = True + + if user.id == self.owner.id: + base.kick_members = True + + return base + + @asyncio.coroutine + def add_recipients(self, *recipients): + """|coro| + + Adds recipients to this group. + + A group can only have a maximum of 10 members. + Attempting to add more ends up in an exception. To + add a recipient to the group, you must have a relationship + with the user of type :attr:`RelationshipType.friend`. + + Parameters + ----------- + \*recipients: :class:`User` + An argument list of users to add to this group. + + Raises + ------- + HTTPException + Adding a recipient to this group failed. + """ + + # TODO: wait for the corresponding WS event + + req = self._state.http.add_group_recipient + for recipient in recipients: + yield from req(self.id, recipient.id) + + @asyncio.coroutine + def remove_recipients(self, *recipients): + """|coro| + + Removes recipients from this group. + + Parameters + ----------- + \*recipients: :class:`User` + An argument list of users to remove from this group. + + Raises + ------- + HTTPException + Removing a recipient from this group failed. + """ + + # TODO: wait for the corresponding WS event + + req = self._state.http.remove_group_recipient + for recipient in recipients: + yield from req(self.id, recipient.id) + + @asyncio.coroutine + def edit(self, **fields): + """|coro| + + Edits the group. + + Parameters + ----------- + name: Optional[str] + The new name to change the group to. + Could be ``None`` to remove the name. + icon: Optional[bytes] + A bytes-like object representing the new icon. + Could be ``None`` to remove the icon. + + Raises + ------- + HTTPException + Editing the group failed. + """ + + try: + icon_bytes = fields['icon'] + except KeyError: + pass + else: + if icon_bytes is not None: + fields['icon'] = utils._bytes_to_base64_data(icon_bytes) + + data = yield from self._state.http.edit_group(self.id, **fields) + self._update_group(data) + + @asyncio.coroutine + def leave(self): + """|coro| + + Leave the group. + + If you are the only one in the group, this deletes it as well. + + Raises + ------- + HTTPException + Leaving the group failed. + """ + + yield from self._state.http.leave_group(self.id) + +def _channel_factory(channel_type): + value = try_enum(ChannelType, channel_type) + if value is ChannelType.text: + return TextChannel, value + elif value is ChannelType.voice: + return VoiceChannel, value + elif value is ChannelType.private: + return DMChannel, value + elif value is ChannelType.category: + return CategoryChannel, value + elif value is ChannelType.group: + return GroupChannel, value + else: + return None, value diff --git a/discord.py-rewrite/discord/client.py b/discord.py-rewrite/discord/client.py new file mode 100644 index 0000000..9000e27 --- /dev/null +++ b/discord.py-rewrite/discord/client.py @@ -0,0 +1,1055 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from .user import User, Profile +from .invite import Invite +from .object import Object +from .guild import Guild +from .errors import * +from .enums import Status, VoiceRegion +from .gateway import * +from .voice_client import VoiceClient +from .http import HTTPClient +from .state import ConnectionState +from . import utils, compat +from .backoff import ExponentialBackoff +from .webhook import Webhook + +import asyncio +import aiohttp +import websockets + +import logging, traceback +import sys, re +import signal +from collections import namedtuple + +PY35 = sys.version_info >= (3, 5) +log = logging.getLogger(__name__) + +AppInfo = namedtuple('AppInfo', 'id name description icon owner') + +def app_info_icon_url(self): + """Retrieves the application's icon_url if it exists. Empty string otherwise.""" + if not self.icon: + return '' + + return 'https://cdn.discordapp.com/app-icons/{0.id}/{0.icon}.jpg'.format(self) + +AppInfo.icon_url = property(app_info_icon_url) + +class Client: + """Represents a client connection that connects to Discord. + This class is used to interact with the Discord WebSocket and API. + + A number of options can be passed to the :class:`Client`. + + .. _event loop: https://docs.python.org/3/library/asyncio-eventloops.html + .. _connector: http://aiohttp.readthedocs.org/en/stable/client_reference.html#connectors + .. _ProxyConnector: http://aiohttp.readthedocs.org/en/stable/client_reference.html#proxyconnector + + Parameters + ---------- + max_messages : Optional[int] + The maximum number of messages to store in the internal message cache. + This defaults to 5000. Passing in `None` or a value less than 100 + will use the default instead of the passed in value. + loop : Optional[event loop] + The `event loop`_ to use for asynchronous operations. Defaults to ``None``, + in which case the default event loop is used via ``asyncio.get_event_loop()``. + connector : aiohttp.BaseConnector + The `connector`_ to use for connection pooling. + proxy : Optional[str] + Proxy URL. + proxy_auth : Optional[aiohttp.BasicAuth] + An object that represents proxy HTTP Basic Authorization. + shard_id : Optional[int] + Integer starting at 0 and less than shard_count. + shard_count : Optional[int] + The total number of shards. + fetch_offline_members: bool + Indicates if :func:`on_ready` should be delayed to fetch all offline + members from the guilds the bot belongs to. If this is ``False``\, then + no offline members are received and :meth:`request_offline_members` + must be used to fetch the offline members of the guild. + game: Optional[:class:`Game`] + A game to start your presence with upon logging on to Discord. + status: Optional[:class:`Status`] + A status to start your presence with upon logging on to Discord. + heartbeat_timeout: float + The maximum numbers of seconds before timing out and restarting the + WebSocket in the case of not receiving a HEARTBEAT_ACK. Useful if + processing the initial packets take too long to the point of disconnecting + you. The default timeout is 60 seconds. + + Attributes + ----------- + ws + The websocket gateway the client is currently connected to. Could be None. + loop + The `event loop`_ that the client uses for HTTP requests and websocket operations. + """ + def __init__(self, *, loop=None, **options): + self.ws = None + self.loop = asyncio.get_event_loop() if loop is None else loop + self._listeners = {} + self.shard_id = options.get('shard_id') + self.shard_count = options.get('shard_count') + + connector = options.pop('connector', None) + proxy = options.pop('proxy', None) + proxy_auth = options.pop('proxy_auth', None) + self.http = HTTPClient(connector, proxy=proxy, proxy_auth=proxy_auth, loop=self.loop) + + self._connection = ConnectionState(dispatch=self.dispatch, chunker=self._chunker, + syncer=self._syncer, http=self.http, loop=self.loop, **options) + + self._connection.shard_count = self.shard_count + self._closed = asyncio.Event(loop=self.loop) + self._ready = asyncio.Event(loop=self.loop) + self._connection._get_websocket = lambda g: self.ws + + if VoiceClient.warn_nacl: + VoiceClient.warn_nacl = False + log.warning("PyNaCl is not installed, voice will NOT be supported") + + # internals + + @asyncio.coroutine + def _syncer(self, guilds): + yield from self.ws.request_sync(guilds) + + @asyncio.coroutine + def _chunker(self, guild): + try: + guild_id = guild.id + except AttributeError: + guild_id = [s.id for s in guild] + + payload = { + 'op': 8, + 'd': { + 'guild_id': guild_id, + 'query': '', + 'limit': 0 + } + } + + yield from self.ws.send_as_json(payload) + + def handle_ready(self): + self._ready.set() + + def _resolve_invite(self, invite): + if isinstance(invite, Invite) or isinstance(invite, Object): + return invite.id + else: + rx = r'(?:https?\:\/\/)?discord\.gg\/(.+)' + m = re.match(rx, invite) + if m: + return m.group(1) + return invite + + @property + def latency(self): + """float: Measures latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds. + + This could be referred to as the Discord WebSocket protocol latency. + """ + ws = self.ws + return float('nan') if not ws else ws.latency + + @property + def user(self): + """Optional[:class:`ClientUser`]: Represents the connected client. None if not logged in.""" + return self._connection.user + + @property + def guilds(self): + """List[:class:`Guild`]: The guilds that the connected client is a member of.""" + return self._connection.guilds + + @property + def emojis(self): + """List[:class:`Emoji`]: The emojis that the connected client has.""" + return self._connection.emojis + + @property + def private_channels(self): + """List[:class:`abc.PrivateChannel`]: The private channels that the connected client is participating on. + + .. note:: + + This returns only up to 128 most recent private channels due to an internal working + on how Discord deals with private channels. + """ + return self._connection.private_channels + + @property + def voice_clients(self): + """List[:class:`VoiceClient`]: Represents a list of voice connections.""" + return self._connection.voice_clients + + def is_ready(self): + """bool: Specifies if the client's internal cache is ready for use.""" + return self._ready.is_set() + + @asyncio.coroutine + def _run_event(self, coro, event_name, *args, **kwargs): + try: + yield from coro(*args, **kwargs) + except asyncio.CancelledError: + pass + except Exception: + try: + yield from self.on_error(event_name, *args, **kwargs) + except asyncio.CancelledError: + pass + + def dispatch(self, event, *args, **kwargs): + log.debug('Dispatching event %s', event) + method = 'on_' + event + handler = 'handle_' + event + + listeners = self._listeners.get(event) + if listeners: + removed = [] + for i, (future, condition) in enumerate(listeners): + if future.cancelled(): + removed.append(i) + continue + + try: + result = condition(*args) + except Exception as e: + future.set_exception(e) + removed.append(i) + else: + if result: + if len(args) == 0: + future.set_result(None) + elif len(args) == 1: + future.set_result(args[0]) + else: + future.set_result(args) + removed.append(i) + + if len(removed) == len(listeners): + self._listeners.pop(event) + else: + for idx in reversed(removed): + del listeners[idx] + + try: + actual_handler = getattr(self, handler) + except AttributeError: + pass + else: + actual_handler(*args, **kwargs) + + try: + coro = getattr(self, method) + except AttributeError: + pass + else: + compat.create_task(self._run_event(coro, method, *args, **kwargs), loop=self.loop) + + @asyncio.coroutine + def on_error(self, event_method, *args, **kwargs): + """|coro| + + The default error handler provided by the client. + + By default this prints to ``sys.stderr`` however it could be + overridden to have a different implementation. + Check :func:`discord.on_error` for more details. + """ + print('Ignoring exception in {}'.format(event_method), file=sys.stderr) + traceback.print_exc() + + @asyncio.coroutine + def request_offline_members(self, *guilds): + """|coro| + + Requests previously offline members from the guild to be filled up + into the :attr:`Guild.members` cache. This function is usually not + called. It should only be used if you have the ``fetch_offline_members`` + parameter set to ``False``. + + When the client logs on and connects to the websocket, Discord does + not provide the library with offline members if the number of members + in the guild is larger than 250. You can check if a guild is large + if :attr:`Guild.large` is ``True``. + + Parameters + ----------- + \*guilds + An argument list of guilds to request offline members for. + + Raises + ------- + InvalidArgument + If any guild is unavailable or not large in the collection. + """ + if any(not g.large or g.unavailable for g in guilds): + raise InvalidArgument('An unavailable or non-large guild was passed.') + + yield from self._connection.request_offline_members(guilds) + + # login state management + + @asyncio.coroutine + def login(self, token, *, bot=True): + """|coro| + + Logs in the client with the specified credentials. + + This function can be used in two different ways. + + Parameters + ----------- + token: str + The authentication token. Do not prefix this token with + anything as the library will do it for you. + bot: bool + Keyword argument that specifies if the account logging on is a bot + token or not. + + Raises + ------ + LoginFailure + The wrong credentials are passed. + HTTPException + An unknown HTTP related error occurred, + usually when it isn't 200 or the known incorrect credentials + passing status code. + """ + + log.info('logging in using static token') + yield from self.http.static_login(token, bot=bot) + self._connection.is_bot = bot + + @asyncio.coroutine + def logout(self): + """|coro| + + Logs out of Discord and closes all connections. + """ + yield from self.close() + + @asyncio.coroutine + def _connect(self): + coro = DiscordWebSocket.from_client(self, shard_id=self.shard_id) + self.ws = yield from asyncio.wait_for(coro, timeout=180.0, loop=self.loop) + while True: + try: + yield from self.ws.poll_event() + except ResumeWebSocket as e: + log.info('Got a request to RESUME the websocket.') + coro = DiscordWebSocket.from_client(self, shard_id=self.shard_id, + session=self.ws.session_id, + sequence=self.ws.sequence, + resume=True) + self.ws = yield from asyncio.wait_for(coro, timeout=180.0, loop=self.loop) + + @asyncio.coroutine + def connect(self, *, reconnect=True): + """|coro| + + Creates a websocket connection and lets the websocket listen + to messages from discord. This is a loop that runs the entire + event system and miscellaneous aspects of the library. Control + is not resumed until the WebSocket connection is terminated. + + Parameters + ----------- + reconnect: bool + If we should attempt reconnecting, either due to internet + failure or a specific failure on Discord's part. Certain + disconnects that lead to bad state will not be handled (such as + invalid sharding payloads or bad tokens). + + Raises + ------- + GatewayNotFound + If the gateway to connect to discord is not found. Usually if this + is thrown then there is a discord API outage. + ConnectionClosed + The websocket connection has been terminated. + """ + + backoff = ExponentialBackoff() + while not self.is_closed(): + try: + yield from self._connect() + except (OSError, + HTTPException, + GatewayNotFound, + ConnectionClosed, + aiohttp.ClientError, + asyncio.TimeoutError, + websockets.InvalidHandshake, + websockets.WebSocketProtocolError) as e: + + if not reconnect: + yield from self.close() + if isinstance(e, ConnectionClosed) and e.code == 1000: + # clean close, don't re-raise this + return + raise + + if self.is_closed(): + return + + # We should only get this when an unhandled close code happens, + # such as a clean disconnect (1000) or a bad state (bad token, no sharding, etc) + # sometimes, discord sends us 1000 for unknown reasons so we should reconnect + # regardless and rely on is_closed instead + if isinstance(e, ConnectionClosed): + if e.code != 1000: + yield from self.close() + raise + + retry = backoff.delay() + log.exception("Attempting a reconnect in %.2fs", retry) + yield from asyncio.sleep(retry, loop=self.loop) + + @asyncio.coroutine + def close(self): + """|coro| + + Closes the connection to discord. + """ + if self.is_closed(): + return + + self._closed.set() + + for voice in self.voice_clients: + try: + yield from voice.disconnect() + except: + # if an error happens during disconnects, disregard it. + pass + + if self.ws is not None and self.ws.open: + yield from self.ws.close() + + + yield from self.http.close() + self._ready.clear() + + @asyncio.coroutine + def start(self, *args, **kwargs): + """|coro| + + A shorthand coroutine for :meth:`login` + :meth:`connect`. + """ + + bot = kwargs.pop('bot', True) + reconnect = kwargs.pop('reconnect', True) + yield from self.login(*args, bot=bot) + yield from self.connect(reconnect=reconnect) + + def _do_cleanup(self): + log.info('Cleaning up event loop.') + loop = self.loop + if loop.is_closed(): + return # we're already cleaning up + + task = compat.create_task(self.close(), loop=loop) + + def _silence_gathered(fut): + try: + fut.result() + except: + pass + finally: + loop.stop() + + def when_future_is_done(fut): + pending = asyncio.Task.all_tasks(loop=loop) + if pending: + log.info('Cleaning up after %s tasks', len(pending)) + gathered = asyncio.gather(*pending, loop=loop) + gathered.cancel() + gathered.add_done_callback(_silence_gathered) + else: + loop.stop() + + task.add_done_callback(when_future_is_done) + if not loop.is_running(): + loop.run_forever() + else: + # on Linux, we're still running because we got triggered via + # the signal handler rather than the natural KeyboardInterrupt + # Since that's the case, we're going to return control after + # registering the task for the event loop to handle later + return None + + try: + return task.result() # suppress unused task warning + except: + return None + + def run(self, *args, **kwargs): + """A blocking call that abstracts away the `event loop`_ + initialisation from you. + + If you want more control over the event loop then this + function should not be used. Use :meth:`start` coroutine + or :meth:`connect` + :meth:`login`. + + Roughly Equivalent to: :: + + try: + loop.run_until_complete(start(*args, **kwargs)) + except KeyboardInterrupt: + loop.run_until_complete(logout()) + # cancel all tasks lingering + finally: + loop.close() + + Warning + -------- + This function must be the last function to call due to the fact that it + is blocking. That means that registration of events or anything being + called after this function call will not execute until it returns. + """ + is_windows = sys.platform == 'win32' + loop = self.loop + if not is_windows: + loop.add_signal_handler(signal.SIGINT, self._do_cleanup) + loop.add_signal_handler(signal.SIGTERM, self._do_cleanup) + + task = compat.create_task(self.start(*args, **kwargs), loop=loop) + + def stop_loop_on_finish(fut): + loop.stop() + + task.add_done_callback(stop_loop_on_finish) + + try: + loop.run_forever() + except KeyboardInterrupt: + log.info('Received signal to terminate bot and event loop.') + finally: + task.remove_done_callback(stop_loop_on_finish) + if is_windows: + self._do_cleanup() + + loop.close() + if task.cancelled() or not task.done(): + return None + return task.result() + + # properties + + def is_closed(self): + """bool: Indicates if the websocket connection is closed.""" + return self._closed.is_set() + + # helpers/getters + + @property + def users(self): + """Returns a list of all the :class:`User` the bot can see.""" + return list(self._connection._users.values()) + + def get_channel(self, id): + """Returns a :class:`abc.GuildChannel` or :class:`abc.PrivateChannel` with the following ID. + + If not found, returns None. + """ + return self._connection.get_channel(id) + + def get_guild(self, id): + """Returns a :class:`Guild` with the given ID. If not found, returns None.""" + return self._connection._get_guild(id) + + def get_user(self, id): + """Returns a :class:`User` with the given ID. If not found, returns None.""" + return self._connection.get_user(id) + + def get_emoji(self, id): + """Returns a :class:`Emoji` with the given ID. If not found, returns None.""" + return self._connection.get_emoji(id) + + def get_all_channels(self): + """A generator that retrieves every :class:`abc.GuildChannel` the client can 'access'. + + This is equivalent to: :: + + for guild in client.guilds: + for channel in guild.channels: + yield channel + + Note + ----- + Just because you receive a :class:`abc.GuildChannel` does not mean that + you can communicate in said channel. :meth:`abc.GuildChannel.permissions_for` should + be used for that. + """ + + for guild in self.guilds: + for channel in guild.channels: + yield channel + + def get_all_members(self): + """Returns a generator with every :class:`Member` the client can see. + + This is equivalent to: :: + + for guild in client.guilds: + for member in guild.members: + yield member + + """ + for guild in self.guilds: + for member in guild.members: + yield member + + # listeners/waiters + + @asyncio.coroutine + def wait_until_ready(self): + """|coro| + + Waits until the client's internal cache is all ready. + """ + yield from self._ready.wait() + + def wait_for(self, event, *, check=None, timeout=None): + """|coro| + + Waits for a WebSocket event to be dispatched. + + This could be used to wait for a user to reply to a message, + or to react to a message, or to edit a message in a self-contained + way. + + The ``timeout`` parameter is passed onto `asyncio.wait_for`_. By default, + it does not timeout. Note that this does propagate the + ``asyncio.TimeoutError`` for you in case of timeout and is provided for + ease of use. + + In case the event returns multiple arguments, a tuple containing those + arguments is returned instead. Please check the + :ref:`documentation ` for a list of events and their + parameters. + + This function returns the **first event that meets the requirements**. + + .. _asyncio.wait_for: https://docs.python.org/3/library/asyncio-task.html#asyncio.wait_for + + Examples + --------- + + Waiting for a user reply: :: + + @client.event + async def on_message(message): + if message.content.startswith('$greet'): + channel = message.channel + await channel.send('Say hello!') + + def check(m): + return m.content == 'hello' and m.channel == channel + + msg = await client.wait_for('message', check=check) + await channel.send('Hello {.author}!'.format(msg)) + + Waiting for a thumbs up reaction from the message author: :: + + @client.event + async def on_message(message): + if message.content.startswith('$thumb'): + channel = message.channel + await channel.send('Send me that \N{THUMBS UP SIGN} reaction, mate') + + def check(reaction, user): + return user == message.author and str(reaction.emoji) == '\N{THUMBS UP SIGN}' + + try: + reaction, user = await client.wait_for('reaction_add', timeout=60.0, check=check) + except asyncio.TimeoutError: + await channel.send('\N{THUMBS DOWN SIGN}') + else: + await channel.send('\N{THUMBS UP SIGN}') + + + Parameters + ------------ + event: str + The event name, similar to the :ref:`event reference `, + but without the ``on_`` prefix, to wait for. + check: Optional[predicate] + A predicate to check what to wait for. The arguments must meet the + parameters of the event being waited for. + timeout: Optional[float] + The number of seconds to wait before timing out and raising + ``asyncio.TimeoutError``\. + + Raises + ------- + asyncio.TimeoutError + If a timeout is provided and it was reached. + + Returns + -------- + Any + Returns no arguments, a single argument, or a tuple of multiple + arguments that mirrors the parameters passed in the + :ref:`event reference `. + """ + + future = compat.create_future(self.loop) + if check is None: + def _check(*args): + return True + check = _check + + ev = event.lower() + try: + listeners = self._listeners[ev] + except KeyError: + listeners = [] + self._listeners[ev] = listeners + + listeners.append((future, check)) + return asyncio.wait_for(future, timeout, loop=self.loop) + + # event registration + + def event(self, coro): + """A decorator that registers an event to listen to. + + You can find more info about the events on the :ref:`documentation below `. + + The events must be a |corourl|_, if not, :exc:`ClientException` is raised. + + Examples + --------- + + Using the basic :meth:`event` decorator: :: + + @client.event + @asyncio.coroutine + def on_ready(): + print('Ready!') + + Saving characters by using the :meth:`async_event` decorator: :: + + @client.async_event + def on_ready(): + print('Ready!') + + """ + + if not asyncio.iscoroutinefunction(coro): + raise ClientException('event registered must be a coroutine function') + + setattr(self, coro.__name__, coro) + log.info('%s has successfully been registered as an event', coro.__name__) + return coro + + def async_event(self, coro): + """A shorthand decorator for ``asyncio.coroutine`` + :meth:`event`.""" + if not asyncio.iscoroutinefunction(coro): + coro = asyncio.coroutine(coro) + + return self.event(coro) + + @asyncio.coroutine + def change_presence(self, *, game=None, status=None, afk=False): + """|coro| + + Changes the client's presence. + + The game parameter is a Game object (not a string) that represents + a game being played currently. + + Example: :: + + game = discord.Game(name="with the API") + await client.change_presence(status=discord.Status.idle, game=game) + + Parameters + ---------- + game: Optional[:class:`Game`] + The game being played. None if no game is being played. + status: Optional[:class:`Status`] + Indicates what status to change to. If None, then + :attr:`Status.online` is used. + afk: bool + Indicates if you are going AFK. This allows the discord + client to know how to handle push notifications better + for you in case you are actually idle and not lying. + + Raises + ------ + InvalidArgument + If the ``game`` parameter is not :class:`Game` or None. + """ + + if status is None: + status = 'online' + status_enum = Status.online + elif status is Status.offline: + status = 'invisible' + status_enum = Status.offline + else: + status_enum = status + status = str(status) + + yield from self.ws.change_presence(game=game, status=status, afk=afk) + + for guild in self._connection.guilds: + me = guild.me + if me is None: + continue + + me.game = game + me.status = status_enum + + # Guild stuff + + @asyncio.coroutine + def create_guild(self, name, region=None, icon=None): + """|coro| + + Creates a :class:`Guild`. + + Bot accounts generally are not allowed to create servers. + + Parameters + ---------- + name: str + The name of the guild. + region: :class:`VoiceRegion` + The region for the voice communication server. + Defaults to :attr:`VoiceRegion.us_west`. + icon: bytes + The *bytes-like* object representing the icon. See :meth:`~ClientUser.edit` + for more details on what is expected. + + Raises + ------ + HTTPException + Guild creation failed. + InvalidArgument + Invalid icon image format given. Must be PNG or JPG. + + Returns + ------- + :class:`Guild` + The guild created. This is not the same guild that is + added to cache. + """ + if icon is not None: + icon = utils._bytes_to_base64_data(icon) + + if region is None: + region = VoiceRegion.us_west.value + else: + region = region.value + + data = yield from self.http.create_guild(name, region, icon) + return Guild(data=data, state=self._connection) + + # Invite management + + @asyncio.coroutine + def get_invite(self, url): + """|coro| + + Gets a :class:`Invite` from a discord.gg URL or ID. + + Note + ------ + If the invite is for a guild you have not joined, the guild and channel + attributes of the returned invite will be :class:`Object` with the names + patched in. + + Parameters + ----------- + url : str + The discord invite ID or URL (must be a discord.gg URL). + + Raises + ------- + NotFound + The invite has expired or is invalid. + HTTPException + Getting the invite failed. + + Returns + -------- + :class:`Invite` + The invite from the URL/ID. + """ + + invite_id = self._resolve_invite(url) + data = yield from self.http.get_invite(invite_id) + return Invite.from_incomplete(state=self._connection, data=data) + + @asyncio.coroutine + def delete_invite(self, invite): + """|coro| + + Revokes an :class:`Invite`, URL, or ID to an invite. + + Parameters + ---------- + invite + The invite to revoke. + + Raises + ------- + Forbidden + You do not have permissions to revoke invites. + NotFound + The invite is invalid or expired. + HTTPException + Revoking the invite failed. + """ + + invite_id = self._resolve_invite(invite) + yield from self.http.delete_invite(invite_id) + + # Miscellaneous stuff + + @asyncio.coroutine + def application_info(self): + """|coro| + + Retrieve's the bot's application information. + + Returns + -------- + :class:`AppInfo` + A namedtuple representing the application info. + + Raises + ------- + HTTPException + Retrieving the information failed somehow. + """ + data = yield from self.http.application_info() + return AppInfo(id=data['id'], name=data['name'], + description=data['description'], icon=data['icon'], + owner=User(state=self._connection, data=data['owner'])) + + @asyncio.coroutine + def get_user_info(self, user_id): + """|coro| + + Retrieves a :class:`User` based on their ID. This can only + be used by bot accounts. You do not have to share any guilds + with the user to get this information, however many operations + do require that you do. + + Parameters + ----------- + user_id: int + The user's ID to fetch from. + + Returns + -------- + :class:`User` + The user you requested. + + Raises + ------- + NotFound + A user with this ID does not exist. + HTTPException + Fetching the user failed. + """ + data = yield from self.http.get_user_info(user_id) + return User(state=self._connection, data=data) + + @asyncio.coroutine + def get_user_profile(self, user_id): + """|coro| + + Gets an arbitrary user's profile. This can only be used by non-bot accounts. + + Parameters + ------------ + user_id: int + The ID of the user to fetch their profile for. + + Raises + ------- + Forbidden + Not allowed to fetch profiles. + HTTPException + Fetching the profile failed. + + Returns + -------- + :class:`Profile` + The profile of the user. + """ + + state = self._connection + data = yield from self.http.get_user_profile(user_id) + + def transform(d): + return state._get_guild(int(d['id'])) + + since = data.get('premium_since') + mutual_guilds = list(filter(None, map(transform, data.get('mutual_guilds', [])))) + user = data['user'] + return Profile(flags=user.get('flags', 0), + premium_since=utils.parse_time(since), + mutual_guilds=mutual_guilds, + user=User(data=user, state=state), + connected_accounts=data['connected_accounts']) + + @asyncio.coroutine + def get_webhook_info(self, webhook_id): + """|coro| + + Retrieves a :class:`Webhook` with the specified ID. + + Raises + -------- + HTTPException + Retrieving the webhook failed. + NotFound + Invalid webhook ID. + Forbidden + You do not have permission to fetch this webhook. + + Returns + --------- + :class:`Webhook` + The webhook you requested. + """ + data = yield from self.http.get_webhook(webhook_id) + return Webhook.from_state(data, state=self._connection) diff --git a/discord.py-rewrite/discord/colour.py b/discord.py-rewrite/discord/colour.py new file mode 100644 index 0000000..9644b27 --- /dev/null +++ b/discord.py-rewrite/discord/colour.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +class Colour: + """Represents a Discord role colour. This class is similar + to an (red, green, blue) tuple. + + There is an alias for this called Color. + + .. container:: operations + + .. describe:: x == y + + Checks if two colours are equal. + + .. describe:: x != y + + Checks if two colours are not equal. + + .. describe:: hash(x) + + Return the colour's hash. + + .. describe:: str(x) + + Returns the hex format for the colour. + + Attributes + ------------ + value: int + The raw integer colour value. + """ + + __slots__ = ('value',) + + def __init__(self, value): + if not isinstance(value, int): + raise TypeError('Expected int parameter, received %s instead.' % value.__class__.__name__) + + self.value = value + + def _get_byte(self, byte): + return (self.value >> (8 * byte)) & 0xff + + def __eq__(self, other): + return isinstance(other, Colour) and self.value == other.value + + def __ne__(self, other): + return not self.__eq__(other) + + def __str__(self): + return '#{:0>6x}'.format(self.value) + + def __repr__(self): + return '' % self.value + + def __hash__(self): + return hash(self.value) + + @property + def r(self): + """Returns the red component of the colour.""" + return self._get_byte(2) + + @property + def g(self): + """Returns the green component of the colour.""" + return self._get_byte(1) + + @property + def b(self): + """Returns the blue component of the colour.""" + return self._get_byte(0) + + def to_rgb(self): + """Returns an (r, g, b) tuple representing the colour.""" + return (self.r, self.g, self.b) + + @classmethod + def from_rgb(cls, r, g, b): + """Constructs a :class:`Colour` from an RGB tuple.""" + return cls((r << 16) + (g << 8) + b) + + @classmethod + def default(cls): + """A factory method that returns a :class:`Colour` with a value of 0.""" + return cls(0) + + @classmethod + def teal(cls): + """A factory method that returns a :class:`Colour` with a value of ``0x1abc9c``.""" + return cls(0x1abc9c) + + @classmethod + def dark_teal(cls): + """A factory method that returns a :class:`Colour` with a value of ``0x11806a``.""" + return cls(0x11806a) + + @classmethod + def green(cls): + """A factory method that returns a :class:`Colour` with a value of ``0x2ecc71``.""" + return cls(0x2ecc71) + + @classmethod + def dark_green(cls): + """A factory method that returns a :class:`Colour` with a value of ``0x1f8b4c``.""" + return cls(0x1f8b4c) + + @classmethod + def blue(cls): + """A factory method that returns a :class:`Colour` with a value of ``0x3498db``.""" + return cls(0x3498db) + + @classmethod + def dark_blue(cls): + """A factory method that returns a :class:`Colour` with a value of ``0x206694``.""" + return cls(0x206694) + + @classmethod + def purple(cls): + """A factory method that returns a :class:`Colour` with a value of ``0x9b59b6``.""" + return cls(0x9b59b6) + + @classmethod + def dark_purple(cls): + """A factory method that returns a :class:`Colour` with a value of ``0x71368a``.""" + return cls(0x71368a) + + @classmethod + def magenta(cls): + """A factory method that returns a :class:`Colour` with a value of ``0xe91e63``.""" + return cls(0xe91e63) + + @classmethod + def dark_magenta(cls): + """A factory method that returns a :class:`Colour` with a value of ``0xad1457``.""" + return cls(0xad1457) + + @classmethod + def gold(cls): + """A factory method that returns a :class:`Colour` with a value of ``0xf1c40f``.""" + return cls(0xf1c40f) + + @classmethod + def dark_gold(cls): + """A factory method that returns a :class:`Colour` with a value of ``0xc27c0e``.""" + return cls(0xc27c0e) + + @classmethod + def orange(cls): + """A factory method that returns a :class:`Colour` with a value of ``0xe67e22``.""" + return cls(0xe67e22) + + @classmethod + def dark_orange(cls): + """A factory method that returns a :class:`Colour` with a value of ``0xa84300``.""" + return cls(0xa84300) + + @classmethod + def red(cls): + """A factory method that returns a :class:`Colour` with a value of ``0xe74c3c``.""" + return cls(0xe74c3c) + + @classmethod + def dark_red(cls): + """A factory method that returns a :class:`Colour` with a value of ``0x992d22``.""" + return cls(0x992d22) + + @classmethod + def lighter_grey(cls): + """A factory method that returns a :class:`Colour` with a value of ``0x95a5a6``.""" + return cls(0x95a5a6) + + @classmethod + def dark_grey(cls): + """A factory method that returns a :class:`Colour` with a value of ``0x607d8b``.""" + return cls(0x607d8b) + + @classmethod + def light_grey(cls): + """A factory method that returns a :class:`Colour` with a value of ``0x979c9f``.""" + return cls(0x979c9f) + + @classmethod + def darker_grey(cls): + """A factory method that returns a :class:`Colour` with a value of ``0x546e7a``.""" + return cls(0x546e7a) + + @classmethod + def blurple(cls): + """A factory method that returns a :class:`Colour` with a value of ``0x7289da``.""" + return cls(0x7289da) + + @classmethod + def greyple(cls): + """A factory method that returns a :class:`Colour` with a value of ``0x99aab5``.""" + return cls(0x99aab5) + +Color = Colour diff --git a/discord.py-rewrite/discord/compat.py b/discord.py-rewrite/discord/compat.py new file mode 100644 index 0000000..3b9e66f --- /dev/null +++ b/discord.py-rewrite/discord/compat.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import concurrent.futures +import asyncio + +try: + create_task = asyncio.ensure_future +except AttributeError: + create_task = asyncio.async + +try: + _create_future = asyncio.AbstractEventLoop.create_future +except AttributeError: + def create_future(loop): + return asyncio.Future(loop=loop) +else: + def create_future(loop): + return loop.create_future() + +try: + run_coroutine_threadsafe = asyncio.run_coroutine_threadsafe +except AttributeError: + # the following code is slightly modified from the + # official asyncio repository that could be found here: + # https://github.com/python/asyncio/blob/master/asyncio/futures.py + # with a commit hash of 5c7efbcdfbe6a5c25b4cd5df22d9a15ab4062c8e + # this portion is licensed under Apache license 2.0 + + def _set_concurrent_future_state(concurrent, source): + """Copy state from a future to a concurrent.futures.Future.""" + assert source.done() + if source.cancelled(): + concurrent.cancel() + if not concurrent.set_running_or_notify_cancel(): + return + exception = source.exception() + if exception is not None: + concurrent.set_exception(exception) + else: + result = source.result() + concurrent.set_result(result) + + + def _copy_future_state(source, dest): + """Internal helper to copy state from another Future. + The other Future may be a concurrent.futures.Future. + """ + assert source.done() + if dest.cancelled(): + return + assert not dest.done() + if source.cancelled(): + dest.cancel() + else: + exception = source.exception() + if exception is not None: + dest.set_exception(exception) + else: + result = source.result() + dest.set_result(result) + + def _chain_future(source, destination): + """Chain two futures so that when one completes, so does the other. + The result (or exception) of source will be copied to destination. + If destination is cancelled, source gets cancelled too. + Compatible with both asyncio.Future and concurrent.futures.Future. + """ + if not isinstance(source, (asyncio.Future, concurrent.futures.Future)): + raise TypeError('A future is required for source argument') + + if not isinstance(destination, (asyncio.Future, concurrent.futures.Future)): + raise TypeError('A future is required for destination argument') + + source_loop = source._loop if isinstance(source, asyncio.Future) else None + dest_loop = destination._loop if isinstance(destination, asyncio.Future) else None + + def _set_state(future, other): + if isinstance(future, asyncio.Future): + _copy_future_state(other, future) + else: + _set_concurrent_future_state(future, other) + + def _call_check_cancel(destination): + if destination.cancelled(): + if source_loop is None or source_loop is dest_loop: + source.cancel() + else: + source_loop.call_soon_threadsafe(source.cancel) + + def _call_set_state(source): + if dest_loop is None or dest_loop is source_loop: + _set_state(destination, source) + else: + dest_loop.call_soon_threadsafe(_set_state, destination, source) + + destination.add_done_callback(_call_check_cancel) + source.add_done_callback(_call_set_state) + + def run_coroutine_threadsafe(coro, loop): + """Submit a coroutine object to a given event loop. + + Return a concurrent.futures.Future to access the result. + """ + if not asyncio.iscoroutine(coro): + raise TypeError('A coroutine object is required') + + future = concurrent.futures.Future() + + def callback(): + try: + _chain_future(create_task(coro, loop=loop), future) + except Exception as exc: + if future.set_running_or_notify_cancel(): + future.set_exception(exc) + raise + loop.call_soon_threadsafe(callback) + return future diff --git a/discord.py-rewrite/discord/context_managers.py b/discord.py-rewrite/discord/context_managers.py new file mode 100644 index 0000000..93d292c --- /dev/null +++ b/discord.py-rewrite/discord/context_managers.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import asyncio + +from .compat import create_task + +def _typing_done_callback(fut): + # just retrieve any exception and call it a day + try: + fut.exception() + except: + pass + +class Typing: + def __init__(self, messageable): + self.loop = messageable._state.loop + self.messageable = messageable + + @asyncio.coroutine + def do_typing(self): + try: + channel = self._channel + except AttributeError: + channel = yield from self.messageable._get_channel() + + typing = channel._state.http.send_typing + + while True: + yield from typing(channel.id) + yield from asyncio.sleep(5) + + def __enter__(self): + self.task = create_task(self.do_typing(), loop=self.loop) + self.task.add_done_callback(_typing_done_callback) + return self + + def __exit__(self, exc_type, exc, tb): + self.task.cancel() + + @asyncio.coroutine + def __aenter__(self): + self._channel = channel = yield from self.messageable._get_channel() + yield from channel._state.http.send_typing(channel.id) + return self.__enter__() + + @asyncio.coroutine + def __aexit__(self, exc_type, exc, tb): + self.task.cancel() diff --git a/discord.py-rewrite/discord/embeds.py b/discord.py-rewrite/discord/embeds.py new file mode 100644 index 0000000..77d0c7b --- /dev/null +++ b/discord.py-rewrite/discord/embeds.py @@ -0,0 +1,478 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import datetime + +from . import utils +from .colour import Colour + +class _EmptyEmbed: + def __bool__(self): + return False + + def __repr__(self): + return 'Embed.Empty' + +EmptyEmbed = _EmptyEmbed() + +class EmbedProxy: + def __init__(self, layer): + self.__dict__.update(layer) + + def __len__(self): + return len(self.__dict__) + + def __repr__(self): + return 'EmbedProxy(%s)' % ', '.join(('%s=%r' % (k, v) for k, v in self.__dict__.items() if not k.startswith('_'))) + + def __getattr__(self, attr): + return EmptyEmbed + +class Embed: + """Represents a Discord embed. + + The following attributes can be set during creation + of the object: + + Certain properties return an ``EmbedProxy``. Which is a type + that acts similar to a regular `dict` except access the attributes + via dotted access, e.g. ``embed.author.icon_url``. If the attribute + is invalid or empty, then a special sentinel value is returned, + :attr:`Embed.Empty`. + + For ease of use, all parameters that expect a ``str`` are implicitly + casted to ``str`` for you. + + Attributes + ----------- + title: str + The title of the embed. + type: str + The type of embed. Usually "rich". + description: str + The description of the embed. + url: str + The URL of the embed. + timestamp: `datetime.datetime` + The timestamp of the embed content. This could be a naive or aware datetime. + colour: :class:`Colour` or int + The colour code of the embed. Aliased to ``color`` as well. + Empty + A special sentinel value used by ``EmbedProxy`` and this class + to denote that the value or attribute is empty. + """ + + __slots__ = ('title', 'url', 'type', '_timestamp', '_colour', '_footer', + '_image', '_thumbnail', '_video', '_provider', '_author', + '_fields', 'description') + + Empty = EmptyEmbed + + def __init__(self, **kwargs): + # swap the colour/color aliases + try: + colour = kwargs['colour'] + except KeyError: + colour = kwargs.get('color', EmptyEmbed) + + self.colour = colour + self.title = kwargs.get('title', EmptyEmbed) + self.type = kwargs.get('type', 'rich') + self.url = kwargs.get('url', EmptyEmbed) + self.description = kwargs.get('description', EmptyEmbed) + + try: + timestamp = kwargs['timestamp'] + except KeyError: + pass + else: + self.timestamp = timestamp + + @classmethod + def from_data(cls, data): + # we are bypassing __init__ here since it doesn't apply here + self = cls.__new__(cls) + + # fill in the basic fields + + self.title = data.get('title', EmptyEmbed) + self.type = data.get('type', EmptyEmbed) + self.description = data.get('description', EmptyEmbed) + self.url = data.get('url', EmptyEmbed) + + # try to fill in the more rich fields + + try: + self._colour = Colour(value=data['color']) + except KeyError: + pass + + try: + self._timestamp = utils.parse_time(data['timestamp']) + except KeyError: + pass + + for attr in ('thumbnail', 'video', 'provider', 'author', 'fields', 'image', 'footer'): + try: + value = data[attr] + except KeyError: + continue + else: + setattr(self, '_' + attr, value) + + return self + + @property + def colour(self): + return getattr(self, '_colour', EmptyEmbed) + + @colour.setter + def colour(self, value): + if isinstance(value, (Colour, _EmptyEmbed)): + self._colour = value + elif isinstance(value, int): + self._colour = Colour(value=value) + else: + raise TypeError('Expected discord.Colour, int, or Embed.Empty but received %s instead.' % value.__class__.__name__) + + color = colour + + @property + def timestamp(self): + return getattr(self, '_timestamp', EmptyEmbed) + + @timestamp.setter + def timestamp(self, value): + if isinstance(value, (datetime.datetime, _EmptyEmbed)): + self._timestamp = value + else: + raise TypeError("Expected datetime.datetime or Embed.Empty received %s instead" % value.__class__.__name__) + + @property + def footer(self): + """Returns an ``EmbedProxy`` denoting the footer contents. + + See :meth:`set_footer` for possible values you can access. + + If the attribute has no value then :attr:`Empty` is returned. + """ + return EmbedProxy(getattr(self, '_footer', {})) + + def set_footer(self, *, text=EmptyEmbed, icon_url=EmptyEmbed): + """Sets the footer for the embed content. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ----------- + text: str + The footer text. + icon_url: str + The URL of the footer icon. Only HTTP(S) is supported. + """ + + self._footer = {} + if text is not EmptyEmbed: + self._footer['text'] = str(text) + + if icon_url is not EmptyEmbed: + self._footer['icon_url'] = str(icon_url) + + return self + + @property + def image(self): + """Returns an ``EmbedProxy`` denoting the image contents. + + Possible attributes you can access are: + + - ``url`` + - ``proxy_url`` + - ``width`` + - ``height`` + + If the attribute has no value then :attr:`Empty` is returned. + """ + return EmbedProxy(getattr(self, '_image', {})) + + def set_image(self, *, url): + """Sets the image for the embed content. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ----------- + url: str + The source URL for the image. Only HTTP(S) is supported. + """ + + self._image = { + 'url': str(url) + } + + return self + + @property + def thumbnail(self): + """Returns an ``EmbedProxy`` denoting the thumbnail contents. + + Possible attributes you can access are: + + - ``url`` + - ``proxy_url`` + - ``width`` + - ``height`` + + If the attribute has no value then :attr:`Empty` is returned. + """ + return EmbedProxy(getattr(self, '_thumbnail', {})) + + def set_thumbnail(self, *, url): + """Sets the thumbnail for the embed content. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ----------- + url: str + The source URL for the thumbnail. Only HTTP(S) is supported. + """ + + self._thumbnail = { + 'url': str(url) + } + + return self + + @property + def video(self): + """Returns an ``EmbedProxy`` denoting the video contents. + + Possible attributes include: + + - ``url`` for the video URL. + - ``height`` for the video height. + - ``width`` for the video width. + + If the attribute has no value then :attr:`Empty` is returned. + """ + return EmbedProxy(getattr(self, '_video', {})) + + @property + def provider(self): + """Returns an ``EmbedProxy`` denoting the provider contents. + + The only attributes that might be accessed are ``name`` and ``url``. + + If the attribute has no value then :attr:`Empty` is returned. + """ + return EmbedProxy(getattr(self, '_provider', {})) + + @property + def author(self): + """Returns an ``EmbedProxy`` denoting the author contents. + + See :meth:`set_author` for possible values you can access. + + If the attribute has no value then :attr:`Empty` is returned. + """ + return EmbedProxy(getattr(self, '_author', {})) + + def set_author(self, *, name, url=EmptyEmbed, icon_url=EmptyEmbed): + """Sets the author for the embed content. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ----------- + name: str + The name of the author. + url: str + The URL for the author. + icon_url: str + The URL of the author icon. Only HTTP(S) is supported. + """ + + self._author = { + 'name': str(name) + } + + if url is not EmptyEmbed: + self._author['url'] = str(url) + + if icon_url is not EmptyEmbed: + self._author['icon_url'] = str(icon_url) + + return self + + @property + def fields(self): + """Returns a list of ``EmbedProxy`` denoting the field contents. + + See :meth:`add_field` for possible values you can access. + + If the attribute has no value then :attr:`Empty` is returned. + """ + return [EmbedProxy(d) for d in getattr(self, '_fields', [])] + + def add_field(self, *, name, value, inline=True): + """Adds a field to the embed object. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ----------- + name: str + The name of the field. + value: str + The value of the field. + inline: bool + Whether the field should be displayed inline. + """ + + field = { + 'inline': inline, + 'name': str(name), + 'value': str(value) + } + + try: + self._fields.append(field) + except AttributeError: + self._fields = [field] + + return self + + def clear_fields(self): + """Removes all fields from this embed.""" + try: + self._fields.clear() + except AttributeError: + self._fields = [] + + def remove_field(self, index): + """Removes a field at a specified index. + + If the index is invalid or out of bounds then the error is + silently swallowed. + + .. note:: + + When deleting a field by index, the index of the other fields + shift to fill the gap just like a regular list. + + Parameters + ----------- + index: int + The index of the field to remove. + """ + try: + del self._fields[index] + except (AttributeError, IndexError): + pass + + def set_field_at(self, index, *, name, value, inline=True): + """Modifies a field to the embed object. + + The index must point to a valid pre-existing field. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ----------- + index: int + The index of the field to modify. + name: str + The name of the field. + value: str + The value of the field. + inline: bool + Whether the field should be displayed inline. + + Raises + ------- + IndexError + An invalid index was provided. + """ + + try: + field = self._fields[index] + except (TypeError, IndexError, AttributeError): + raise IndexError('field index out of range') + + field['name'] = str(name) + field['value'] = str(value) + field['inline'] = inline + return self + + def to_dict(self): + """Converts this embed object into a dict.""" + + # add in the raw data into the dict + result = { + key[1:]: getattr(self, key) + for key in self.__slots__ + if key[0] == '_' and hasattr(self, key) + } + + # deal with basic convenience wrappers + + try: + colour = result.pop('colour') + except KeyError: + pass + else: + if colour: + result['color'] = colour.value + + try: + timestamp = result.pop('timestamp') + except KeyError: + pass + else: + if timestamp: + result['timestamp'] = timestamp.isoformat() + + # add in the non raw attribute ones + if self.type: + result['type'] = self.type + + if self.description: + result['description'] = self.description + + if self.url: + result['url'] = self.url + + if self.title: + result['title'] = self.title + + return result diff --git a/discord.py-rewrite/discord/emoji.py b/discord.py-rewrite/discord/emoji.py new file mode 100644 index 0000000..6faf8db --- /dev/null +++ b/discord.py-rewrite/discord/emoji.py @@ -0,0 +1,239 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import asyncio +from collections import namedtuple + +from . import utils +from .mixins import Hashable + +class PartialReactionEmoji(namedtuple('PartialReactionEmoji', 'name id')): + """Represents a "partial" reaction emoji. + + This model will be given in two scenarios: + + - "Raw" data events such as :func:`on_raw_reaction_add` + - Custom emoji that the bot cannot see from e.g. :attr:`Message.reactions` + + .. container:: operations + + .. describe:: x == y + + Checks if two emoji are the same. + + .. describe:: x != y + + Checks if two emoji are not the same. + + .. describe:: hash(x) + + Return the emoji's hash. + + .. describe:: str(x) + + Returns the emoji rendered for discord. + + Attributes + ----------- + name: str + The custom emoji name, if applicable, or the unicode codepoint + of the non-custom emoji. + id: Optional[int] + The ID of the custom emoji, if applicable. + """ + + __slots__ = () + + def __str__(self): + if self.id is None: + return self.name + return '<:%s:%s>' % (self.name, self.id) + + def is_custom_emoji(self): + """Checks if this is a custom non-Unicode emoji.""" + return self.id is not None + + def is_unicode_emoji(self): + """Checks if this is a Unicode emoji.""" + return self.id is None + + def _as_reaction(self): + if self.id is None: + return self.name + return ':%s:%s' % (self.name, self.id) + +class Emoji(Hashable): + """Represents a custom emoji. + + Depending on the way this object was created, some of the attributes can + have a value of ``None``. + + .. container:: operations + + .. describe:: x == y + + Checks if two emoji are the same. + + .. describe:: x != y + + Checks if two emoji are not the same. + + .. describe:: hash(x) + + Return the emoji's hash. + + .. describe:: iter(x) + + Returns an iterator of ``(field, value)`` pairs. This allows this class + to be used as an iterable in list/dict/etc constructions. + + .. describe:: str(x) + + Returns the emoji rendered for discord. + + Attributes + ----------- + name: str + The name of the emoji. + id: int + The emoji's ID. + require_colons: bool + If colons are required to use this emoji in the client (:PJSalt: vs PJSalt). + managed: bool + If this emoji is managed by a Twitch integration. + guild_id: int + The guild ID the emoji belongs to. + """ + __slots__ = ('require_colons', 'managed', 'id', 'name', '_roles', 'guild_id', '_state') + + def __init__(self, *, guild, state, data): + self.guild_id = guild.id + self._state = state + self._from_data(data) + + def _from_data(self, emoji): + self.require_colons = emoji['require_colons'] + self.managed = emoji['managed'] + self.id = int(emoji['id']) + self.name = emoji['name'] + self._roles = set(emoji.get('roles', [])) + + def _iterator(self): + for attr in self.__slots__: + if attr[0] != '_': + value = getattr(self, attr, None) + if value is not None: + yield (attr, value) + + def __iter__(self): + return self._iterator() + + def __str__(self): + return "<:{0.name}:{0.id}>".format(self) + + def __repr__(self): + return ''.format(self) + + @property + def created_at(self): + """Returns the emoji's creation time in UTC.""" + return utils.snowflake_time(self.id) + + @property + def url(self): + """Returns a URL version of the emoji.""" + return "https://cdn.discordapp.com/emojis/{0.id}.png".format(self) + + @property + def roles(self): + """List[:class:`Role`]: A list of roles that is allowed to use this emoji. + + If roles is empty, the emoji is unrestricted. + """ + guild = self.guild + if guild is None: + return [] + + return [role for role in guild.roles if role.id in self._roles] + + @property + def guild(self): + """:class:`Guild`: The guild this emoji belongs to.""" + return self._state._get_guild(self.guild_id) + + @asyncio.coroutine + def delete(self, *, reason=None): + """|coro| + + Deletes the custom emoji. + + You must have :attr:`~Permissions.manage_emojis` permission to + do this. + + Guild local emotes can only be deleted by user bots. + + Parameters + ----------- + reason: Optional[str] + The reason for deleting this emoji. Shows up on the audit log. + + Raises + ------- + Forbidden + You are not allowed to delete emojis. + HTTPException + An error occurred deleting the emoji. + """ + + yield from self._state.http.delete_custom_emoji(self.guild.id, self.id, reason=reason) + + @asyncio.coroutine + def edit(self, *, name, reason=None): + """|coro| + + Edits the custom emoji. + + You must have :attr:`~Permissions.manage_emojis` permission to + do this. + + Guild local emotes can only be edited by user bots. + + Parameters + ----------- + name: str + The new emoji name. + reason: Optional[str] + The reason for editing this emoji. Shows up on the audit log. + + Raises + ------- + Forbidden + You are not allowed to edit emojis. + HTTPException + An error occurred editing the emoji. + """ + + yield from self._state.http.edit_custom_emoji(self.guild.id, self.id, name=name, reason=reason) diff --git a/discord.py-rewrite/discord/enums.py b/discord.py-rewrite/discord/enums.py new file mode 100644 index 0000000..88857bd --- /dev/null +++ b/discord.py-rewrite/discord/enums.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from enum import Enum, IntEnum + +__all__ = ['ChannelType', 'MessageType', 'VoiceRegion', 'VerificationLevel', + 'ContentFilter', 'Status', 'DefaultAvatar', 'RelationshipType', + 'AuditLogAction', 'AuditLogActionCategory', 'UserFlags', ] + +class ChannelType(Enum): + text = 0 + private = 1 + voice = 2 + group = 3 + category = 4 + + def __str__(self): + return self.name + +class MessageType(Enum): + default = 0 + recipient_add = 1 + recipient_remove = 2 + call = 3 + channel_name_change = 4 + channel_icon_change = 5 + pins_add = 6 + new_member = 7 + +class VoiceRegion(Enum): + us_west = 'us-west' + us_east = 'us-east' + us_south = 'us-south' + us_central = 'us-central' + eu_west = 'eu-west' + eu_central = 'eu-central' + singapore = 'singapore' + london = 'london' + sydney = 'sydney' + amsterdam = 'amsterdam' + frankfurt = 'frankfurt' + brazil = 'brazil' + vip_us_east = 'vip-us-east' + vip_us_west = 'vip-us-west' + vip_amsterdam = 'vip-amsterdam' + + def __str__(self): + return self.value + +class VerificationLevel(IntEnum): + none = 0 + low = 1 + medium = 2 + high = 3 + table_flip = 3 + extreme = 4 + double_table_flip = 4 + + def __str__(self): + return self.name + +class ContentFilter(IntEnum): + disabled = 0 + no_role = 1 + all_members = 2 + + def __str__(self): + return self.name + +class Status(Enum): + online = 'online' + offline = 'offline' + idle = 'idle' + dnd = 'dnd' + do_not_disturb = 'dnd' + invisible = 'invisible' + + def __str__(self): + return self.value + +class DefaultAvatar(Enum): + blurple = 0 + grey = 1 + gray = 1 + green = 2 + orange = 3 + red = 4 + + def __str__(self): + return self.name + +class RelationshipType(Enum): + friend = 1 + blocked = 2 + incoming_request = 3 + outgoing_request = 4 + +class AuditLogActionCategory(Enum): + create = 1 + delete = 2 + update = 3 + +class AuditLogAction(Enum): + guild_update = 1 + channel_create = 10 + channel_update = 11 + channel_delete = 12 + overwrite_create = 13 + overwrite_update = 14 + overwrite_delete = 15 + kick = 20 + member_prune = 21 + ban = 22 + unban = 23 + member_update = 24 + member_role_update = 25 + role_create = 30 + role_update = 31 + role_delete = 32 + invite_create = 40 + invite_update = 41 + invite_delete = 42 + webhook_create = 50 + webhook_update = 51 + webhook_delete = 52 + emoji_create = 60 + emoji_update = 61 + emoji_delete = 62 + message_delete = 72 + + @property + def category(self): + lookup = { + AuditLogAction.guild_update: AuditLogActionCategory.update, + AuditLogAction.channel_create: AuditLogActionCategory.create, + AuditLogAction.channel_update: AuditLogActionCategory.update, + AuditLogAction.channel_delete: AuditLogActionCategory.delete, + AuditLogAction.overwrite_create: AuditLogActionCategory.create, + AuditLogAction.overwrite_update: AuditLogActionCategory.update, + AuditLogAction.overwrite_delete: AuditLogActionCategory.delete, + AuditLogAction.kick: None, + AuditLogAction.member_prune: None, + AuditLogAction.ban: None, + AuditLogAction.unban: None, + AuditLogAction.member_update: AuditLogActionCategory.update, + AuditLogAction.member_role_update: AuditLogActionCategory.update, + AuditLogAction.role_create: AuditLogActionCategory.create, + AuditLogAction.role_update: AuditLogActionCategory.update, + AuditLogAction.role_delete: AuditLogActionCategory.delete, + AuditLogAction.invite_create: AuditLogActionCategory.create, + AuditLogAction.invite_update: AuditLogActionCategory.update, + AuditLogAction.invite_delete: AuditLogActionCategory.delete, + AuditLogAction.webhook_create: AuditLogActionCategory.create, + AuditLogAction.webhook_update: AuditLogActionCategory.update, + AuditLogAction.webhook_delete: AuditLogActionCategory.delete, + AuditLogAction.emoji_create: AuditLogActionCategory.create, + AuditLogAction.emoji_update: AuditLogActionCategory.update, + AuditLogAction.emoji_delete: AuditLogActionCategory.delete, + AuditLogAction.message_delete: AuditLogActionCategory.delete, + } + return lookup[self] + + @property + def target_type(self): + v = self.value + if v == -1: + return 'all' + elif v < 10: + return 'guild' + elif v < 20: + return 'channel' + elif v < 30: + return 'user' + elif v < 40: + return 'role' + elif v < 50: + return 'invite' + elif v < 60: + return 'webhook' + elif v < 70: + return 'emoji' + elif v < 80: + return 'message' + +class UserFlags(Enum): + staff = 1 + partner = 2 + hypesquad = 4 + +def try_enum(cls, val): + """A function that tries to turn the value into enum ``cls``. + + If it fails it returns the value instead. + """ + try: + return cls(val) + except ValueError: + return val diff --git a/discord.py-rewrite/discord/errors.py b/discord.py-rewrite/discord/errors.py new file mode 100644 index 0000000..84768ca --- /dev/null +++ b/discord.py-rewrite/discord/errors.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +class DiscordException(Exception): + """Base exception class for discord.py + + Ideally speaking, this could be caught to handle any exceptions thrown from this library. + """ + pass + +class ClientException(DiscordException): + """Exception that's thrown when an operation in the :class:`Client` fails. + + These are usually for exceptions that happened due to user input. + """ + pass + +class NoMoreItems(DiscordException): + """Exception that is thrown when an async iteration operation has no more + items. This is mainly exposed for Python 3.4 support where `StopAsyncIteration` + is not provided. + """ + pass + +class GatewayNotFound(DiscordException): + """An exception that is usually thrown when the gateway hub + for the :class:`Client` websocket is not found.""" + def __init__(self): + message = 'The gateway to connect to discord was not found.' + super(GatewayNotFound, self).__init__(message) + +def flatten_error_dict(d, key=''): + items = [] + for k, v in d.items(): + new_key = key + '.' + k if key else k + + if isinstance(v, dict): + try: + _errors = v['_errors'] + except Exception: + items.extend(flatten_error_dict(v, new_key).items()) + else: + items.append((new_key, ' '.join(x.get('message', '') for x in _errors))) + else: + items.append((new_key, v)) + + return dict(items) + +class HTTPException(DiscordException): + """Exception that's thrown when an HTTP request operation fails. + + Attributes + ------------ + response: aiohttp.ClientResponse + The response of the failed HTTP request. This is an + instance of `aiohttp.ClientResponse`__. In some cases + this could also be a ``requests.Response``. + + __ http://aiohttp.readthedocs.org/en/stable/client_reference.html#aiohttp.ClientResponse + + text: str + The text of the error. Could be an empty string. + status: int + The status code of the HTTP request. + code: int + The Discord specific error code for the failure. + """ + + def __init__(self, response, message): + self.response = response + self.status = response.status + if isinstance(message, dict): + self.code = message.get('code', 0) + base = message.get('message', '') + errors = message.get('errors') + if errors: + errors = flatten_error_dict(errors) + helpful = '\n'.join('In %s: %s' % t for t in errors.items()) + self.text = base + '\n' + helpful + else: + self.text = base + else: + self.text = message + self.code = 0 + + fmt = '{0.reason} (status code: {0.status})' + if len(self.text): + fmt = fmt + ': {1}' + + super().__init__(fmt.format(self.response, self.text)) + +class Forbidden(HTTPException): + """Exception that's thrown for when status code 403 occurs. + + Subclass of :exc:`HTTPException` + """ + pass + +class NotFound(HTTPException): + """Exception that's thrown for when status code 404 occurs. + + Subclass of :exc:`HTTPException` + """ + pass + + +class InvalidArgument(ClientException): + """Exception that's thrown when an argument to a function + is invalid some way (e.g. wrong value or wrong type). + + This could be considered the analogous of ``ValueError`` and + ``TypeError`` except derived from :exc:`ClientException` and thus + :exc:`DiscordException`. + """ + pass + +class LoginFailure(ClientException): + """Exception that's thrown when the :meth:`Client.login` function + fails to log you in from improper credentials or some other misc. + failure. + """ + pass + +class ConnectionClosed(ClientException): + """Exception that's thrown when the gateway connection is + closed for reasons that could not be handled internally. + + Attributes + ----------- + code: int + The close code of the websocket. + reason: str + The reason provided for the closure. + shard_id: Optional[int] + The shard ID that got closed if applicable. + """ + def __init__(self, original, *, shard_id): + # This exception is just the same exception except + # reconfigured to subclass ClientException for users + self.code = original.code + self.reason = original.reason + self.shard_id = shard_id + super().__init__(str(original)) diff --git a/discord.py-rewrite/discord/ext/commands/__init__.py b/discord.py-rewrite/discord/ext/commands/__init__.py new file mode 100644 index 0000000..f5f7bb2 --- /dev/null +++ b/discord.py-rewrite/discord/ext/commands/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- + +""" +discord.ext.commands +~~~~~~~~~~~~~~~~~~~~~ + +An extension module to facilitate creation of bot commands. + +:copyright: (c) 2017 Rapptz +:license: MIT, see LICENSE for more details. +""" + +from .bot import Bot, AutoShardedBot, when_mentioned, when_mentioned_or +from .context import Context +from .core import * +from .errors import * +from .formatter import HelpFormatter, Paginator +from .converter import * +from .cooldowns import * diff --git a/discord.py-rewrite/discord/ext/commands/bot.py b/discord.py-rewrite/discord/ext/commands/bot.py new file mode 100644 index 0000000..048b2f4 --- /dev/null +++ b/discord.py-rewrite/discord/ext/commands/bot.py @@ -0,0 +1,995 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import asyncio +import discord +import inspect +import importlib +import sys +import traceback +import re + +from .core import GroupMixin, Command, command +from .view import StringView +from .context import Context +from .errors import CommandNotFound, CommandError +from .formatter import HelpFormatter + +def when_mentioned(bot, msg): + """A callable that implements a command prefix equivalent to being mentioned. + + These are meant to be passed into the :attr:`.Bot.command_prefix` attribute. + """ + return [bot.user.mention + ' ', '<@!%s> ' % bot.user.id] + +def when_mentioned_or(*prefixes): + """A callable that implements when mentioned or other prefixes provided. + + These are meant to be passed into the :attr:`.Bot.command_prefix` attribute. + + Example + -------- + + .. code-block:: python3 + + bot = commands.Bot(command_prefix=commands.when_mentioned_or('!')) + + + .. note:: + + This callable returns another callable, so if this is done inside a custom + callable, you must call the returned callable, for example: + + .. code-block:: python3 + + async def get_prefix(bot, message): + extras = await prefixes_for(message.guild) # returns a list + return commands.when_mentioned_or(*extras)(bot, message) + + + See Also + ---------- + :func:`.when_mentioned` + """ + def inner(bot, msg): + r = list(prefixes) + r.extend(when_mentioned(bot, msg)) + return r + + return inner + +_mentions_transforms = { + '@everyone': '@\u200beveryone', + '@here': '@\u200bhere' +} + +_mention_pattern = re.compile('|'.join(_mentions_transforms.keys())) + +def _is_submodule(parent, child): + return parent == child or child.startswith(parent + ".") + +@asyncio.coroutine +def _default_help_command(ctx, *commands : str): + """Shows this message.""" + bot = ctx.bot + destination = ctx.message.author if bot.pm_help else ctx.message.channel + + def repl(obj): + return _mentions_transforms.get(obj.group(0), '') + + # help by itself just lists our own commands. + if len(commands) == 0: + pages = yield from bot.formatter.format_help_for(ctx, bot) + elif len(commands) == 1: + # try to see if it is a cog name + name = _mention_pattern.sub(repl, commands[0]) + command = None + if name in bot.cogs: + command = bot.cogs[name] + else: + command = bot.all_commands.get(name) + if command is None: + yield from destination.send(bot.command_not_found.format(name)) + return + + pages = yield from bot.formatter.format_help_for(ctx, command) + else: + name = _mention_pattern.sub(repl, commands[0]) + command = bot.all_commands.get(name) + if command is None: + yield from destination.send(bot.command_not_found.format(name)) + return + + for key in commands[1:]: + try: + key = _mention_pattern.sub(repl, key) + command = command.all_commands.get(key) + if command is None: + yield from destination.send(bot.command_not_found.format(key)) + return + except AttributeError: + yield from destination.send(bot.command_has_no_subcommands.format(command, key)) + return + + pages = yield from bot.formatter.format_help_for(ctx, command) + + if bot.pm_help is None: + characters = sum(map(lambda l: len(l), pages)) + # modify destination based on length of pages. + if characters > 1000: + destination = ctx.message.author + + for page in pages: + yield from destination.send(page) + +class BotBase(GroupMixin): + def __init__(self, command_prefix, formatter=None, description=None, pm_help=False, **options): + super().__init__(**options) + self.command_prefix = command_prefix + self.extra_events = {} + self.cogs = {} + self.extensions = {} + self._checks = [] + self._check_once = [] + self._before_invoke = None + self._after_invoke = None + self.description = inspect.cleandoc(description) if description else '' + self.pm_help = pm_help + self.owner_id = options.get('owner_id') + self.command_not_found = options.pop('command_not_found', 'No command called "{}" found.') + self.command_has_no_subcommands = options.pop('command_has_no_subcommands', 'Command {0.name} has no subcommands.') + + if options.pop('self_bot', False): + self._skip_check = lambda x, y: x != y + else: + self._skip_check = lambda x, y: x == y + + self.help_attrs = options.pop('help_attrs', {}) + + if 'name' not in self.help_attrs: + self.help_attrs['name'] = 'help' + + if formatter is not None: + if not isinstance(formatter, HelpFormatter): + raise discord.ClientException('Formatter must be a subclass of HelpFormatter') + self.formatter = formatter + else: + self.formatter = HelpFormatter() + + # pay no mind to this ugliness. + self.command(**self.help_attrs)(_default_help_command) + + # internal helpers + + def dispatch(self, event_name, *args, **kwargs): + super().dispatch(event_name, *args, **kwargs) + ev = 'on_' + event_name + for event in self.extra_events.get(ev, []): + coro = self._run_event(event, event_name, *args, **kwargs) + discord.compat.create_task(coro, loop=self.loop) + + @asyncio.coroutine + def close(self): + for extension in tuple(self.extensions): + try: + self.unload_extension(extension) + except: + pass + + for cog in tuple(self.cogs): + try: + self.remove_cog(cog) + except: + pass + + yield from super().close() + + @asyncio.coroutine + def on_command_error(self, context, exception): + """|coro| + + The default command error handler provided by the bot. + + By default this prints to ``sys.stderr`` however it could be + overridden to have a different implementation. + + This only fires if you do not specify any listeners for command error. + """ + if self.extra_events.get('on_command_error', None): + return + + if hasattr(context.command, 'on_error'): + return + + cog = context.cog + if cog: + attr = '_{0.__class__.__name__}__error'.format(cog) + if hasattr(cog, attr): + return + + print('Ignoring exception in command {}:'.format(context.command), file=sys.stderr) + traceback.print_exception(type(exception), exception, exception.__traceback__, file=sys.stderr) + + # global check registration + + def check(self, func): + """A decorator that adds a global check to the bot. + + A global check is similar to a :func:`.check` that is applied + on a per command basis except it is run before any command checks + have been verified and applies to every command the bot has. + + .. note:: + + This function can either be a regular function or a coroutine. + + Similar to a command :func:`.check`\, this takes a single parameter + of type :class:`.Context` and can only raise exceptions derived from + :exc:`.CommandError`. + + Example + --------- + + .. code-block:: python3 + + @bot.check + def check_commands(ctx): + return ctx.command.qualified_name in allowed_commands + + """ + self.add_check(func) + return func + + def add_check(self, func, *, call_once=False): + """Adds a global check to the bot. + + This is the non-decorator interface to :meth:`.check` + and :meth:`.check_once`. + + Parameters + ----------- + func + The function that was used as a global check. + call_once: bool + If the function should only be called once per + :meth:`.Command.invoke` call. + """ + + if call_once: + self._check_once.append(func) + else: + self._checks.append(func) + + def remove_check(self, func): + """Removes a global check from the bot. + + This function is idempotent and will not raise an exception + if the function is not in the global checks. + + Parameters + ----------- + func + The function to remove from the global checks. + """ + + try: + self._checks.remove(func) + except ValueError: + try: + self._check_once.remove(func) + except ValueError: + pass + + def check_once(self, func): + """A decorator that adds a "call once" global check to the bot. + + Unlike regular global checks, this one is called only once + per :meth:`.Command.invoke` call. + + Regular global checks are called whenever a command is called + or :meth:`.Command.can_run` is called. This type of check + bypasses that and ensures that it's called only once, even inside + the default help command. + + .. note:: + + This function can either be a regular function or a coroutine. + + Similar to a command :func:`.check`\, this takes a single parameter + of type :class:`.Context` and can only raise exceptions derived from + :exc:`.CommandError`. + + Example + --------- + + .. code-block:: python3 + + @bot.check_once + def whitelist(ctx): + return ctx.message.author.id in my_whitelist + + """ + self.add_check(func, call_once=True) + return func + + @asyncio.coroutine + def can_run(self, ctx, *, call_once=False): + data = self._check_once if call_once else self._checks + + if len(data) == 0: + return True + + return (yield from discord.utils.async_all(f(ctx) for f in data)) + + @asyncio.coroutine + def is_owner(self, user): + """Checks if a :class:`.User` or :class:`.Member` is the owner of + this bot. + + If an :attr:`owner_id` is not set, it is fetched automatically + through the use of :meth:`~.Bot.application_info`. + + Parameters + ----------- + user: :class:`.abc.User` + The user to check for. + """ + + if self.owner_id is None: + app = yield from self.application_info() + self.owner_id = owner_id = app.owner.id + return user.id == owner_id + return user.id == self.owner_id + + def before_invoke(self, coro): + """A decorator that registers a coroutine as a pre-invoke hook. + + A pre-invoke hook is called directly before the command is + called. This makes it a useful function to set up database + connections or any type of set up required. + + This pre-invoke hook takes a sole parameter, a :class:`.Context`. + + .. note:: + + The :meth:`~.Bot.before_invoke` and :meth:`~.Bot.after_invoke` hooks are + only called if all checks and argument parsing procedures pass + without error. If any check or argument parsing procedures fail + then the hooks are not called. + + Parameters + ----------- + coro + The coroutine to register as the pre-invoke hook. + + Raises + ------- + :exc:`.ClientException` + The coroutine is not actually a coroutine. + """ + if not asyncio.iscoroutinefunction(coro): + raise discord.ClientException('The error handler must be a coroutine.') + + self._before_invoke = coro + return coro + + def after_invoke(self, coro): + """A decorator that registers a coroutine as a post-invoke hook. + + A post-invoke hook is called directly after the command is + called. This makes it a useful function to clean-up database + connections or any type of clean up required. + + This post-invoke hook takes a sole parameter, a :class:`.Context`. + + .. note:: + + Similar to :meth:`~.Bot.before_invoke`\, this is not called unless + checks and argument parsing procedures succeed. This hook is, + however, **always** called regardless of the internal command + callback raising an error (i.e. :exc:`.CommandInvokeError`\). + This makes it ideal for clean-up scenarios. + + Parameters + ----------- + coro + The coroutine to register as the post-invoke hook. + + Raises + ------- + :exc:`.ClientException` + The coroutine is not actually a coroutine. + """ + if not asyncio.iscoroutinefunction(coro): + raise discord.ClientException('The error handler must be a coroutine.') + + self._after_invoke = coro + return coro + + # listener registration + + def add_listener(self, func, name=None): + """The non decorator alternative to :meth:`.listen`. + + Parameters + ----------- + func : coroutine + The extra event to listen to. + name : Optional[str] + The name of the command to use. Defaults to ``func.__name__``. + + Example + -------- + + .. code-block:: python3 + + async def on_ready(): pass + async def my_message(message): pass + + bot.add_listener(on_ready) + bot.add_listener(my_message, 'on_message') + + """ + name = func.__name__ if name is None else name + + if not asyncio.iscoroutinefunction(func): + raise discord.ClientException('Listeners must be coroutines') + + if name in self.extra_events: + self.extra_events[name].append(func) + else: + self.extra_events[name] = [func] + + def remove_listener(self, func, name=None): + """Removes a listener from the pool of listeners. + + Parameters + ----------- + func + The function that was used as a listener to remove. + name + The name of the event we want to remove. Defaults to + ``func.__name__``. + """ + + name = func.__name__ if name is None else name + + if name in self.extra_events: + try: + self.extra_events[name].remove(func) + except ValueError: + pass + + def listen(self, name=None): + """A decorator that registers another function as an external + event listener. Basically this allows you to listen to multiple + events from different places e.g. such as :func:`.on_ready` + + The functions being listened to must be a coroutine. + + Example + -------- + + .. code-block:: python3 + + @bot.listen() + async def on_message(message): + print('one') + + # in some other file... + + @bot.listen('on_message') + async def my_message(message): + print('two') + + Would print one and two in an unspecified order. + + Raises + ------- + :exc:`.ClientException` + The function being listened to is not a coroutine. + """ + + def decorator(func): + self.add_listener(func, name) + return func + + return decorator + + # cogs + + def add_cog(self, cog): + """Adds a "cog" to the bot. + + A cog is a class that has its own event listeners and commands. + + They are meant as a way to organize multiple relevant commands + into a singular class that shares some state or no state at all. + + The cog can also have a ``__global_check`` member function that allows + you to define a global check. See :meth:`.check` for more info. If + the name is ``__global_check_once`` then it's equivalent to the + :meth:`.check_once` decorator. + + More information will be documented soon. + + Parameters + ----------- + cog + The cog to register to the bot. + """ + + self.cogs[type(cog).__name__] = cog + + try: + check = getattr(cog, '_{.__class__.__name__}__global_check'.format(cog)) + except AttributeError: + pass + else: + self.add_check(check) + + try: + check = getattr(cog, '_{.__class__.__name__}__global_check_once'.format(cog)) + except AttributeError: + pass + else: + self.add_check(check, call_once=True) + + members = inspect.getmembers(cog) + for name, member in members: + # register commands the cog has + if isinstance(member, Command): + if member.parent is None: + self.add_command(member) + continue + + # register event listeners the cog has + if name.startswith('on_'): + self.add_listener(member, name) + + def get_cog(self, name): + """Gets the cog instance requested. + + If the cog is not found, ``None`` is returned instead. + + Parameters + ----------- + name : str + The name of the cog you are requesting. + """ + return self.cogs.get(name) + + def get_cog_commands(self, name): + """Gets a unique set of the cog's registered commands + without aliases. + + If the cog is not found, an empty set is returned. + + Parameters + ------------ + name: str + The name of the cog whose commands you are requesting. + + Returns + --------- + Set[:class:`.Command`] + A unique set of commands without aliases that belong + to the cog. + """ + + try: + cog = self.cogs[name] + except KeyError: + return set() + + return {c for c in self.all_commands.values() if c.instance is cog} + + def remove_cog(self, name): + """Removes a cog from the bot. + + All registered commands and event listeners that the + cog has registered will be removed as well. + + If no cog is found then this method has no effect. + + If the cog defines a special member function named ``__unload`` + then it is called when removal has completed. This function + **cannot** be a coroutine. It must be a regular function. + + Parameters + ----------- + name : str + The name of the cog to remove. + """ + + cog = self.cogs.pop(name, None) + if cog is None: + return + + members = inspect.getmembers(cog) + for name, member in members: + # remove commands the cog has + if isinstance(member, Command): + if member.parent is None: + self.remove_command(member.name) + continue + + # remove event listeners the cog has + if name.startswith('on_'): + self.remove_listener(member) + + try: + check = getattr(cog, '_{0.__class__.__name__}__global_check'.format(cog)) + except AttributeError: + pass + else: + self.remove_check(check) + + try: + check = getattr(cog, '_{0.__class__.__name__}__global_check_once'.format(cog)) + except AttributeError: + pass + else: + self.remove_check(check) + + unloader_name = '_{0.__class__.__name__}__unload'.format(cog) + try: + unloader = getattr(cog, unloader_name) + except AttributeError: + pass + else: + unloader() + + del cog + + # extensions + + def load_extension(self, name): + """Loads an extension. + + An extension is a python module that contains commands, cogs, or + listeners. + + An extension must have a global function, ``setup`` defined as + the entry point on what to do when the extension is loaded. This entry + point must have a single argument, the ``bot``. + + Parameters + ------------ + name: str + The extension name to load. It must be dot separated like + regular Python imports if accessing a sub-module. e.g. + ``foo.test`` if you want to import ``foo/test.py``. + + Raises + -------- + ClientException + The extension does not have a setup function. + ImportError + The extension could not be imported. + """ + + if name in self.extensions: + return + + lib = importlib.import_module(name) + if not hasattr(lib, 'setup'): + del lib + del sys.modules[name] + raise discord.ClientException('extension does not have a setup function') + + lib.setup(self) + self.extensions[name] = lib + + def unload_extension(self, name): + """Unloads an extension. + + When the extension is unloaded, all commands, listeners, and cogs are + removed from the bot and the module is un-imported. + + The extension can provide an optional global function, ``teardown``, + to do miscellaneous clean-up if necessary. This function takes a single + parameter, the ``bot``, similar to ``setup`` from + :func:`~.Bot.load_extension`. + + Parameters + ------------ + name: str + The extension name to unload. It must be dot separated like + regular Python imports if accessing a sub-module. e.g. + ``foo.test`` if you want to import ``foo/test.py``. + """ + + lib = self.extensions.get(name) + if lib is None: + return + + lib_name = lib.__name__ + + # find all references to the module + + # remove the cogs registered from the module + for cogname, cog in self.cogs.copy().items(): + if _is_submodule(lib_name, cog.__module__): + self.remove_cog(cogname) + + # first remove all the commands from the module + for cmd in self.all_commands.copy().values(): + if _is_submodule(lib_name, cmd.module): + if isinstance(cmd, GroupMixin): + cmd.recursively_remove_all_commands() + self.remove_command(cmd.name) + + # then remove all the listeners from the module + for event_list in self.extra_events.copy().values(): + remove = [] + for index, event in enumerate(event_list): + if _is_submodule(lib_name, event.__module__): + remove.append(index) + + for index in reversed(remove): + del event_list[index] + + try: + func = getattr(lib, 'teardown') + except AttributeError: + pass + else: + try: + func(self) + except: + pass + finally: + # finally remove the import.. + del lib + del self.extensions[name] + del sys.modules[name] + for module in list(sys.modules.keys()): + if _is_submodule(lib_name, module): + del sys.modules[module] + + # command processing + + @asyncio.coroutine + def get_prefix(self, message): + """|coro| + + Retrieves the prefix the bot is listening to + with the message as a context. + + Parameters + ----------- + message: :class:`discord.Message` + The message context to get the prefix of. + + Raises + -------- + :exc:`.ClientException` + The prefix was invalid. This could be if the prefix + function returned None, the prefix list returned no + elements that aren't None, or the prefix string is + empty. + + Returns + -------- + Union[List[str], str] + A list of prefixes or a single prefix that the bot is + listening for. + """ + prefix = ret = self.command_prefix + if callable(prefix): + ret = prefix(self, message) + if asyncio.iscoroutine(ret): + ret = yield from ret + + if isinstance(ret, (list, tuple)): + ret = [p for p in ret if p] + + if not ret: + raise discord.ClientException('invalid prefix (could be an empty string, empty list, or None)') + + return ret + + @asyncio.coroutine + def get_context(self, message, *, cls=Context): + """|coro| + + Returns the invocation context from the message. + + This is a more low-level counter-part for :meth:`.process_commands` + to allow users more fine grained control over the processing. + + The returned context is not guaranteed to be a valid invocation + context, :attr:`.Context.valid` must be checked to make sure it is. + If the context is not valid then it is not a valid candidate to be + invoked under :meth:`~.Bot.invoke`. + + Parameters + ----------- + message: :class:`discord.Message` + The message to get the invocation context from. + cls + The factory class that will be used to create the context. + By default, this is :class:`.Context`. Should a custom + class be provided, it must be similar enough to :class:`.Context`\'s + interface. + + Returns + -------- + :class:`.Context` + The invocation context. The type of this can change via the + ``cls`` parameter. + """ + + view = StringView(message.content) + ctx = cls(prefix=None, view=view, bot=self, message=message) + + if self._skip_check(message.author.id, self.user.id): + return ctx + + prefix = yield from self.get_prefix(message) + invoked_prefix = prefix + + if isinstance(prefix, str): + if not view.skip_string(prefix): + return ctx + else: + invoked_prefix = discord.utils.find(view.skip_string, prefix) + if invoked_prefix is None: + return ctx + + invoker = view.get_word() + ctx.invoked_with = invoker + ctx.prefix = invoked_prefix + ctx.command = self.all_commands.get(invoker) + return ctx + + @asyncio.coroutine + def invoke(self, ctx): + """|coro| + + Invokes the command given under the invocation context and + handles all the internal event dispatch mechanisms. + + Parameters + ----------- + ctx: :class:`.Context` + The invocation context to invoke. + """ + if ctx.command is not None: + self.dispatch('command', ctx) + try: + if (yield from self.can_run(ctx, call_once=True)): + yield from ctx.command.invoke(ctx) + except CommandError as e: + yield from ctx.command.dispatch_error(ctx, e) + else: + self.dispatch('command_completion', ctx) + elif ctx.invoked_with: + exc = CommandNotFound('Command "{}" is not found'.format(ctx.invoked_with)) + self.dispatch('command_error', ctx, exc) + + @asyncio.coroutine + def process_commands(self, message): + """|coro| + + This function processes the commands that have been registered + to the bot and other groups. Without this coroutine, none of the + commands will be triggered. + + By default, this coroutine is called inside the :func:`.on_message` + event. If you choose to override the :func:`.on_message` event, then + you should invoke this coroutine as well. + + This is built using other low level tools, and is equivalent to a + call to :meth:`~.Bot.get_context` followed by a call to :meth:`~.Bot.invoke`. + + Parameters + ----------- + message : discord.Message + The message to process commands for. + """ + ctx = yield from self.get_context(message) + yield from self.invoke(ctx) + + @asyncio.coroutine + def on_message(self, message): + yield from self.process_commands(message) + +class Bot(BotBase, discord.Client): + """Represents a discord bot. + + This class is a subclass of :class:`discord.Client` and as a result + anything that you can do with a :class:`discord.Client` you can do with + this bot. + + .. _deque: https://docs.python.org/3.4/library/collections.html#collections.deque + .. _event loop: https://docs.python.org/3/library/asyncio-eventloops.html + + This class also subclasses :class:`.GroupMixin` to provide the functionality + to manage commands. + + Attributes + ----------- + command_prefix + The command prefix is what the message content must contain initially + to have a command invoked. This prefix could either be a string to + indicate what the prefix should be, or a callable that takes in the bot + as its first parameter and :class:`discord.Message` as its second + parameter and returns the prefix. This is to facilitate "dynamic" + command prefixes. This callable can be either a regular function or + a coroutine. + + The command prefix could also be a list or a tuple indicating that + multiple checks for the prefix should be used and the first one to + match will be the invocation prefix. You can get this prefix via + :attr:`.Context.prefix`. + description : str + The content prefixed into the default help message. + self_bot : bool + If ``True``, the bot will only listen to commands invoked by itself rather + than ignoring itself. If ``False`` (the default) then the bot will ignore + itself. This cannot be changed once initialised. + formatter : :class:`.HelpFormatter` + The formatter used to format the help message. By default, it uses a + the :class:`.HelpFormatter`. Check it for more info on how to override it. + If you want to change the help command completely (add aliases, etc) then + a call to :meth:`~.Bot.remove_command` with 'help' as the argument would do the + trick. + pm_help : Optional[bool] + A tribool that indicates if the help command should PM the user instead of + sending it to the channel it received it from. If the boolean is set to + ``True``, then all help output is PM'd. If ``False``, none of the help + output is PM'd. If ``None``, then the bot will only PM when the help + message becomes too long (dictated by more than 1000 characters). + Defaults to ``False``. + help_attrs : dict + A dictionary of options to pass in for the construction of the help command. + This allows you to change the command behaviour without actually changing + the implementation of the command. The attributes will be the same as the + ones passed in the :class:`.Command` constructor. Note that ``pass_context`` + will always be set to ``True`` regardless of what you pass in. + command_not_found : str + The format string used when the help command is invoked with a command that + is not found. Useful for i18n. Defaults to ``"No command called {} found."``. + The only format argument is the name of the command passed. + command_has_no_subcommands : str + The format string used when the help command is invoked with requests for a + subcommand but the command does not have any subcommands. Defaults to + ``"Command {0.name} has no subcommands."``. The first format argument is the + :class:`.Command` attempted to get a subcommand and the second is the name. + owner_id: Optional[int] + The ID that owns the bot. If this is not set and is then queried via + :meth:`.is_owner` then it is fetched automatically using + :meth:`~.Bot.application_info`. + """ + pass + +class AutoShardedBot(BotBase, discord.AutoShardedClient): + """This is similar to :class:`.Bot` except that it is derived from + :class:`discord.AutoShardedClient` instead. + """ + pass diff --git a/discord.py-rewrite/discord/ext/commands/context.py b/discord.py-rewrite/discord/ext/commands/context.py new file mode 100644 index 0000000..c250fc6 --- /dev/null +++ b/discord.py-rewrite/discord/ext/commands/context.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import asyncio +import discord.abc +import discord.utils + +class Context(discord.abc.Messageable): + """Represents the context in which a command is being invoked under. + + This class contains a lot of meta data to help you understand more about + the invocation context. This class is not created manually and is instead + passed around to commands as the first parameter. + + This class implements the :class:`abc.Messageable` ABC. + + Attributes + ----------- + message: :class:`discord.Message` + The message that triggered the command being executed. + bot: :class:`.Bot` + The bot that contains the command being executed. + args: list + The list of transformed arguments that were passed into the command. + If this is accessed during the :func:`on_command_error` event + then this list could be incomplete. + kwargs: dict + A dictionary of transformed arguments that were passed into the command. + Similar to :attr:`args`\, if this is accessed in the + :func:`on_command_error` event then this dict could be incomplete. + prefix: str + The prefix that was used to invoke the command. + command + The command (i.e. :class:`.Command` or its superclasses) that is being + invoked currently. + invoked_with: str + The command name that triggered this invocation. Useful for finding out + which alias called the command. + invoked_subcommand + The subcommand (i.e. :class:`.Command` or its superclasses) that was + invoked. If no valid subcommand was invoked then this is equal to + `None`. + subcommand_passed: Optional[str] + The string that was attempted to call a subcommand. This does not have + to point to a valid registered subcommand and could just point to a + nonsense string. If nothing was passed to attempt a call to a + subcommand then this is set to `None`. + command_failed: bool + A boolean that indicates if the command failed to be parsed, checked, + or invoked. + """ + + def __init__(self, **attrs): + self.message = attrs.pop('message', None) + self.bot = attrs.pop('bot', None) + self.args = attrs.pop('args', []) + self.kwargs = attrs.pop('kwargs', {}) + self.prefix = attrs.pop('prefix') + self.command = attrs.pop('command', None) + self.view = attrs.pop('view', None) + self.invoked_with = attrs.pop('invoked_with', None) + self.invoked_subcommand = attrs.pop('invoked_subcommand', None) + self.subcommand_passed = attrs.pop('subcommand_passed', None) + self.command_failed = attrs.pop('command_failed', False) + self._state = self.message._state + + @asyncio.coroutine + def invoke(self, *args, **kwargs): + """|coro| + + Calls a command with the arguments given. + + This is useful if you want to just call the callback that a + :class:`.Command` holds internally. + + Note + ------ + You do not pass in the context as it is done for you. + + Warning + --------- + The first parameter passed **must** be the command being invoked. + + Parameters + ----------- + command: :class:`.Command` + A command or superclass of a command that is going to be called. + \*args + The arguments to to use. + \*\*kwargs + The keyword arguments to use. + """ + + try: + command = args[0] + except IndexError: + raise TypeError('Missing command to invoke.') from None + + arguments = [] + if command.instance is not None: + arguments.append(command.instance) + + arguments.append(self) + arguments.extend(args[1:]) + + ret = yield from command.callback(*arguments, **kwargs) + return ret + + @asyncio.coroutine + def reinvoke(self, *, call_hooks=False, restart=True): + """|coro| + + Calls the command again. + + This is similar to :meth:`~.Context.invoke` except that it bypasses + checks, cooldowns, and error handlers. + + .. note:: + + If you want to bypass :exc:`.UserInputError` derived exceptions, + it is recommended to use the regular :meth:`~.Context.invoke` + as it will work more naturally. After all, this will end up + using the old arguments the user has used and will thus just + fail again. + + Parameters + ------------ + call_hooks: bool + Whether to call the before and after invoke hooks. + restart: bool + Whether to start the call chain from the very beginning + or where we left off (i.e. the command that caused the error). + The default is to start where we left off. + """ + cmd = self.command + view = self.view + if cmd is None: + raise ValueError('This context is not valid.') + + # some state to revert to when we're done + index, previous = view.index, view.previous + invoked_with = self.invoked_with + invoked_subcommand = self.invoked_subcommand + subcommand_passed = self.subcommand_passed + + if restart: + to_call = cmd.root_parent or cmd + view.index = len(self.prefix) + view.previous = 0 + view.get_word() # advance to get the root command + else: + to_call = cmd + + try: + yield from to_call.reinvoke(self, call_hooks=call_hooks) + finally: + self.command = cmd + view.index = index + view.previous = previous + self.invoked_with = invoked_with + self.invoked_subcommand = invoked_subcommand + self.subcommand_passed = subcommand_passed + + @property + def valid(self): + """Checks if the invocation context is valid to be invoked with.""" + return self.prefix is not None and self.command is not None + + @asyncio.coroutine + def _get_channel(self): + return self.channel + + @property + def cog(self): + """Returns the cog associated with this context's command. None if it does not exist.""" + + if self.command is None: + return None + return self.command.instance + + @discord.utils.cached_property + def guild(self): + """Returns the guild associated with this context's command. None if not available.""" + return self.message.guild + + @discord.utils.cached_property + def channel(self): + """Returns the channel associated with this context's command. Shorthand for :attr:`Message.channel`.""" + return self.message.channel + + @discord.utils.cached_property + def author(self): + """Returns the author associated with this context's command. Shorthand for :attr:`Message.author`""" + return self.message.author + + @discord.utils.cached_property + def me(self): + """Similar to :attr:`Guild.me` except it may return the :class:`ClientUser` in private message contexts.""" + return self.guild.me if self.guild is not None else self.bot.user + + @property + def voice_client(self): + """Optional[:class:`VoiceClient`]: A shortcut to :attr:`Guild.voice_client`\, if applicable.""" + g = self.guild + return g.voice_client if g else None diff --git a/discord.py-rewrite/discord/ext/commands/converter.py b/discord.py-rewrite/discord/ext/commands/converter.py new file mode 100644 index 0000000..47ce8cc --- /dev/null +++ b/discord.py-rewrite/discord/ext/commands/converter.py @@ -0,0 +1,481 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import discord +import asyncio +import re +import inspect + +from .errors import BadArgument, NoPrivateMessage +from .view import StringView + +__all__ = [ 'Converter', 'MemberConverter', 'UserConverter', + 'TextChannelConverter', 'InviteConverter', 'RoleConverter', + 'GameConverter', 'ColourConverter', 'VoiceChannelConverter', + 'EmojiConverter','CategoryChannelConverter', 'IDConverter', + 'clean_content' ] + +def _get_from_guilds(bot, getter, argument): + result = None + for guild in bot.guilds: + result = getattr(guild, getter)(argument) + if result: + return result + return result + +class Converter: + """The base class of custom converters that require the :class:`.Context` + to be passed to be useful. + + This allows you to implement converters that function similar to the + special cased ``discord`` classes. + + Classes that derive from this should override the :meth:`~.Converter.convert` + method to do its conversion logic. This method must be a coroutine. + """ + + @asyncio.coroutine + def convert(self, ctx, argument): + """|coro| + + The method to override to do conversion logic. + + If an error is found while converting, it is recommended to + raise a :exc:`.CommandError` derived exception as it will + properly propagate to the error handlers. + + Parameters + ----------- + ctx: :class:`.Context` + The invocation context that the argument is being used in. + argument: str + The argument that is being converted. + """ + raise NotImplementedError('Derived classes need to implement this.') + +class IDConverter(Converter): + def __init__(self): + self._id_regex = re.compile(r'([0-9]{15,21})$') + super().__init__() + + def _get_id_match(self, argument): + return self._id_regex.match(argument) + +class MemberConverter(IDConverter): + """Converts to a :class:`Member`. + + All lookups are via the local guild. If in a DM context, then the lookup + is done by the global cache. + + The lookup strategy is as follows (in order): + + 1. Lookup by ID. + 2. Lookup by mention. + 3. Lookup by name#discrim + 4. Lookup by name + 5. Lookup by nickname + """ + + @asyncio.coroutine + def convert(self, ctx, argument): + message = ctx.message + bot = ctx.bot + match = self._get_id_match(argument) or re.match(r'<@!?([0-9]+)>$', argument) + guild = message.guild + result = None + if match is None: + # not a mention... + if guild: + result = guild.get_member_named(argument) + else: + result = _get_from_guilds(bot, 'get_member_named', argument) + else: + user_id = int(match.group(1)) + if guild: + result = guild.get_member(user_id) + else: + result = _get_from_guilds(bot, 'get_member', user_id) + + if result is None: + raise BadArgument('Member "{}" not found'.format(argument)) + + return result + +class UserConverter(IDConverter): + """Converts to a :class:`User`. + + All lookups are via the global user cache. + + The lookup strategy is as follows (in order): + + 1. Lookup by ID. + 2. Lookup by mention. + 3. Lookup by name#discrim + 4. Lookup by name + """ + @asyncio.coroutine + def convert(self, ctx, argument): + match = self._get_id_match(argument) or re.match(r'<@!?([0-9]+)>$', argument) + result = None + state = ctx._state + + if match is not None: + user_id = int(match.group(1)) + result = ctx.bot.get_user(user_id) + else: + arg = argument + # check for discriminator if it exists + if len(arg) > 5 and arg[-5] == '#': + discrim = arg[-4:] + name = arg[:-5] + predicate = lambda u: u.name == name and u.discriminator == discrim + result = discord.utils.find(predicate, state._users.values()) + if result is not None: + return result + + predicate = lambda u: u.name == arg + result = discord.utils.find(predicate, state._users.values()) + + if result is None: + raise BadArgument('User "{}" not found'.format(argument)) + + return result + +class TextChannelConverter(IDConverter): + """Converts to a :class:`TextChannel`. + + All lookups are via the local guild. If in a DM context, then the lookup + is done by the global cache. + + The lookup strategy is as follows (in order): + + 1. Lookup by ID. + 2. Lookup by mention. + 3. Lookup by name + """ + @asyncio.coroutine + def convert(self, ctx, argument): + bot = ctx.bot + + match = self._get_id_match(argument) or re.match(r'<#([0-9]+)>$', argument) + result = None + guild = ctx.guild + + if match is None: + # not a mention + if guild: + result = discord.utils.get(guild.text_channels, name=argument) + else: + def check(c): + return isinstance(c, discord.TextChannel) and c.name == argument + result = discord.utils.find(check, bot.get_all_channels()) + else: + channel_id = int(match.group(1)) + if guild: + result = guild.get_channel(channel_id) + else: + result = _get_from_guilds(bot, 'get_channel', channel_id) + + if not isinstance(result, discord.TextChannel): + raise BadArgument('Channel "{}" not found.'.format(argument)) + + return result + +class VoiceChannelConverter(IDConverter): + """Converts to a :class:`VoiceChannel`. + + All lookups are via the local guild. If in a DM context, then the lookup + is done by the global cache. + + The lookup strategy is as follows (in order): + + 1. Lookup by ID. + 2. Lookup by mention. + 3. Lookup by name + """ + @asyncio.coroutine + def convert(self, ctx, argument): + bot = ctx.bot + match = self._get_id_match(argument) or re.match(r'<#([0-9]+)>$', argument) + result = None + guild = ctx.guild + + if match is None: + # not a mention + if guild: + result = discord.utils.get(guild.voice_channels, name=argument) + else: + def check(c): + return isinstance(c, discord.VoiceChannel) and c.name == argument + result = discord.utils.find(check, bot.get_all_channels()) + else: + channel_id = int(match.group(1)) + if guild: + result = guild.get_channel(channel_id) + else: + result = _get_from_guilds(bot, 'get_channel', channel_id) + + if not isinstance(result, discord.VoiceChannel): + raise BadArgument('Channel "{}" not found.'.format(argument)) + + return result + +class CategoryChannelConverter(IDConverter): + """Converts to a :class:`CategoryChannel`. + + All lookups are via the local guild. If in a DM context, then the lookup + is done by the global cache. + + The lookup strategy is as follows (in order): + + 1. Lookup by ID. + 2. Lookup by mention. + 3. Lookup by name + """ + @asyncio.coroutine + def convert(self, ctx, argument): + bot = ctx.bot + + match = self._get_id_match(argument) or re.match(r'<#([0-9]+)>$', argument) + result = None + guild = ctx.guild + + if match is None: + # not a mention + if guild: + result = discord.utils.get(guild.categories, name=argument) + else: + def check(c): + return isinstance(c, discord.CategoryChannel) and c.name == argument + result = discord.utils.find(check, bot.get_all_channels()) + else: + channel_id = int(match.group(1)) + if guild: + result = guild.get_channel(channel_id) + else: + result = _get_from_guilds(bot, 'get_channel', channel_id) + + if not isinstance(result, discord.CategoryChannel): + raise BadArgument('Channel "{}" not found.'.format(argument)) + + return result + +class ColourConverter(Converter): + """Converts to a :class:`Colour`. + + The following formats are accepted: + + - ``0x`` + - ``#`` + - ``0x#`` + - Any of the ``classmethod`` in :class:`Colour` + + - The ``_`` in the name can be optionally replaced with spaces. + """ + @asyncio.coroutine + def convert(self, ctx, argument): + arg = argument.replace('0x', '').lower() + + if arg[0] == '#': + arg = arg[1:] + try: + value = int(arg, base=16) + return discord.Colour(value=value) + except ValueError: + method = getattr(discord.Colour, arg.replace(' ', '_'), None) + if method is None or not inspect.ismethod(method): + raise BadArgument('Colour "{}" is invalid.'.format(arg)) + return method() + +class RoleConverter(IDConverter): + """Converts to a :class:`Role`. + + + All lookups are via the local guild. If in a DM context, then the lookup + is done by the global cache. + + The lookup strategy is as follows (in order): + + 1. Lookup by ID. + 2. Lookup by mention. + 3. Lookup by name + """ + @asyncio.coroutine + def convert(self, ctx, argument): + guild = ctx.message.guild + if not guild: + raise NoPrivateMessage() + + match = self._get_id_match(argument) or re.match(r'<@&([0-9]+)>$', argument) + params = dict(id=int(match.group(1))) if match else dict(name=argument) + result = discord.utils.get(guild.roles, **params) + if result is None: + raise BadArgument('Role "{}" not found.'.format(argument)) + return result + +class GameConverter(Converter): + """Converts to :class:`Game`.""" + @asyncio.coroutine + def convert(self, ctx, argument): + return discord.Game(name=argument) + +class InviteConverter(Converter): + """Converts to a :class:`Invite`. + + This is done via an HTTP request using :meth:`.Bot.get_invite`. + """ + @asyncio.coroutine + def convert(self, ctx, argument): + try: + invite = yield from ctx.bot.get_invite(argument) + return invite + except Exception as e: + raise BadArgument('Invite is invalid or expired') from e + +class EmojiConverter(IDConverter): + """Converts to a :class:`Emoji`. + + + All lookups are done for the local guild first, if available. If that lookup + fails, then it checks the client's global cache. + + The lookup strategy is as follows (in order): + + 1. Lookup by ID. + 2. Lookup by extracting ID from the emoji. + 3. Lookup by name + """ + @asyncio.coroutine + def convert(self, ctx, argument): + match = self._get_id_match(argument) or re.match(r'<:[a-zA-Z0-9\_]+:([0-9]+)>$', argument) + result = None + bot = ctx.bot + guild = ctx.guild + + if match is None: + # Try to get the emoji by name. Try local guild first. + if guild: + result = discord.utils.get(guild.emojis, name=argument) + + if result is None: + result = discord.utils.get(bot.emojis, name=argument) + else: + emoji_id = int(match.group(1)) + + # Try to look up emoji by id. + if guild: + result = discord.utils.get(guild.emojis, id=emoji_id) + + if result is None: + result = discord.utils.get(bot.emojis, id=emoji_id) + + if result is None: + raise BadArgument('Emoji "{}" not found.'.format(argument)) + + return result + +class clean_content(Converter): + """Converts the argument to mention scrubbed version of + said content. + + This behaves similarly to :attr:`.Message.clean_content`. + + Attributes + ------------ + fix_channel_mentions: bool + Whether to clean channel mentions. + use_nicknames: bool + Whether to use nicknames when transforming mentions. + escape_markdown: bool + Whether to also escape special markdown characters. + """ + def __init__(self, *, fix_channel_mentions=False, use_nicknames=True, escape_markdown=False): + self.fix_channel_mentions = fix_channel_mentions + self.use_nicknames = use_nicknames + self.escape_markdown = escape_markdown + + @asyncio.coroutine + def convert(self, ctx, argument): + message = ctx.message + transformations = {} + + if self.fix_channel_mentions and ctx.guild: + def resolve_channel(id, *, _get=ctx.guild.get_channel): + ch = _get(id) + return ('<#%s>' % id), ('#' + ch.name if ch else '#deleted-channel') + + transformations.update(resolve_channel(channel) for channel in message.raw_channel_mentions) + + if self.use_nicknames and ctx.guild: + def resolve_member(id, *, _get=ctx.guild.get_member): + m = _get(id) + return '@' + m.display_name if m else '@deleted-user' + else: + def resolve_member(id, *, _get=ctx.bot.get_user): + m = _get(id) + return '@' + m.name if m else '@deleted-user' + + + transformations.update( + ('<@%s>' % member_id, resolve_member(member_id)) + for member_id in message.raw_mentions + ) + + transformations.update( + ('<@!%s>' % member_id, resolve_member(member_id)) + for member_id in message.raw_mentions + ) + + if ctx.guild: + def resolve_role(id, *, _find=discord.utils.find, _roles=ctx.guild.roles): + r = _find(lambda x: x.id == id, _roles) + return '@' + r.name if r else '@deleted-role' + + transformations.update( + ('<@&%s>' % role_id, resolve_role(role_id)) + for role_id in message.raw_role_mentions + ) + + def repl(obj): + return transformations.get(obj.group(0), '') + + pattern = re.compile('|'.join(transformations.keys())) + result = pattern.sub(repl, argument) + + if self.escape_markdown: + transformations = { + re.escape(c): '\\' + c + for c in ('*', '`', '_', '~', '\\') + } + + def replace(obj): + return transformations.get(re.escape(obj.group(0)), '') + + pattern = re.compile('|'.join(transformations.keys())) + result = pattern.sub(replace, result) + + # Completely ensure no mentions escape: + return re.sub(r'@(everyone|here|[!&]?[0-9]{17,21})', '@\u200b\\1', result) diff --git a/discord.py-rewrite/discord/ext/commands/cooldowns.py b/discord.py-rewrite/discord/ext/commands/cooldowns.py new file mode 100644 index 0000000..7414628 --- /dev/null +++ b/discord.py-rewrite/discord/ext/commands/cooldowns.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import enum +import time + +__all__ = ['BucketType', 'Cooldown', 'CooldownMapping'] + +class BucketType(enum.Enum): + default = 0 + user = 1 + guild = 2 + channel = 3 + +class Cooldown: + __slots__ = ('rate', 'per', 'type', '_window', '_tokens', '_last') + + def __init__(self, rate, per, type): + self.rate = int(rate) + self.per = float(per) + self.type = type + self._window = 0.0 + self._tokens = self.rate + self._last = 0.0 + + if not isinstance(self.type, BucketType): + raise TypeError('Cooldown type must be a BucketType') + + def is_rate_limited(self): + current = time.time() + self._last = current + + # first token used means that we start a new rate limit window + if self._tokens == self.rate: + self._window = current + + # check if our window has passed and we can refresh our tokens + if current > self._window + self.per: + self._tokens = self.rate + self._window = current + + # check if we're rate limited + if self._tokens == 0: + return self.per - (current - self._window) + + # we're not so decrement our tokens + self._tokens -= 1 + + # see if we got rate limited due to this token change, and if + # so update the window to point to our current time frame + if self._tokens == 0: + self._window = current + + def reset(self): + self._tokens = self.rate + self._last = 0.0 + + def copy(self): + return Cooldown(self.rate, self.per, self.type) + + def __repr__(self): + return ''.format(self) + +class CooldownMapping: + def __init__(self, original): + self._cache = {} + self._cooldown = original + + @property + def valid(self): + return self._cooldown is not None + + @classmethod + def from_cooldown(cls, rate, per, type): + return cls(Cooldown(rate, per, type)) + + def _bucket_key(self, ctx): + msg = ctx.message + bucket_type = self._cooldown.type + if bucket_type is BucketType.user: + return msg.author.id + elif bucket_type is BucketType.guild: + return getattr(msg.guild, 'id', msg.author.id) + elif bucket_type is BucketType.channel: + return msg.channel.id + + def _verify_cache_integrity(self): + # we want to delete all cache objects that haven't been used + # in a cooldown window. e.g. if we have a command that has a + # cooldown of 60s and it has not been used in 60s then that key should be deleted + current = time.time() + dead_keys = [k for k, v in self._cache.items() if current > v._last + v.per] + for k in dead_keys: + del self._cache[k] + + def get_bucket(self, ctx): + if self._cooldown.type is BucketType.default: + return self._cooldown + + self._verify_cache_integrity() + key = self._bucket_key(ctx) + if key not in self._cache: + bucket = self._cooldown.copy() + self._cache[key] = bucket + else: + bucket = self._cache[key] + + return bucket diff --git a/discord.py-rewrite/discord/ext/commands/core.py b/discord.py-rewrite/discord/ext/commands/core.py new file mode 100644 index 0000000..070e7e3 --- /dev/null +++ b/discord.py-rewrite/discord/ext/commands/core.py @@ -0,0 +1,1275 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import asyncio +import inspect +import discord +import functools + +from .errors import * +from .cooldowns import Cooldown, BucketType, CooldownMapping +from .view import quoted_word +from . import converter as converters + +__all__ = [ 'Command', 'Group', 'GroupMixin', 'command', 'group', + 'has_role', 'has_permissions', 'has_any_role', 'check', + 'bot_has_role', 'bot_has_permissions', 'bot_has_any_role', + 'cooldown', 'guild_only', 'is_owner', 'is_nsfw', ] + +def wrap_callback(coro): + @functools.wraps(coro) + @asyncio.coroutine + def wrapped(*args, **kwargs): + try: + ret = yield from coro(*args, **kwargs) + except CommandError: + raise + except asyncio.CancelledError: + return + except Exception as e: + raise CommandInvokeError(e) from e + return ret + return wrapped + +def hooked_wrapped_callback(command, ctx, coro): + @functools.wraps(coro) + @asyncio.coroutine + def wrapped(*args, **kwargs): + try: + ret = yield from coro(*args, **kwargs) + except CommandError: + ctx.command_failed = True + raise + except asyncio.CancelledError: + ctx.command_failed = True + return + except Exception as e: + ctx.command_failed = True + raise CommandInvokeError(e) from e + finally: + yield from command.call_after_hooks(ctx) + return ret + return wrapped + +def _convert_to_bool(argument): + lowered = argument.lower() + if lowered in ('yes', 'y', 'true', 't', '1', 'enable', 'on'): + return True + elif lowered in ('no', 'n', 'false', 'f', '0', 'disable', 'off'): + return False + else: + raise BadArgument(lowered + ' is not a recognised boolean option') + +class Command: + """A class that implements the protocol for a bot text command. + + These are not created manually, instead they are created via the + decorator or functional interface. + + Attributes + ----------- + name: str + The name of the command. + callback: coroutine + The coroutine that is executed when the command is called. + help: str + The long help text for the command. + brief: str + The short help text for the command. If this is not specified + then the first line of the long help text is used instead. + usage: str + A replacement for arguments in the default help text. + aliases: list + The list of aliases the command can be invoked under. + enabled: bool + A boolean that indicates if the command is currently enabled. + If the command is invoked while it is disabled, then + :exc:`.DisabledCommand` is raised to the :func:`.on_command_error` + event. Defaults to ``True``. + parent: Optional[command] + The parent command that this command belongs to. ``None`` is there + isn't one. + checks + A list of predicates that verifies if the command could be executed + with the given :class:`.Context` as the sole parameter. If an exception + is necessary to be thrown to signal failure, then one derived from + :exc:`.CommandError` should be used. Note that if the checks fail then + :exc:`.CheckFailure` exception is raised to the :func:`.on_command_error` + event. + description: str + The message prefixed into the default help command. + hidden: bool + If ``True``\, the default help command does not show this in the + help output. + rest_is_raw: bool + If ``False`` and a keyword-only argument is provided then the keyword + only argument is stripped and handled as if it was a regular argument + that handles :exc:`.MissingRequiredArgument` and default values in a + regular matter rather than passing the rest completely raw. If ``True`` + then the keyword-only argument will pass in the rest of the arguments + in a completely raw matter. Defaults to ``False``. + ignore_extra: bool + If ``True``\, ignores extraneous strings passed to a command if all its + requirements are met (e.g. ``?foo a b c`` when only expecting ``a`` + and ``b``). Otherwise :func:`.on_command_error` and local error handlers + are called with :exc:`.TooManyArguments`. Defaults to ``True``. + """ + def __init__(self, name, callback, **kwargs): + self.name = name + if not isinstance(name, str): + raise TypeError('Name of a command must be a string.') + + self.callback = callback + self.enabled = kwargs.get('enabled', True) + self.help = kwargs.get('help') + self.brief = kwargs.get('brief') + self.usage = kwargs.get('usage') + self.rest_is_raw = kwargs.get('rest_is_raw', False) + self.aliases = kwargs.get('aliases', []) + self.description = inspect.cleandoc(kwargs.get('description', '')) + self.hidden = kwargs.get('hidden', False) + signature = inspect.signature(callback) + self.params = signature.parameters.copy() + self.checks = kwargs.get('checks', []) + self.module = callback.__module__ + self.ignore_extra = kwargs.get('ignore_extra', True) + self.instance = None + self.parent = None + self._buckets = CooldownMapping(kwargs.get('cooldown')) + self._before_invoke = None + self._after_invoke = None + + @asyncio.coroutine + def dispatch_error(self, ctx, error): + ctx.command_failed = True + cog = self.instance + try: + coro = self.on_error + except AttributeError: + pass + else: + injected = wrap_callback(coro) + if cog is not None: + yield from injected(cog, ctx, error) + else: + yield from injected(ctx, error) + + try: + local = getattr(cog, '_{0.__class__.__name__}__error'.format(cog)) + except AttributeError: + pass + else: + wrapped = wrap_callback(local) + yield from wrapped(ctx, error) + finally: + ctx.bot.dispatch('command_error', ctx, error) + + def __get__(self, instance, owner): + if instance is not None: + self.instance = instance + return self + + @asyncio.coroutine + def do_conversion(self, ctx, converter, argument): + if converter is bool: + return _convert_to_bool(argument) + + if converter.__module__.startswith('discord.') and not converter.__module__.endswith('converter'): + converter = getattr(converters, converter.__name__ + 'Converter') + + if inspect.isclass(converter): + if issubclass(converter, converters.Converter): + instance = converter() + ret = yield from instance.convert(ctx, argument) + return ret + else: + method = getattr(converter, 'convert', None) + if method is not None and inspect.ismethod(method): + ret = yield from method(ctx, argument) + return ret + elif isinstance(converter, converters.Converter): + ret = yield from converter.convert(ctx, argument) + return ret + + return converter(argument) + + def _get_converter(self, param): + converter = param.annotation + if converter is param.empty: + if param.default is not param.empty: + converter = str if param.default is None else type(param.default) + else: + converter = str + return converter + + @asyncio.coroutine + def transform(self, ctx, param): + required = param.default is param.empty + converter = self._get_converter(param) + consume_rest_is_special = param.kind == param.KEYWORD_ONLY and not self.rest_is_raw + view = ctx.view + view.skip_ws() + + if view.eof: + if param.kind == param.VAR_POSITIONAL: + raise RuntimeError() # break the loop + if required: + raise MissingRequiredArgument(param) + return param.default + + if consume_rest_is_special: + argument = view.read_rest().strip() + else: + argument = quoted_word(view) + + try: + return (yield from self.do_conversion(ctx, converter, argument)) + except CommandError as e: + raise e + except Exception as e: + try: + name = converter.__name__ + except AttributeError: + name = converter.__class__.__name__ + + raise BadArgument('Converting to "{}" failed for parameter "{}".'.format(name, param.name)) from e + + @property + def clean_params(self): + """Retrieves the parameter OrderedDict without the context or self parameters. + + Useful for inspecting signature. + """ + result = self.params.copy() + if self.instance is not None: + # first parameter is self + result.popitem(last=False) + + try: + # first/second parameter is context + result.popitem(last=False) + except Exception as e: + raise ValueError('Missing context parameter') from None + + return result + + @property + def full_parent_name(self): + """Retrieves the fully qualified parent command name. + + This the base command name required to execute it. For example, + in ``?one two three`` the parent name would be ``one two``. + """ + entries = [] + command = self + while command.parent is not None: + command = command.parent + entries.append(command.name) + + return ' '.join(reversed(entries)) + + @property + def root_parent(self): + """Retrieves the root parent of this command. + + If the command has no parents then it returns ``None``. + + For example in commands ``?a b c test``, the root parent is + ``a``. + """ + entries = [] + command = self + while command.parent is not None: + command = command.parent + entries.append(command) + + if len(entries) == 0: + return None + + return entries[-1] + + @property + def qualified_name(self): + """Retrieves the fully qualified command name. + + This is the full parent name with the command name as well. + For example, in ``?one two three`` the qualified name would be + ``one two three``. + """ + + parent = self.full_parent_name + if parent: + return parent + ' ' + self.name + else: + return self.name + + def __str__(self): + return self.qualified_name + + @asyncio.coroutine + def _parse_arguments(self, ctx): + ctx.args = [ctx] if self.instance is None else [self.instance, ctx] + ctx.kwargs = {} + args = ctx.args + kwargs = ctx.kwargs + + view = ctx.view + iterator = iter(self.params.items()) + + if self.instance is not None: + # we have 'self' as the first parameter so just advance + # the iterator and resume parsing + try: + next(iterator) + except StopIteration: + fmt = 'Callback for {0.name} command is missing "self" parameter.' + raise discord.ClientException(fmt.format(self)) + + # next we have the 'ctx' as the next parameter + try: + next(iterator) + except StopIteration: + fmt = 'Callback for {0.name} command is missing "ctx" parameter.' + raise discord.ClientException(fmt.format(self)) + + for name, param in iterator: + if param.kind == param.POSITIONAL_OR_KEYWORD: + transformed = yield from self.transform(ctx, param) + args.append(transformed) + elif param.kind == param.KEYWORD_ONLY: + # kwarg only param denotes "consume rest" semantics + if self.rest_is_raw: + converter = self._get_converter(param) + argument = view.read_rest() + kwargs[name] = yield from self.do_conversion(ctx, converter, argument) + else: + kwargs[name] = yield from self.transform(ctx, param) + break + elif param.kind == param.VAR_POSITIONAL: + while not view.eof: + try: + transformed = yield from self.transform(ctx, param) + args.append(transformed) + except RuntimeError: + break + + if not self.ignore_extra: + if not view.eof: + raise TooManyArguments('Too many arguments passed to ' + self.qualified_name) + + @asyncio.coroutine + def _verify_checks(self, ctx): + if not self.enabled: + raise DisabledCommand('{0.name} command is disabled'.format(self)) + + if not (yield from self.can_run(ctx)): + raise CheckFailure('The check functions for command {0.qualified_name} failed.'.format(self)) + + @asyncio.coroutine + def call_before_hooks(self, ctx): + # now that we're done preparing we can call the pre-command hooks + # first, call the command local hook: + cog = self.instance + if self._before_invoke is not None: + if cog is None: + yield from self._before_invoke(ctx) + else: + yield from self._before_invoke(cog, ctx) + + # call the cog local hook if applicable: + try: + hook = getattr(cog, '_{0.__class__.__name__}__before_invoke'.format(cog)) + except AttributeError: + pass + else: + yield from hook(ctx) + + # call the bot global hook if necessary + hook = ctx.bot._before_invoke + if hook is not None: + yield from hook(ctx) + + @asyncio.coroutine + def call_after_hooks(self, ctx): + cog = self.instance + if self._after_invoke is not None: + if cog is None: + yield from self._after_invoke(ctx) + else: + yield from self._after_invoke(cog, ctx) + + try: + hook = getattr(cog, '_{0.__class__.__name__}__after_invoke'.format(cog)) + except AttributeError: + pass + else: + yield from hook(ctx) + + hook = ctx.bot._after_invoke + if hook is not None: + yield from hook(ctx) + + @asyncio.coroutine + def prepare(self, ctx): + ctx.command = self + yield from self._verify_checks(ctx) + + if self._buckets.valid: + bucket = self._buckets.get_bucket(ctx) + retry_after = bucket.is_rate_limited() + if retry_after: + raise CommandOnCooldown(bucket, retry_after) + + yield from self._parse_arguments(ctx) + yield from self.call_before_hooks(ctx) + + def reset_cooldown(self, ctx): + """Resets the cooldown on this command. + + Parameters + ----------- + ctx: :class:`.Context` + The invocation context to reset the cooldown under. + """ + if self._buckets.valid: + bucket = self._buckets.get_bucket(ctx) + bucket.reset() + + @asyncio.coroutine + def invoke(self, ctx): + yield from self.prepare(ctx) + + # terminate the invoked_subcommand chain. + # since we're in a regular command (and not a group) then + # the invoked subcommand is None. + ctx.invoked_subcommand = None + injected = hooked_wrapped_callback(self, ctx, self.callback) + yield from injected(*ctx.args, **ctx.kwargs) + + @asyncio.coroutine + def reinvoke(self, ctx, *, call_hooks=False): + ctx.command = self + yield from self._parse_arguments(ctx) + + if call_hooks: + yield from self.call_before_hooks(ctx) + + ctx.invoked_subcommand = None + try: + yield from self.callback(*ctx.args, **ctx.kwargs) + except: + ctx.command_failed = True + raise + finally: + if call_hooks: + yield from self.call_after_hooks(ctx) + + def error(self, coro): + """A decorator that registers a coroutine as a local error handler. + + A local error handler is an :func:`.on_command_error` event limited to + a single command. However, the :func:`.on_command_error` is still + invoked afterwards as the catch-all. + + Parameters + ----------- + coro + The coroutine to register as the local error handler. + + Raises + ------- + discord.ClientException + The coroutine is not actually a coroutine. + """ + + if not asyncio.iscoroutinefunction(coro): + raise discord.ClientException('The error handler must be a coroutine.') + + self.on_error = coro + return coro + + def before_invoke(self, coro): + """A decorator that registers a coroutine as a pre-invoke hook. + + A pre-invoke hook is called directly before the command is + called. This makes it a useful function to set up database + connections or any type of set up required. + + This pre-invoke hook takes a sole parameter, a :class:`.Context`. + + See :meth:`.Bot.before_invoke` for more info. + + Parameters + ----------- + coro + The coroutine to register as the pre-invoke hook. + + Raises + ------- + :exc:`.ClientException` + The coroutine is not actually a coroutine. + """ + if not asyncio.iscoroutinefunction(coro): + raise discord.ClientException('The error handler must be a coroutine.') + + self._before_invoke = coro + return coro + + def after_invoke(self, coro): + """A decorator that registers a coroutine as a post-invoke hook. + + A post-invoke hook is called directly after the command is + called. This makes it a useful function to clean-up database + connections or any type of clean up required. + + This post-invoke hook takes a sole parameter, a :class:`.Context`. + + See :meth:`.Bot.after_invoke` for more info. + + Parameters + ----------- + coro + The coroutine to register as the post-invoke hook. + + Raises + ------- + :exc:`.ClientException` + The coroutine is not actually a coroutine. + """ + if not asyncio.iscoroutinefunction(coro): + raise discord.ClientException('The error handler must be a coroutine.') + + self._after_invoke = coro + return coro + + @property + def cog_name(self): + """The name of the cog this command belongs to. None otherwise.""" + return type(self.instance).__name__ if self.instance is not None else None + + @property + def short_doc(self): + """Gets the "short" documentation of a command. + + By default, this is the :attr:`brief` attribute. + If that lookup leads to an empty string then the first line of the + :attr:`help` attribute is used instead. + """ + if self.brief: + return self.brief + if self.help: + return self.help.split('\n', 1)[0] + return '' + + @property + def signature(self): + """Returns a POSIX-like signature useful for help command output.""" + result = [] + parent = self.full_parent_name + if len(self.aliases) > 0: + aliases = '|'.join(self.aliases) + fmt = '[%s|%s]' % (self.name, aliases) + if parent: + fmt = parent + ' ' + fmt + result.append(fmt) + else: + name = self.name if not parent else parent + ' ' + self.name + result.append(name) + + if self.usage: + result.append(self.usage) + return ' '.join(result) + + params = self.clean_params + if not params: + return ' '.join(result) + + for name, param in params.items(): + if param.default is not param.empty: + # We don't want None or '' to trigger the [name=value] case and instead it should + # do [name] since [name=None] or [name=] are not exactly useful for the user. + should_print = param.default if isinstance(param.default, str) else param.default is not None + if should_print: + result.append('[%s=%s]' % (name, param.default)) + else: + result.append('[%s]' % name) + elif param.kind == param.VAR_POSITIONAL: + result.append('[%s...]' % name) + else: + result.append('<%s>' % name) + + return ' '.join(result) + + @asyncio.coroutine + def can_run(self, ctx): + """|coro| + + Checks if the command can be executed by checking all the predicates + inside the :attr:`.checks` attribute. + + Parameters + ----------- + ctx: :class:`.Context` + The ctx of the command currently being invoked. + + Returns + -------- + bool + A boolean indicating if the command can be invoked. + """ + + original = ctx.command + ctx.command = self + + try: + if not (yield from ctx.bot.can_run(ctx)): + raise CheckFailure('The global check functions for command {0.qualified_name} failed.'.format(self)) + + cog = self.instance + if cog is not None: + try: + local_check = getattr(cog, '_{0.__class__.__name__}__local_check'.format(cog)) + except AttributeError: + pass + else: + ret = yield from discord.utils.maybe_coroutine(local_check, ctx) + if not ret: + return False + + predicates = self.checks + if not predicates: + # since we have no checks, then we just return True. + return True + + return (yield from discord.utils.async_all(predicate(ctx) for predicate in predicates)) + finally: + ctx.command = original + +class GroupMixin: + """A mixin that implements common functionality for classes that behave + similar to :class:`.Group` and are allowed to register commands. + + Attributes + ----------- + all_commands: dict + A mapping of command name to :class:`.Command` or superclass + objects. + """ + def __init__(self, **kwargs): + self.all_commands = {} + super().__init__(**kwargs) + + @property + def commands(self): + """Set[:class:`.Command`]: A unique set of commands without aliases that are registered.""" + return set(self.all_commands.values()) + + def recursively_remove_all_commands(self): + for command in self.all_commands.copy().values(): + if isinstance(command, GroupMixin): + command.recursively_remove_all_commands() + self.remove_command(command.name) + + def add_command(self, command): + """Adds a :class:`.Command` or its superclasses into the internal list + of commands. + + This is usually not called, instead the :meth:`~.GroupMixin.command` or + :meth:`~.GroupMixin.group` shortcut decorators are used instead. + + Parameters + ----------- + command + The command to add. + + Raises + ------- + :exc:`.ClientException` + If the command is already registered. + TypeError + If the command passed is not a subclass of :class:`.Command`. + """ + + if not isinstance(command, Command): + raise TypeError('The command passed must be a subclass of Command') + + if isinstance(self, Command): + command.parent = self + + if command.name in self.all_commands: + raise discord.ClientException('Command {0.name} is already registered.'.format(command)) + + self.all_commands[command.name] = command + for alias in command.aliases: + if alias in self.all_commands: + raise discord.ClientException('The alias {} is already an existing command or alias.'.format(alias)) + self.all_commands[alias] = command + + def remove_command(self, name): + """Remove a :class:`.Command` or subclasses from the internal list + of commands. + + This could also be used as a way to remove aliases. + + Parameters + ----------- + name: str + The name of the command to remove. + + Returns + -------- + :class:`.Command` or subclass + The command that was removed. If the name is not valid then + `None` is returned instead. + """ + command = self.all_commands.pop(name, None) + + # does not exist + if command is None: + return None + + if name in command.aliases: + # we're removing an alias so we don't want to remove the rest + return command + + # we're not removing the alias so let's delete the rest of them. + for alias in command.aliases: + self.all_commands.pop(alias, None) + return command + + def walk_commands(self): + """An iterator that recursively walks through all commands and subcommands.""" + for command in tuple(self.all_commands.values()): + yield command + if isinstance(command, GroupMixin): + yield from command.walk_commands() + + def get_command(self, name): + """Get a :class:`.Command` or subclasses from the internal list + of commands. + + This could also be used as a way to get aliases. + + The name could be fully qualified (e.g. ``'foo bar'``) will get + the subcommand ``bar`` of the group command ``foo``. If a + subcommand is not found then ``None`` is returned just as usual. + + Parameters + ----------- + name: str + The name of the command to get. + + Returns + -------- + Command or subclass + The command that was requested. If not found, returns ``None``. + """ + + names = name.split() + obj = self.all_commands.get(names[0]) + if not isinstance(obj, GroupMixin): + return obj + + for name in names[1:]: + try: + obj = obj.all_commands[name] + except (AttributeError, KeyError): + return None + + return obj + + def command(self, *args, **kwargs): + """A shortcut decorator that invokes :func:`.command` and adds it to + the internal command list via :meth:`~.GroupMixin.add_command`. + """ + def decorator(func): + result = command(*args, **kwargs)(func) + self.add_command(result) + return result + + return decorator + + def group(self, *args, **kwargs): + """A shortcut decorator that invokes :func:`.group` and adds it to + the internal command list via :meth:`~.GroupMixin.add_command`. + """ + def decorator(func): + result = group(*args, **kwargs)(func) + self.add_command(result) + return result + + return decorator + +class Group(GroupMixin, Command): + """A class that implements a grouping protocol for commands to be + executed as subcommands. + + This class is a subclass of :class:`.Command` and thus all options + valid in :class:`.Command` are valid in here as well. + + Attributes + ----------- + invoke_without_command: bool + Indicates if the group callback should begin parsing and + invocation only if no subcommand was found. Useful for + making it an error handling function to tell the user that + no subcommand was found or to have different functionality + in case no subcommand was found. If this is ``False``, then + the group callback will always be invoked first. This means + that the checks and the parsing dictated by its parameters + will be executed. Defaults to ``False``. + """ + def __init__(self, **attrs): + self.invoke_without_command = attrs.pop('invoke_without_command', False) + super().__init__(**attrs) + + @asyncio.coroutine + def invoke(self, ctx): + early_invoke = not self.invoke_without_command + if early_invoke: + yield from self.prepare(ctx) + + view = ctx.view + previous = view.index + view.skip_ws() + trigger = view.get_word() + + if trigger: + ctx.subcommand_passed = trigger + ctx.invoked_subcommand = self.all_commands.get(trigger, None) + + if early_invoke: + injected = hooked_wrapped_callback(self, ctx, self.callback) + yield from injected(*ctx.args, **ctx.kwargs) + + if trigger and ctx.invoked_subcommand: + ctx.invoked_with = trigger + yield from ctx.invoked_subcommand.invoke(ctx) + elif not early_invoke: + # undo the trigger parsing + view.index = previous + view.previous = previous + yield from super().invoke(ctx) + + @asyncio.coroutine + def reinvoke(self, ctx, *, call_hooks=False): + early_invoke = not self.invoke_without_command + if early_invoke: + ctx.command = self + yield from self._parse_arguments(ctx) + + if call_hooks: + yield from self.call_before_hooks(ctx) + + view = ctx.view + previous = view.index + view.skip_ws() + trigger = view.get_word() + + if trigger: + ctx.subcommand_passed = trigger + ctx.invoked_subcommand = self.all_commands.get(trigger, None) + + if early_invoke: + try: + yield from self.callback(*ctx.args, **ctx.kwargs) + except: + ctx.command_failed = True + raise + finally: + if call_hooks: + yield from self.call_after_hooks(ctx) + + if trigger and ctx.invoked_subcommand: + ctx.invoked_with = trigger + yield from ctx.invoked_subcommand.reinvoke(ctx, call_hooks=call_hooks) + elif not early_invoke: + # undo the trigger parsing + view.index = previous + view.previous = previous + yield from super().reinvoke(ctx, call_hooks=call_hooks) + +# Decorators + +def command(name=None, cls=None, **attrs): + """A decorator that transforms a function into a :class:`.Command` + or if called with :func:`.group`, :class:`.Group`. + + By default the ``help`` attribute is received automatically from the + docstring of the function and is cleaned up with the use of + ``inspect.cleandoc``. If the docstring is ``bytes``, then it is decoded + into ``str`` using utf-8 encoding. + + All checks added using the :func:`.check` & co. decorators are added into + the function. There is no way to supply your own checks through this + decorator. + + Parameters + ----------- + name: str + The name to create the command with. By default this uses the + function name unchanged. + cls + The class to construct with. By default this is :class:`.Command`. + You usually do not change this. + attrs + Keyword arguments to pass into the construction of the class denoted + by ``cls``. + + Raises + ------- + TypeError + If the function is not a coroutine or is already a command. + """ + if cls is None: + cls = Command + + def decorator(func): + if isinstance(func, Command): + raise TypeError('Callback is already a command.') + if not asyncio.iscoroutinefunction(func): + raise TypeError('Callback must be a coroutine.') + + try: + checks = func.__commands_checks__ + checks.reverse() + del func.__commands_checks__ + except AttributeError: + checks = [] + + try: + cooldown = func.__commands_cooldown__ + del func.__commands_cooldown__ + except AttributeError: + cooldown = None + + help_doc = attrs.get('help') + if help_doc is not None: + help_doc = inspect.cleandoc(help_doc) + else: + help_doc = inspect.getdoc(func) + if isinstance(help_doc, bytes): + help_doc = help_doc.decode('utf-8') + + attrs['help'] = help_doc + fname = name or func.__name__ + return cls(name=fname, callback=func, checks=checks, cooldown=cooldown, **attrs) + + return decorator + +def group(name=None, **attrs): + """A decorator that transforms a function into a :class:`.Group`. + + This is similar to the :func:`.command` decorator but creates a + :class:`.Group` instead of a :class:`.Command`. + """ + return command(name=name, cls=Group, **attrs) + +def check(predicate): + """A decorator that adds a check to the :class:`.Command` or its + subclasses. These checks could be accessed via :attr:`.Command.checks`. + + These checks should be predicates that take in a single parameter taking + a :class:`.Context`. If the check returns a ``False``\-like value then + during invocation a :exc:`.CheckFailure` exception is raised and sent to + the :func:`.on_command_error` event. + + If an exception should be thrown in the predicate then it should be a + subclass of :exc:`.CommandError`. Any exception not subclassed from it + will be propagated while those subclassed will be sent to + :func:`.on_command_error`. + + .. note:: + + These functions can either be regular functions or coroutines. + + Parameters + ----------- + predicate + The predicate to check if the command should be invoked. + + Examples + --------- + + Creating a basic check to see if the command invoker is you. + + .. code-block:: python3 + + def check_if_it_is_me(ctx): + return ctx.message.author.id == 85309593344815104 + + @bot.command() + @commands.check(check_if_it_is_me) + async def only_for_me(ctx): + await ctx.send('I know you!') + + Transforming common checks into its own decorator: + + .. code-block:: python3 + + def is_me(): + def predicate(ctx): + return ctx.message.author.id == 85309593344815104 + return commands.check(predicate) + + @bot.command() + @is_me() + async def only_me(ctx): + await ctx.send('Only you!') + + """ + + def decorator(func): + if isinstance(func, Command): + func.checks.append(predicate) + else: + if not hasattr(func, '__commands_checks__'): + func.__commands_checks__ = [] + + func.__commands_checks__.append(predicate) + + return func + return decorator + +def has_role(name): + """A :func:`.check` that is added that checks if the member invoking the + command has the role specified via the name specified. + + The name is case sensitive and must be exact. No normalisation is done in + the input. + + If the message is invoked in a private message context then the check will + return ``False``. + + Parameters + ----------- + name: str + The name of the role to check. + """ + + def predicate(ctx): + if not isinstance(ctx.channel, discord.abc.GuildChannel): + return False + + role = discord.utils.get(ctx.author.roles, name=name) + return role is not None + + return check(predicate) + +def has_any_role(*names): + """A :func:`.check` that is added that checks if the member invoking the + command has **any** of the roles specified. This means that if they have + one out of the three roles specified, then this check will return `True`. + + Similar to :func:`.has_role`\, the names passed in must be exact. + + Parameters + ----------- + names + An argument list of names to check that the member has roles wise. + + Example + -------- + + .. code-block:: python3 + + @bot.command() + @commands.has_any_role('Library Devs', 'Moderators') + async def cool(ctx): + await ctx.send('You are cool indeed') + """ + def predicate(ctx): + if not isinstance(ctx.channel, discord.abc.GuildChannel): + return False + + getter = functools.partial(discord.utils.get, ctx.author.roles) + return any(getter(name=name) is not None for name in names) + return check(predicate) + +def has_permissions(**perms): + """A :func:`.check` that is added that checks if the member has any of + the permissions necessary. + + The permissions passed in must be exactly like the properties shown under + :class:`.discord.Permissions`. + + This check raises a special exception, :exc:`.MissingPermissions` + that is derived from :exc:`.CheckFailure`. + + Parameters + ------------ + perms + An argument list of permissions to check for. + + Example + --------- + + .. code-block:: python3 + + @bot.command() + @commands.has_permissions(manage_messages=True) + async def test(ctx): + await ctx.send('You can manage messages.') + + """ + def predicate(ctx): + ch = ctx.channel + permissions = ch.permissions_for(ctx.author) + + missing = [perm for perm, value in perms.items() if getattr(permissions, perm, None) != value] + + if not missing: + return True + + raise MissingPermissions(missing) + + return check(predicate) + +def bot_has_role(name): + """Similar to :func:`.has_role` except checks if the bot itself has the + role. + """ + + def predicate(ctx): + ch = ctx.channel + if not isinstance(ch, discord.abc.GuildChannel): + return False + me = ch.guild.me + role = discord.utils.get(me.roles, name=name) + return role is not None + return check(predicate) + +def bot_has_any_role(*names): + """Similar to :func:`.has_any_role` except checks if the bot itself has + any of the roles listed. + """ + def predicate(ctx): + ch = ctx.channel + if not isinstance(ch, discord.abc.GuildChannel): + return False + me = ch.guild.me + getter = functools.partial(discord.utils.get, me.roles) + return any(getter(name=name) is not None for name in names) + return check(predicate) + +def bot_has_permissions(**perms): + """Similar to :func:`.has_permissions` except checks if the bot itself has + the permissions listed. + + This check raises a special exception, :exc:`.BotMissingPermissions` + that is derived from :exc:`.CheckFailure`. + """ + def predicate(ctx): + guild = ctx.guild + me = guild.me if guild is not None else ctx.bot.user + permissions = ctx.channel.permissions_for(me) + + missing = [perm for perm, value in perms.items() if getattr(permissions, perm, None) != value] + + if not missing: + return True + + raise BotMissingPermissions(missing) + + return check(predicate) + +def guild_only(): + """A :func:`.check` that indicates this command must only be used in a + guild context only. Basically, no private messages are allowed when + using the command. + + This check raises a special exception, :exc:`.NoPrivateMessage` + that is derived from :exc:`.CheckFailure`. + """ + + def predicate(ctx): + if ctx.guild is None: + raise NoPrivateMessage('This command cannot be used in private messages.') + return True + + return check(predicate) + +def is_owner(): + """A :func:`.check` that checks if the person invoking this command is the + owner of the bot. + + This is powered by :meth:`.Bot.is_owner`. + + This check raises a special exception, :exc:`.NotOwner` that is derived + from :exc:`.CheckFailure`. + """ + + @asyncio.coroutine + def predicate(ctx): + if not (yield from ctx.bot.is_owner(ctx.author)): + raise NotOwner('You do not own this bot.') + return True + + return check(predicate) + +def is_nsfw(): + """A :func:`.check` that checks if the channel is a NSFW channel.""" + def pred(ctx): + return isinstance(ctx.channel, discord.TextChannel) and ctx.channel.is_nsfw() + return check(pred) + +def cooldown(rate, per, type=BucketType.default): + """A decorator that adds a cooldown to a :class:`.Command` + or its subclasses. + + A cooldown allows a command to only be used a specific amount + of times in a specific time frame. These cooldowns can be based + either on a per-guild, per-channel, per-user, or global basis. + Denoted by the third argument of ``type`` which must be of enum + type ``BucketType`` which could be either: + + - ``BucketType.default`` for a global basis. + - ``BucketType.user`` for a per-user basis. + - ``BucketType.guild`` for a per-guild basis. + - ``BucketType.channel`` for a per-channel basis. + + If a cooldown is triggered, then :exc:`.CommandOnCooldown` is triggered in + :func:`.on_command_error` and the local error handler. + + A command can only have a single cooldown. + + Parameters + ------------ + rate: int + The number of times a command can be used before triggering a cooldown. + per: float + The amount of seconds to wait for a cooldown when it's been triggered. + type: ``BucketType`` + The type of cooldown to have. + """ + + def decorator(func): + if isinstance(func, Command): + func._buckets = CooldownMapping(Cooldown(rate, per, type)) + else: + func.__commands_cooldown__ = Cooldown(rate, per, type) + return func + return decorator diff --git a/discord.py-rewrite/discord/ext/commands/errors.py b/discord.py-rewrite/discord/ext/commands/errors.py new file mode 100644 index 0000000..c7d8774 --- /dev/null +++ b/discord.py-rewrite/discord/ext/commands/errors.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from discord.errors import DiscordException + + +__all__ = [ 'CommandError', 'MissingRequiredArgument', 'BadArgument', + 'NoPrivateMessage', 'CheckFailure', 'CommandNotFound', + 'DisabledCommand', 'CommandInvokeError', 'TooManyArguments', + 'UserInputError', 'CommandOnCooldown', 'NotOwner', + 'MissingPermissions', 'BotMissingPermissions'] + +class CommandError(DiscordException): + """The base exception type for all command related errors. + + This inherits from :exc:`discord.DiscordException`. + + This exception and exceptions derived from it are handled + in a special way as they are caught and passed into a special event + from :class:`.Bot`\, :func:`on_command_error`. + """ + def __init__(self, message=None, *args): + if message is not None: + # clean-up @everyone and @here mentions + m = message.replace('@everyone', '@\u200beveryone').replace('@here', '@\u200bhere') + super().__init__(m, *args) + else: + super().__init__(*args) + +class UserInputError(CommandError): + """The base exception type for errors that involve errors + regarding user input. + + This inherits from :exc:`.CommandError`. + """ + pass + +class CommandNotFound(CommandError): + """Exception raised when a command is attempted to be invoked + but no command under that name is found. + + This is not raised for invalid subcommands, rather just the + initial main command that is attempted to be invoked. + """ + pass + +class MissingRequiredArgument(UserInputError): + """Exception raised when parsing a command and a parameter + that is required is not encountered. + + Attributes + ----------- + param: str + The argument that is missing. + """ + def __init__(self, param): + self.param = param.name + super().__init__('{0.name} is a required argument that is missing.'.format(param)) + +class TooManyArguments(UserInputError): + """Exception raised when the command was passed too many arguments and its + :attr:`.Command.ignore_extra` attribute was not set to ``True``. + """ + pass + +class BadArgument(UserInputError): + """Exception raised when a parsing or conversion failure is encountered + on an argument to pass into a command. + """ + pass + +class CheckFailure(CommandError): + """Exception raised when the predicates in :attr:`.Command.checks` have failed.""" + pass + +class NoPrivateMessage(CheckFailure): + """Exception raised when an operation does not work in private message + contexts. + """ + pass + +class NotOwner(CheckFailure): + """Exception raised when the message author is not the owner of the bot.""" + pass + +class DisabledCommand(CommandError): + """Exception raised when the command being invoked is disabled.""" + pass + +class CommandInvokeError(CommandError): + """Exception raised when the command being invoked raised an exception. + + Attributes + ----------- + original + The original exception that was raised. You can also get this via + the ``__cause__`` attribute. + """ + def __init__(self, e): + self.original = e + super().__init__('Command raised an exception: {0.__class__.__name__}: {0}'.format(e)) + +class CommandOnCooldown(CommandError): + """Exception raised when the command being invoked is on cooldown. + + Attributes + ----------- + cooldown: Cooldown + A class with attributes ``rate``, ``per``, and ``type`` similar to + the :func:`.cooldown` decorator. + retry_after: float + The amount of seconds to wait before you can retry again. + """ + def __init__(self, cooldown, retry_after): + self.cooldown = cooldown + self.retry_after = retry_after + super().__init__('You are on cooldown. Try again in {:.2f}s'.format(retry_after)) + +class MissingPermissions(CheckFailure): + """Exception raised when the command invoker lacks permissions to run + command. + + Attributes + ----------- + missing_perms: list + The required permissions that are missing. + """ + def __init__(self, missing_perms, *args): + self.missing_perms = missing_perms + + missing = [perm.replace('_', ' ').replace('guild', 'server').title() for perm in missing_perms] + + if len(missing) > 2: + fmt = '{}, and {}'.format(", ".join(missing[:-1]), missing[-1]) + else: + fmt = ' and '.join(missing) + message = 'You are missing {} permission(s) to run command.'.format(fmt) + super().__init__(message, *args) + +class BotMissingPermissions(CheckFailure): + """Exception raised when the bot lacks permissions to run command. + + Attributes + ----------- + missing_perms: list + The required permissions that are missing. + """ + def __init__(self, missing_perms, *args): + self.missing_perms = missing_perms + + missing = [perm.replace('_', ' ').replace('guild', 'server').title() for perm in missing_perms] + + if len(missing) > 2: + fmt = '{}, and {}'.format(", ".join(missing[:-1]), missing[-1]) + else: + fmt = ' and '.join(missing) + message = 'Bot requires {} permission(s) to run command.'.format(fmt) + super().__init__(message, *args) diff --git a/discord.py-rewrite/discord/ext/commands/formatter.py b/discord.py-rewrite/discord/ext/commands/formatter.py new file mode 100644 index 0000000..8e30f58 --- /dev/null +++ b/discord.py-rewrite/discord/ext/commands/formatter.py @@ -0,0 +1,346 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import itertools +import inspect +import asyncio + +from .core import GroupMixin, Command +from .errors import CommandError +# from discord.iterators import _FilteredAsyncIterator + +# help -> shows info of bot on top/bottom and lists subcommands +# help command -> shows detailed info of command +# help command -> same as above + +# + +# + +# + +# Cog: +# +# +# Other Cog: +# +# No Category: +# + +# Type help command for more info on a command. +# You can also type help category for more info on a category. + +class Paginator: + """A class that aids in paginating code blocks for Discord messages. + + Attributes + ----------- + prefix: str + The prefix inserted to every page. e.g. three backticks. + suffix: str + The suffix appended at the end of every page. e.g. three backticks. + max_size: int + The maximum amount of codepoints allowed in a page. + """ + def __init__(self, prefix='```', suffix='```', max_size=2000): + self.prefix = prefix + self.suffix = suffix + self.max_size = max_size - len(suffix) + self._current_page = [prefix] + self._count = len(prefix) + 1 # prefix + newline + self._pages = [] + + def add_line(self, line='', *, empty=False): + """Adds a line to the current page. + + If the line exceeds the :attr:`max_size` then an exception + is raised. + + Parameters + ----------- + line: str + The line to add. + empty: bool + Indicates if another empty line should be added. + + Raises + ------ + RuntimeError + The line was too big for the current :attr:`max_size`. + """ + if len(line) > self.max_size - len(self.prefix) - 2: + raise RuntimeError('Line exceeds maximum page size %s' % (self.max_size - len(self.prefix) - 2)) + + if self._count + len(line) + 1 > self.max_size: + self.close_page() + + self._count += len(line) + 1 + self._current_page.append(line) + + if empty: + self._current_page.append('') + self._count += 1 + + def close_page(self): + """Prematurely terminate a page.""" + self._current_page.append(self.suffix) + self._pages.append('\n'.join(self._current_page)) + self._current_page = [self.prefix] + self._count = len(self.prefix) + 1 # prefix + newline + + @property + def pages(self): + """Returns the rendered list of pages.""" + # we have more than just the prefix in our current page + if len(self._current_page) > 1: + self.close_page() + return self._pages + + def __repr__(self): + fmt = '' + return fmt.format(self) + +class HelpFormatter: + """The default base implementation that handles formatting of the help + command. + + To override the behaviour of the formatter, :meth:`~.HelpFormatter.format` + should be overridden. A number of utility functions are provided for use + inside that method. + + Attributes + ----------- + show_hidden: bool + Dictates if hidden commands should be shown in the output. + Defaults to ``False``. + show_check_failure: bool + Dictates if commands that have their :attr:`.Command.checks` failed + shown. Defaults to ``False``. + width: int + The maximum number of characters that fit in a line. + Defaults to 80. + """ + def __init__(self, show_hidden=False, show_check_failure=False, width=80): + self.width = width + self.show_hidden = show_hidden + self.show_check_failure = show_check_failure + + def has_subcommands(self): + """bool: Specifies if the command has subcommands.""" + return isinstance(self.command, GroupMixin) + + def is_bot(self): + """bool: Specifies if the command being formatted is the bot itself.""" + return self.command is self.context.bot + + def is_cog(self): + """bool: Specifies if the command being formatted is actually a cog.""" + return not self.is_bot() and not isinstance(self.command, Command) + + def shorten(self, text): + """Shortens text to fit into the :attr:`width`.""" + if len(text) > self.width: + return text[:self.width - 3] + '...' + return text + + @property + def max_name_size(self): + """int: Returns the largest name length of a command or if it has subcommands + the largest subcommand name.""" + try: + commands = self.command.all_commands if not self.is_cog() else self.context.bot.all_commands + if commands: + return max(map(lambda c: len(c.name) if self.show_hidden or not c.hidden else 0, commands.values())) + return 0 + except AttributeError: + return len(self.command.name) + + @property + def clean_prefix(self): + """The cleaned up invoke prefix. i.e. mentions are ``@name`` instead of ``<@id>``.""" + user = self.context.bot.user + # this breaks if the prefix mention is not the bot itself but I + # consider this to be an *incredibly* strange use case. I'd rather go + # for this common use case rather than waste performance for the + # odd one. + return self.context.prefix.replace(user.mention, '@' + user.name) + + def get_command_signature(self): + """Retrieves the signature portion of the help page.""" + prefix = self.clean_prefix + cmd = self.command + return prefix + cmd.signature + + def get_ending_note(self): + command_name = self.context.invoked_with + return "Type {0}{1} command for more info on a command.\n" \ + "You can also type {0}{1} category for more info on a category.".format(self.clean_prefix, command_name) + + @asyncio.coroutine + def filter_command_list(self): + """Returns a filtered list of commands based on the two attributes + provided, :attr:`show_check_failure` and :attr:`show_hidden`. + Also filters based on if :meth:`~.HelpFormatter.is_cog` is valid. + + Returns + -------- + iterable + An iterable with the filter being applied. The resulting value is + a (key, value) tuple of the command name and the command itself. + """ + + def sane_no_suspension_point_predicate(tup): + cmd = tup[1] + if self.is_cog(): + # filter commands that don't exist to this cog. + if cmd.instance is not self.command: + return False + + if cmd.hidden and not self.show_hidden: + return False + + return True + + @asyncio.coroutine + def predicate(tup): + if sane_no_suspension_point_predicate(tup) is False: + return False + + cmd = tup[1] + try: + return (yield from cmd.can_run(self.context)) + except CommandError: + return False + + iterator = self.command.all_commands.items() if not self.is_cog() else self.context.bot.all_commands.items() + if self.show_check_failure: + return filter(sane_no_suspension_point_predicate, iterator) + + # Gotta run every check and verify it + ret = [] + for elem in iterator: + valid = yield from predicate(elem) + if valid: + ret.append(elem) + + return ret + + def _add_subcommands_to_page(self, max_width, commands): + for name, command in commands: + if name in command.aliases: + # skip aliases + continue + + entry = ' {0:<{width}} {1}'.format(name, command.short_doc, width=max_width) + shortened = self.shorten(entry) + self._paginator.add_line(shortened) + + @asyncio.coroutine + def format_help_for(self, context, command_or_bot): + """Formats the help page and handles the actual heavy lifting of how + the help command looks like. To change the behaviour, override the + :meth:`~.HelpFormatter.format` method. + + Parameters + ----------- + context: :class:`.Context` + The context of the invoked help command. + command_or_bot: :class:`.Command` or :class:`.Bot` + The bot or command that we are getting the help of. + + Returns + -------- + list + A paginated output of the help command. + """ + self.context = context + self.command = command_or_bot + return (yield from self.format()) + + @asyncio.coroutine + def format(self): + """Handles the actual behaviour involved with formatting. + + To change the behaviour, this method should be overridden. + + Returns + -------- + list + A paginated output of the help command. + """ + self._paginator = Paginator() + + # we need a padding of ~80 or so + + description = self.command.description if not self.is_cog() else inspect.getdoc(self.command) + + if description: + # portion + self._paginator.add_line(description, empty=True) + + if isinstance(self.command, Command): + # + signature = self.get_command_signature() + self._paginator.add_line(signature, empty=True) + + # section + if self.command.help: + self._paginator.add_line(self.command.help, empty=True) + + # end it here if it's just a regular command + if not self.has_subcommands(): + self._paginator.close_page() + return self._paginator.pages + + max_width = self.max_name_size + + def category(tup): + cog = tup[1].cog_name + # we insert the zero width space there to give it approximate + # last place sorting position. + return cog + ':' if cog is not None else '\u200bNo Category:' + + filtered = yield from self.filter_command_list() + if self.is_bot(): + data = sorted(filtered, key=category) + for category, commands in itertools.groupby(data, key=category): + # there simply is no prettier way of doing this. + commands = sorted(commands) + if len(commands) > 0: + self._paginator.add_line(category) + + self._add_subcommands_to_page(max_width, commands) + else: + filtered = sorted(filtered) + if filtered: + self._paginator.add_line('Commands:') + self._add_subcommands_to_page(max_width, filtered) + + # add the ending note + self._paginator.add_line() + ending_note = self.get_ending_note() + self._paginator.add_line(ending_note) + return self._paginator.pages diff --git a/discord.py-rewrite/discord/ext/commands/view.py b/discord.py-rewrite/discord/ext/commands/view.py new file mode 100644 index 0000000..acde0b7 --- /dev/null +++ b/discord.py-rewrite/discord/ext/commands/view.py @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from .errors import BadArgument + +class StringView: + def __init__(self, buffer): + self.index = 0 + self.buffer = buffer + self.end = len(buffer) + self.previous = 0 + + @property + def current(self): + return None if self.eof else self.buffer[self.index] + + @property + def eof(self): + return self.index >= self.end + + def undo(self): + self.index = self.previous + + def skip_ws(self): + pos = 0 + while not self.eof: + try: + current = self.buffer[self.index + pos] + if not current.isspace(): + break + pos += 1 + except IndexError: + break + + self.previous = self.index + self.index += pos + return self.previous != self.index + + def skip_string(self, string): + strlen = len(string) + if self.buffer[self.index:self.index + strlen] == string: + self.previous = self.index + self.index += strlen + return True + return False + + def read_rest(self): + result = self.buffer[self.index:] + self.previous = self.index + self.index = self.end + return result + + def read(self, n): + result = self.buffer[self.index:self.index + n] + self.previous = self.index + self.index += n + return result + + def get(self): + try: + result = self.buffer[self.index + 1] + except IndexError: + result = None + + self.previous = self.index + self.index += 1 + return result + + def get_word(self): + pos = 0 + while not self.eof: + try: + current = self.buffer[self.index + pos] + if current.isspace(): + break + pos += 1 + except IndexError: + break + self.previous = self.index + result = self.buffer[self.index:self.index + pos] + self.index += pos + return result + + def __repr__(self): + return ''.format(self) + +# Parser + +def quoted_word(view): + current = view.current + + if current is None: + return None + + is_quoted = current == '"' + result = [] if is_quoted else [current] + + while not view.eof: + current = view.get() + if not current: + if is_quoted: + # unexpected EOF + raise BadArgument('Expected closing "') + return ''.join(result) + + # currently we accept strings in the format of "hello world" + # to embed a quote inside the string you must escape it: "a \"world\"" + if current == '\\': + next_char = view.get() + if not next_char: + # string ends with \ and no character after it + if is_quoted: + # if we're quoted then we're expecting a closing quote + raise BadArgument('Expected closing "') + # if we aren't then we just let it through + return ''.join(result) + + if next_char == '"': + # escaped quote + result.append('"') + else: + # different escape character, ignore it + view.undo() + result.append(current) + continue + + # closing quote + if current == '"': + next_char = view.get() + valid_eof = not next_char or next_char.isspace() + if is_quoted: + if not valid_eof: + raise BadArgument('Expected space after closing quotation') + + # we're quoted so it's okay + return ''.join(result) + else: + # we aren't quoted + raise BadArgument('Unexpected quote mark in non-quoted string') + + if current.isspace() and not is_quoted: + # end of word found + return ''.join(result) + + result.append(current) diff --git a/discord.py-rewrite/discord/file.py b/discord.py-rewrite/discord/file.py new file mode 100644 index 0000000..c4733a3 --- /dev/null +++ b/discord.py-rewrite/discord/file.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import os.path + +class File: + """A parameter object used for :meth:`abc.Messageable.send` + for sending file objects. + + Attributes + ----------- + fp: Union[str, BinaryIO] + A file-like object opened in binary mode and read mode + or a filename representing a file in the hard drive to + open. + + .. note:: + + If the file-like object passed is opened via ``open`` then the + modes 'rb' should be used. + + To pass binary data, consider usage of ``io.BytesIO``. + + filename: Optional[str] + The filename to display when uploading to Discord. + If this is not given then it defaults to ``fp.name`` or if ``fp`` is + a string then the ``filename`` will default to the string given. + """ + + __slots__ = ('fp', 'filename', '_true_fp') + + def __init__(self, fp, filename=None): + self.fp = fp + self._true_fp = None + + if filename is None: + if isinstance(fp, str): + _, self.filename = os.path.split(fp) + else: + self.filename = getattr(fp, 'name', None) + else: + self.filename = filename + + def open_file(self): + fp = self.fp + if isinstance(fp, str): + self._true_fp = fp = open(fp, 'rb') + return fp + + def close(self): + if self._true_fp: + self._true_fp.close() diff --git a/discord.py-rewrite/discord/game.py b/discord.py-rewrite/discord/game.py new file mode 100644 index 0000000..238f22e --- /dev/null +++ b/discord.py-rewrite/discord/game.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +class Game: + """Represents a Discord game. + + .. container:: operations + + .. describe:: x == y + + Checks if two games are equal. + + .. describe:: x != y + + Checks if two games are not equal. + + .. describe:: hash(x) + + Returns the game's hash. + + .. describe:: str(x) + + Returns the game's name. + + Attributes + ----------- + name: str + The game's name. + url: str + The game's URL. Usually used for twitch streaming. + type: int + The type of game being played. 1 indicates "Streaming". + """ + + __slots__ = ('name', 'type', 'url') + + def __init__(self, **kwargs): + self.name = kwargs.get('name') + self.url = kwargs.get('url') + self.type = kwargs.get('type', 0) + + def __str__(self): + return str(self.name) + + def __repr__(self): + return ''.format(self) + + def _iterator(self): + for attr in self.__slots__: + value = getattr(self, attr, None) + if value is not None: + yield (attr, value) + + def __iter__(self): + return self._iterator() + + def __eq__(self, other): + return isinstance(other, Game) and other.name == self.name + + def __ne__(self, other): + return not self.__eq__(other) + + def __hash__(self): + return hash(self.name) diff --git a/discord.py-rewrite/discord/gateway.py b/discord.py-rewrite/discord/gateway.py new file mode 100644 index 0000000..0ab0276 --- /dev/null +++ b/discord.py-rewrite/discord/gateway.py @@ -0,0 +1,699 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import sys +import time +import websockets +import asyncio + +from . import utils, compat +from .game import Game +from .errors import ConnectionClosed, InvalidArgument +import logging +import zlib, json +from collections import namedtuple +import threading +import struct + +log = logging.getLogger(__name__) + +__all__ = [ 'DiscordWebSocket', 'KeepAliveHandler', 'VoiceKeepAliveHandler', + 'DiscordVoiceWebSocket', 'ResumeWebSocket' ] + +class ResumeWebSocket(Exception): + """Signals to initialise via RESUME opcode instead of IDENTIFY.""" + def __init__(self, shard_id): + self.shard_id = shard_id + +EventListener = namedtuple('EventListener', 'predicate event result future') + +class KeepAliveHandler(threading.Thread): + def __init__(self, *args, **kwargs): + ws = kwargs.pop('ws', None) + interval = kwargs.pop('interval', None) + shard_id = kwargs.pop('shard_id', None) + threading.Thread.__init__(self, *args, **kwargs) + self.ws = ws + self.interval = interval + self.daemon = True + self.shard_id = shard_id + self.msg = 'Keeping websocket alive with sequence %s.' + self._stop_ev = threading.Event() + self._last_ack = time.monotonic() + self._last_send = time.monotonic() + self.heartbeat_timeout = ws._max_heartbeat_timeout + + def run(self): + while not self._stop_ev.wait(self.interval): + if self._last_ack + self.heartbeat_timeout < time.monotonic(): + log.warn("Shard ID %s has stopped responding to the gateway. Closing and restarting." % self.shard_id) + coro = self.ws.close(1006) + f = compat.run_coroutine_threadsafe(coro, loop=self.ws.loop) + + try: + f.result() + except: + pass + finally: + self.stop() + return + + data = self.get_payload() + log.debug(self.msg, data['d']) + coro = self.ws.send_as_json(data) + f = compat.run_coroutine_threadsafe(coro, loop=self.ws.loop) + try: + # block until sending is complete + f.result() + except Exception: + self.stop() + else: + self._last_send = time.monotonic() + + def get_payload(self): + return { + 'op': self.ws.HEARTBEAT, + 'd': self.ws.sequence + } + + def stop(self): + self._stop_ev.set() + + def ack(self): + self._last_ack = time.monotonic() + +class VoiceKeepAliveHandler(KeepAliveHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.msg = 'Keeping voice websocket alive with timestamp %s.' + + def get_payload(self): + return { + 'op': self.ws.HEARTBEAT, + 'd': int(time.time() * 1000) + } + +class DiscordWebSocket(websockets.client.WebSocketClientProtocol): + """Implements a WebSocket for Discord's gateway v6. + + This is created through :func:`create_main_websocket`. Library + users should never create this manually. + + Attributes + ----------- + DISPATCH + Receive only. Denotes an event to be sent to Discord, such as READY. + HEARTBEAT + When received tells Discord to keep the connection alive. + When sent asks if your connection is currently alive. + IDENTIFY + Send only. Starts a new session. + PRESENCE + Send only. Updates your presence. + VOICE_STATE + Send only. Starts a new connection to a voice guild. + VOICE_PING + Send only. Checks ping time to a voice guild, do not use. + RESUME + Send only. Resumes an existing connection. + RECONNECT + Receive only. Tells the client to reconnect to a new gateway. + REQUEST_MEMBERS + Send only. Asks for the full member list of a guild. + INVALIDATE_SESSION + Receive only. Tells the client to optionally invalidate the session + and IDENTIFY again. + HELLO + Receive only. Tells the client the heartbeat interval. + HEARTBEAT_ACK + Receive only. Confirms receiving of a heartbeat. Not having it implies + a connection issue. + GUILD_SYNC + Send only. Requests a guild sync. + gateway + The gateway we are currently connected to. + token + The authentication token for discord. + """ + + DISPATCH = 0 + HEARTBEAT = 1 + IDENTIFY = 2 + PRESENCE = 3 + VOICE_STATE = 4 + VOICE_PING = 5 + RESUME = 6 + RECONNECT = 7 + REQUEST_MEMBERS = 8 + INVALIDATE_SESSION = 9 + HELLO = 10 + HEARTBEAT_ACK = 11 + GUILD_SYNC = 12 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.max_size = None + # an empty dispatcher to prevent crashes + self._dispatch = lambda *args: None + # generic event listeners + self._dispatch_listeners = [] + # the keep alive + self._keep_alive = None + + # ws related stuff + self.session_id = None + self.sequence = None + + @classmethod + @asyncio.coroutine + def from_client(cls, client, *, shard_id=None, session=None, sequence=None, resume=False): + """Creates a main websocket for Discord from a :class:`Client`. + + This is for internal use only. + """ + gateway = yield from client.http.get_gateway() + ws = yield from websockets.connect(gateway, loop=client.loop, klass=cls) + + # dynamically add attributes needed + ws.token = client.http.token + ws._connection = client._connection + ws._dispatch = client.dispatch + ws.gateway = gateway + ws.shard_id = shard_id + ws.shard_count = client._connection.shard_count + ws.session_id = session + ws.sequence = sequence + ws._max_heartbeat_timeout = client._connection.heartbeat_timeout + + client._connection._update_references(ws) + + log.info('Created websocket connected to %s', gateway) + + # poll event for OP Hello + yield from ws.poll_event() + + if not resume: + yield from ws.identify() + return ws + + yield from ws.resume() + try: + yield from ws.ensure_open() + except websockets.exceptions.ConnectionClosed: + # ws got closed so let's just do a regular IDENTIFY connect. + log.info('RESUME failed (the websocket decided to close) for Shard ID %s. Retrying.', shard_id) + return (yield from cls.from_client(client, shard_id=shard_id)) + else: + return ws + + def wait_for(self, event, predicate, result=None): + """Waits for a DISPATCH'd event that meets the predicate. + + Parameters + ----------- + event : str + The event name in all upper case to wait for. + predicate + A function that takes a data parameter to check for event + properties. The data parameter is the 'd' key in the JSON message. + result + A function that takes the same data parameter and executes to send + the result to the future. If None, returns the data. + + Returns + -------- + asyncio.Future + A future to wait for. + """ + + future = compat.create_future(self.loop) + entry = EventListener(event=event, predicate=predicate, result=result, future=future) + self._dispatch_listeners.append(entry) + return future + + @asyncio.coroutine + def identify(self): + """Sends the IDENTIFY packet.""" + payload = { + 'op': self.IDENTIFY, + 'd': { + 'token': self.token, + 'properties': { + '$os': sys.platform, + '$browser': 'discord.py', + '$device': 'discord.py', + '$referrer': '', + '$referring_domain': '' + }, + 'compress': True, + 'large_threshold': 250, + 'v': 3 + } + } + + if not self._connection.is_bot: + payload['d']['synced_guilds'] = [] + + if self.shard_id is not None and self.shard_count is not None: + payload['d']['shard'] = [self.shard_id, self.shard_count] + + state = self._connection + if state._game is not None or state._status is not None: + payload['d']['presence'] = { + 'status': state._status, + 'game': state._game, + 'since': 0, + 'afk': False + } + + yield from self.send_as_json(payload) + log.info('Shard ID %s has sent the IDENTIFY payload.', self.shard_id) + + @asyncio.coroutine + def resume(self): + """Sends the RESUME packet.""" + payload = { + 'op': self.RESUME, + 'd': { + 'seq': self.sequence, + 'session_id': self.session_id, + 'token': self.token + } + } + + yield from self.send_as_json(payload) + log.info('Shard ID %s has sent the RESUME payload.', self.shard_id) + + @asyncio.coroutine + def received_message(self, msg): + self._dispatch('socket_raw_receive', msg) + + if isinstance(msg, bytes): + msg = zlib.decompress(msg, 15, 10490000) # This is 10 MiB + msg = msg.decode('utf-8') + + msg = json.loads(msg) + + log.debug('For Shard ID %s: WebSocket Event: %s', self.shard_id, msg) + self._dispatch('socket_response', msg) + + op = msg.get('op') + data = msg.get('d') + seq = msg.get('s') + if seq is not None: + self.sequence = seq + + if op == self.RECONNECT: + # "reconnect" can only be handled by the Client + # so we terminate our connection and raise an + # internal exception signalling to reconnect. + log.info('Received RECONNECT opcode.') + yield from self.close() + raise ResumeWebSocket(self.shard_id) + + if op == self.HEARTBEAT_ACK: + self._keep_alive.ack() + return + + if op == self.HEARTBEAT: + beat = self._keep_alive.get_payload() + yield from self.send_as_json(beat) + return + + if op == self.HELLO: + interval = data['heartbeat_interval'] / 1000.0 + self._keep_alive = KeepAliveHandler(ws=self, interval=interval, shard_id=self.shard_id) + # send a heartbeat immediately + yield from self.send_as_json(self._keep_alive.get_payload()) + self._keep_alive.start() + return + + if op == self.INVALIDATE_SESSION: + if data == True: + yield from asyncio.sleep(5.0, loop=self.loop) + yield from self.close() + raise ResumeWebSocket(self.shard_id) + + self.sequence = None + self.session_id = None + log.info('Shard ID %s session has been invalidated.' % self.shard_id) + yield from self.identify() + return + + if op != self.DISPATCH: + log.warning('Unknown OP code %s.', op) + return + + event = msg.get('t') + + if event == 'READY': + self._trace = trace = data.get('_trace', []) + self.sequence = msg['s'] + self.session_id = data['session_id'] + log.info('Shard ID %s has connected to Gateway: %s (Session ID: %s).', + self.shard_id, ', '.join(trace), self.session_id) + + if event == 'RESUMED': + self._trace = trace = data.get('_trace', []) + log.info('Shard ID %s has successfully RESUMED session %s under trace %s.', + self.shard_id, self.session_id, ', '.join(trace)) + + parser = 'parse_' + event.lower() + + try: + func = getattr(self._connection, parser) + except AttributeError: + log.warning('Unknown event %s.', event) + else: + func(data) + + # remove the dispatched listeners + removed = [] + for index, entry in enumerate(self._dispatch_listeners): + if entry.event != event: + continue + + future = entry.future + if future.cancelled(): + removed.append(index) + continue + + try: + valid = entry.predicate(data) + except Exception as e: + future.set_exception(e) + removed.append(index) + else: + if valid: + ret = data if entry.result is None else entry.result(data) + future.set_result(ret) + removed.append(index) + + for index in reversed(removed): + del self._dispatch_listeners[index] + + @property + def latency(self): + """float: Measures latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds.""" + heartbeat = self._keep_alive + return float('inf') if heartbeat is None else heartbeat._last_ack - heartbeat._last_send + + def _can_handle_close(self, code): + return code not in (1000, 4004, 4010, 4011) + + @asyncio.coroutine + def poll_event(self): + """Polls for a DISPATCH event and handles the general gateway loop. + + Raises + ------ + ConnectionClosed + The websocket connection was terminated for unhandled reasons. + """ + try: + msg = yield from self.recv() + yield from self.received_message(msg) + except websockets.exceptions.ConnectionClosed as e: + if self._can_handle_close(e.code): + log.info('Websocket closed with %s (%s), attempting a reconnect.', e.code, e.reason) + raise ResumeWebSocket(self.shard_id) from e + else: + log.info('Websocket closed with %s (%s), cannot reconnect.', e.code, e.reason) + raise ConnectionClosed(e, shard_id=self.shard_id) from e + + @asyncio.coroutine + def send(self, data): + self._dispatch('socket_raw_send', data) + yield from super().send(data) + + @asyncio.coroutine + def send_as_json(self, data): + try: + yield from super().send(utils.to_json(data)) + except websockets.exceptions.ConnectionClosed as e: + if not self._can_handle_close(e.code): + raise ConnectionClosed(e, shard_id=self.shard_id) from e + + @asyncio.coroutine + def change_presence(self, *, game=None, status=None, afk=False, since=0.0): + if game is not None and not isinstance(game, Game): + raise InvalidArgument('game must be of type Game or None') + + if status == 'idle': + since = int(time.time() * 1000) + + sent_game = dict(game) if game else None + + payload = { + 'op': self.PRESENCE, + 'd': { + 'game': sent_game, + 'afk': afk, + 'since': since, + 'status': status + } + } + + sent = utils.to_json(payload) + log.debug('Sending "%s" to change status', sent) + yield from self.send(sent) + + @asyncio.coroutine + def request_sync(self, guild_ids): + payload = { + 'op': self.GUILD_SYNC, + 'd': list(guild_ids) + } + yield from self.send_as_json(payload) + + @asyncio.coroutine + def voice_state(self, guild_id, channel_id, self_mute=False, self_deaf=False): + payload = { + 'op': self.VOICE_STATE, + 'd': { + 'guild_id': guild_id, + 'channel_id': channel_id, + 'self_mute': self_mute, + 'self_deaf': self_deaf + } + } + + log.debug('Updating our voice state to %s.', payload) + yield from self.send_as_json(payload) + + @asyncio.coroutine + def close_connection(self, force=False): + if self._keep_alive: + self._keep_alive.stop() + + yield from super().close_connection(force=force) + +class DiscordVoiceWebSocket(websockets.client.WebSocketClientProtocol): + """Implements the websocket protocol for handling voice connections. + + Attributes + ----------- + IDENTIFY + Send only. Starts a new voice session. + SELECT_PROTOCOL + Send only. Tells discord what encryption mode and how to connect for voice. + READY + Receive only. Tells the websocket that the initial connection has completed. + HEARTBEAT + Send only. Keeps your websocket connection alive. + SESSION_DESCRIPTION + Receive only. Gives you the secret key required for voice. + SPEAKING + Send only. Notifies the client if you are currently speaking. + HEARTBEAT_ACK + Receive only. Tells you your heartbeat has been acknowledged. + RESUME + Sent only. Tells the client to resume its session. + HELLO + Receive only. Tells you that your websocket connection was acknowledged. + INVALIDATE_SESSION + Sent only. Tells you that your RESUME request has failed and to re-IDENTIFY. + """ + + IDENTIFY = 0 + SELECT_PROTOCOL = 1 + READY = 2 + HEARTBEAT = 3 + SESSION_DESCRIPTION = 4 + SPEAKING = 5 + HEARTBEAT_ACK = 6 + RESUME = 7 + HELLO = 8 + INVALIDATE_SESSION = 9 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.max_size = None + self._keep_alive = None + + @asyncio.coroutine + def send_as_json(self, data): + log.debug('Sending voice websocket frame: %s.', data) + yield from self.send(utils.to_json(data)) + + @asyncio.coroutine + def resume(self): + state = self._connection + payload = { + 'op': self.RESUME, + 'd': { + 'token': state.token, + 'server_id': str(state.server_id), + 'session_id': state.session_id + } + } + yield from self.send_as_json(payload) + + @asyncio.coroutine + def identify(self): + state = self._connection + payload = { + 'op': self.IDENTIFY, + 'd': { + 'server_id': str(state.server_id), + 'user_id': str(state.user.id), + 'session_id': state.session_id, + 'token': state.token + } + } + yield from self.send_as_json(payload) + + @classmethod + @asyncio.coroutine + def from_client(cls, client, *, resume=False): + """Creates a voice websocket for the :class:`VoiceClient`.""" + gateway = 'wss://' + client.endpoint + '/?v=3' + ws = yield from websockets.connect(gateway, loop=client.loop, klass=cls) + ws.gateway = gateway + ws._connection = client + ws._max_heartbeat_timeout = 60.0 + + if resume: + yield from ws.resume() + else: + yield from ws.identify() + + return ws + + @asyncio.coroutine + def select_protocol(self, ip, port): + payload = { + 'op': self.SELECT_PROTOCOL, + 'd': { + 'protocol': 'udp', + 'data': { + 'address': ip, + 'port': port, + 'mode': 'xsalsa20_poly1305' + } + } + } + + yield from self.send_as_json(payload) + + @asyncio.coroutine + def speak(self, is_speaking=True): + payload = { + 'op': self.SPEAKING, + 'd': { + 'speaking': is_speaking, + 'delay': 0 + } + } + + yield from self.send_as_json(payload) + + @asyncio.coroutine + def received_message(self, msg): + log.debug('Voice websocket frame received: %s', msg) + op = msg['op'] + data = msg.get('d') + + if op == self.READY: + interval = data['heartbeat_interval'] / 1000.0 + self._keep_alive = VoiceKeepAliveHandler(ws=self, interval=interval) + self._keep_alive.start() + yield from self.initial_connection(data) + elif op == self.HEARTBEAT_ACK: + self._keep_alive.ack() + elif op == self.INVALIDATE_SESSION: + log.info('Voice RESUME failed.') + yield from self.identify() + elif op == self.SESSION_DESCRIPTION: + yield from self.load_secret_key(data) + + @asyncio.coroutine + def initial_connection(self, data): + state = self._connection + state.ssrc = data['ssrc'] + state.voice_port = data['port'] + + packet = bytearray(70) + struct.pack_into('>I', packet, 0, state.ssrc) + state.socket.sendto(packet, (state.endpoint_ip, state.voice_port)) + recv = yield from self.loop.sock_recv(state.socket, 70) + log.debug('received packet in initial_connection: %s', recv) + + # the ip is ascii starting at the 4th byte and ending at the first null + ip_start = 4 + ip_end = recv.index(0, ip_start) + state.ip = recv[ip_start:ip_end].decode('ascii') + + # the port is a little endian unsigned short in the last two bytes + # yes, this is different endianness from everything else + state.port = struct.unpack_from(''.format(self) + + def _update_voice_state(self, data, channel_id): + user_id = int(data['user_id']) + channel = self.get_channel(channel_id) + try: + # check if we should remove the voice state from cache + if channel is None: + after = self._voice_states.pop(user_id) + else: + after = self._voice_states[user_id] + + before = copy.copy(after) + after._update(data, channel) + except KeyError: + # if we're here then we're getting added into the cache + after = VoiceState(data=data, channel=channel) + before = VoiceState(data=data, channel=None) + self._voice_states[user_id] = after + + member = self.get_member(user_id) + return member, before, after + + def _add_role(self, role): + # roles get added to the bottom (position 1, pos 0 is @everyone) + # so since self.roles has the @everyone role, we can't increment + # its position because it's stuck at position 0. Luckily x += False + # is equivalent to adding 0. So we cast the position to a bool and + # increment it. + for r in self.roles: + r.position += bool(r.position) + + self.roles.append(role) + + def _remove_role(self, role): + # this raises ValueError if it fails.. + self.roles.remove(role) + + # since it didn't, we can change the positions now + # basically the same as above except we only decrement + # the position if we're above the role we deleted. + for r in self.roles: + r.position -= r.position > role.position + + def _from_data(self, guild): + # according to Stan, this is always available even if the guild is unavailable + # I don't have this guarantee when someone updates the guild. + member_count = guild.get('member_count', None) + if member_count: + self._member_count = member_count + + self.name = guild.get('name') + self.region = try_enum(VoiceRegion, guild.get('region')) + self.verification_level = try_enum(VerificationLevel, guild.get('verification_level')) + self.explicit_content_filter = try_enum(ContentFilter, guild.get('explicit_content_filter', 0)) + self.afk_timeout = guild.get('afk_timeout') + self.icon = guild.get('icon') + self.unavailable = guild.get('unavailable', False) + self.id = int(guild['id']) + self.roles = [Role(guild=self, data=r, state=self._state) for r in guild.get('roles', [])] + self.mfa_level = guild.get('mfa_level') + self.emojis = tuple(map(lambda d: self._state.store_emoji(self, d), guild.get('emojis', []))) + self.features = guild.get('features', []) + self.splash = guild.get('splash') + self._system_channel_id = guild.get('system_channel_id') + + for mdata in guild.get('members', []): + member = Member(data=mdata, guild=self, state=self._state) + self._add_member(member) + + self._sync(guild) + self._large = None if member_count is None else self._member_count >= 250 + + self.owner_id = utils._get_as_snowflake(guild, 'owner_id') + self.afk_channel = self.get_channel(utils._get_as_snowflake(guild, 'afk_channel_id')) + + for obj in guild.get('voice_states', []): + self._update_voice_state(obj, int(obj['channel_id'])) + + def _sync(self, data): + try: + self._large = data['large'] + except KeyError: + pass + + for presence in data.get('presences', []): + user_id = int(presence['user']['id']) + member = self.get_member(user_id) + if member is not None: + member.status = try_enum(Status, presence['status']) + game = presence.get('game', {}) + member.game = Game(**game) if game else None + + if 'channels' in data: + channels = data['channels'] + for c in channels: + if c['type'] == ChannelType.text.value: + self._add_channel(TextChannel(guild=self, data=c, state=self._state)) + elif c['type'] == ChannelType.voice.value: + self._add_channel(VoiceChannel(guild=self, data=c, state=self._state)) + elif c['type'] == ChannelType.category.value: + self._add_channel(CategoryChannel(guild=self, data=c, state=self._state)) + + + @property + def channels(self): + """List[:class:`abc.GuildChannel`]: A list of channels that belongs to this guild.""" + return list(self._channels.values()) + + @property + def large(self): + """bool: Indicates if the guild is a 'large' guild. + + A large guild is defined as having more than ``large_threshold`` count + members, which for this library is set to the maximum of 250. + """ + if self._large is None: + try: + return self._member_count >= 250 + except AttributeError: + return len(self._members) >= 250 + return self._large + + @property + def voice_channels(self): + """List[:class:`VoiceChannel`]: A list of voice channels that belongs to this guild. + + This is sorted by the position and are in UI order from top to bottom. + """ + r = [ch for ch in self._channels.values() if isinstance(ch, VoiceChannel)] + r.sort(key=lambda c: (c.position, c.id)) + return r + + @property + def me(self): + """Similar to :attr:`Client.user` except an instance of :class:`Member`. + This is essentially used to get the member version of yourself. + """ + self_id = self._state.user.id + return self.get_member(self_id) + + @property + def voice_client(self): + """Returns the :class:`VoiceClient` associated with this guild, if any.""" + return self._state._get_voice_client(self.id) + + @property + def text_channels(self): + """List[:class:`TextChannel`]: A list of text channels that belongs to this guild. + + This is sorted by the position and are in UI order from top to bottom. + """ + r = [ch for ch in self._channels.values() if isinstance(ch, TextChannel)] + r.sort(key=lambda c: (c.position, c.id)) + return r + + @property + def categories(self): + """List[:class:`CategoryChannel`]: A list of categories that belongs to this guild. + + This is sorted by the position and are in UI order from top to bottom. + """ + r = [ch for ch in self._channels.values() if isinstance(ch, CategoryChannel)] + r.sort(key=lambda c: (c.position, c.id)) + return r + + def by_category(self): + """Returns every :class:`CategoryChannel` and their associated channels. + + These channels and categories are sorted in the official Discord UI order. + + If the channels do not have a category, then the first element of the tuple is + ``None``. + + Returns + -------- + List[Tuple[Optional[:class:`CategoryChannel`], List[:class:`abc.GuildChannel`]]]: + The categories and their associated channels. + """ + grouped = defaultdict(list) + for channel in self._channels.values(): + if isinstance(channel, CategoryChannel): + continue + + grouped[channel.category_id].append(channel) + + def key(t): + k, v = t + return ((k.position, k.id) if k else (-1, -1), v) + + _get = self._channels.get + as_list = [(_get(k), v) for k, v in grouped.items()] + as_list.sort(key=key) + for _, channels in as_list: + channels.sort(key=lambda c: (c.position, c.id)) + return as_list + + def get_channel(self, channel_id): + """Returns a :class:`abc.GuildChannel` with the given ID. If not found, returns None.""" + return self._channels.get(channel_id) + + @property + def system_channel(self): + """Optional[:class:`TextChannel`]: Returns the guild's channel used for system messages. + + Currently this is only for new member joins. If no channel is set, then this returns ``None``. + """ + channel_id = self._system_channel_id + return channel_id and self._channels.get(channel_id) + + @property + def members(self): + """List[:class:`Member`]: A list of members that belongs to this guild.""" + return list(self._members.values()) + + def get_member(self, user_id): + """Returns a :class:`Member` with the given ID. If not found, returns None.""" + return self._members.get(user_id) + + @utils.cached_slot_property('_default_role') + def default_role(self): + """Gets the @everyone role that all members have by default.""" + return utils.find(lambda r: r.is_default(), self.roles) + + @property + def owner(self): + """:class:`Member`: The member that owns the guild.""" + return self.get_member(self.owner_id) + + @property + def icon_url(self): + """Returns the URL version of the guild's icon. Returns an empty string if it has no icon.""" + return self.icon_url_as() + + def icon_url_as(self, *, format='webp', size=1024): + """Returns a friendly URL version of the guild's icon. Returns and empty string if it has no icon. + + The format must be one of 'webp', 'jpeg', 'jpg', or 'png'. The + size must be a power of 2 between 16 and 1024. + + Parameters + ----------- + format: str + The format to attempt to convert the icon to. + size: int + The size of the image to display. + + Returns + -------- + str + The resulting CDN URL. + + Raises + ------ + InvalidArgument + Bad image format passed to ``format`` or invalid ``size``. + """ + if not valid_icon_size(size): + raise InvalidArgument("size must be a power of 2 between 16 and 1024") + if format not in VALID_ICON_FORMATS: + raise InvalidArgument("format must be one of {}".format(VALID_ICON_FORMATS)) + + if self.icon is None: + return '' + + return 'https://cdn.discordapp.com/icons/{0.id}/{0.icon}.{1}?size={2}'.format(self, format, size) + + @property + def splash_url(self): + """Returns the URL version of the guild's invite splash. Returns an empty string if it has no splash.""" + if self.splash is None: + return '' + return 'https://cdn.discordapp.com/splashes/{0.id}/{0.splash}.jpg?size=2048'.format(self) + + @property + def member_count(self): + """Returns the true member count regardless of it being loaded fully or not.""" + return self._member_count + + @property + def chunked(self): + """Returns a boolean indicating if the guild is "chunked". + + A chunked guild means that :attr:`member_count` is equal to the + number of members stored in the internal :attr:`members` cache. + + If this value returns ``False``, then you should request for + offline members. + """ + count = getattr(self, '_member_count', None) + if count is None: + return False + return count == len(self._members) + + @property + def shard_id(self): + """Returns the shard ID for this guild if applicable.""" + count = self._state.shard_count + if count is None: + return None + return (self.id >> 22) % count + + @property + def created_at(self): + """Returns the guild's creation time in UTC.""" + return utils.snowflake_time(self.id) + + @property + def role_hierarchy(self): + """Returns the guild's roles in the order of the hierarchy. + + The first element of this list will be the highest role in the + hierarchy. + """ + return sorted(self.roles, reverse=True) + + def get_member_named(self, name): + """Returns the first member found that matches the name provided. + + The name can have an optional discriminator argument, e.g. "Jake#0001" + or "Jake" will both do the lookup. However the former will give a more + precise result. Note that the discriminator must have all 4 digits + for this to work. + + If a nickname is passed, then it is looked up via the nickname. Note + however, that a nickname + discriminator combo will not lookup the nickname + but rather the username + discriminator combo due to nickname + discriminator + not being unique. + + If no member is found, ``None`` is returned. + + Parameters + ----------- + name: str + The name of the member to lookup with an optional discriminator. + + Returns + -------- + :class:`Member` + The member in this guild with the associated name. If not found + then ``None`` is returned. + """ + + result = None + members = self.members + if len(name) > 5 and name[-5] == '#': + # The 5 length is checking to see if #0000 is in the string, + # as a#0000 has a length of 6, the minimum for a potential + # discriminator lookup. + potential_discriminator = name[-4:] + + # do the actual lookup and return if found + # if it isn't found then we'll do a full name lookup below. + result = utils.get(members, name=name[:-5], discriminator=potential_discriminator) + if result is not None: + return result + + def pred(m): + return m.nick == name or m.name == name + + return utils.find(pred, members) + + def _create_channel(self, name, overwrites, channel_type, reason): + if overwrites is None: + overwrites = {} + elif not isinstance(overwrites, dict): + raise InvalidArgument('overwrites parameter expects a dict.') + + perms = [] + for target, perm in overwrites.items(): + if not isinstance(perm, PermissionOverwrite): + raise InvalidArgument('Expected PermissionOverwrite received {0.__name__}'.format(type(perm))) + + allow, deny = perm.pair() + payload = { + 'allow': allow.value, + 'deny': deny.value, + 'id': target.id + } + + if isinstance(target, Role): + payload['type'] = 'role' + else: + payload['type'] = 'member' + + perms.append(payload) + + return self._state.http.create_channel(self.id, name, channel_type.value, permission_overwrites=perms, reason=reason) + + @asyncio.coroutine + def create_text_channel(self, name, *, overwrites=None, reason=None): + """|coro| + + Creates a :class:`TextChannel` for the guild. + + Note that you need the proper permissions to create the channel. + + The ``overwrites`` parameter can be used to create a 'secret' + channel upon creation. This parameter expects a `dict` of + overwrites with the target (either a :class:`Member` or a :class:`Role`) + as the key and a :class:`PermissionOverwrite` as the value. + + Examples + ---------- + + Creating a basic channel: + + .. code-block:: python3 + + channel = await guild.create_text_channel('cool-channel') + + Creating a "secret" channel: + + .. code-block:: python3 + + overwrites = { + guild.default_role: discord.PermissionOverwrite(read_messages=False), + guild.me: discord.PermissionOverwrite(read_messages=True) + } + + channel = await guild.create_text_channel('secret', overwrites=overwrites) + + Parameters + ----------- + name: str + The channel's name. + overwrites + A `dict` of target (either a role or a member) to + :class:`PermissionOverwrite` to apply upon creation of a channel. + Useful for creating secret channels. + reason: Optional[str] + The reason for creating this channel. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have the proper permissions to create this channel. + HTTPException + Creating the channel failed. + InvalidArgument + The permission overwrite information is not in proper form. + + Returns + ------- + :class:`TextChannel` + The channel that was just created. + """ + data = yield from self._create_channel(name, overwrites, ChannelType.text, reason=reason) + return TextChannel(state=self._state, guild=self, data=data) + + @asyncio.coroutine + def create_voice_channel(self, name, *, overwrites=None, reason=None): + """|coro| + + Same as :meth:`create_text_channel` except makes a :class:`VoiceChannel` instead. + """ + data = yield from self._create_channel(name, overwrites, ChannelType.voice, reason=reason) + return VoiceChannel(state=self._state, guild=self, data=data) + + @asyncio.coroutine + def create_category(self, name, *, overwrites=None, reason=None): + """|coro| + + Same as :meth:`create_text_channel` except makes a :class:`CategoryChannel` instead. + """ + data = yield from self._create_channel(name, overwrites, ChannelType.category, reason=reason) + return CategoryChannel(state=self._state, guild=self, data=data) + + create_category_channel = create_category + + @asyncio.coroutine + def leave(self): + """|coro| + + Leaves the guild. + + Note + -------- + You cannot leave the guild that you own, you must delete it instead + via :meth:`delete`. + + Raises + -------- + HTTPException + Leaving the guild failed. + """ + yield from self._state.http.leave_guild(self.id) + + @asyncio.coroutine + def delete(self): + """|coro| + + Deletes the guild. You must be the guild owner to delete the + guild. + + Raises + -------- + HTTPException + Deleting the guild failed. + Forbidden + You do not have permissions to delete the guild. + """ + + yield from self._state.http.delete_guild(self.id) + + @asyncio.coroutine + def edit(self, *, reason=None, **fields): + """|coro| + + Edits the guild. + + You must have the :attr:`~Permissions.manage_guild` permission + to edit the guild. + + Parameters + ---------- + name: str + The new name of the guild. + icon: bytes + A *bytes-like* object representing the icon. Only PNG/JPEG supported. + Could be ``None`` to denote removal of the icon. + splash: bytes + A *bytes-like* object representing the invite splash. + Only PNG/JPEG supported. Could be ``None`` to denote removing the + splash. Only available for partnered guilds with ``INVITE_SPLASH`` + feature. + region: :class:`VoiceRegion` + The new region for the guild's voice communication. + afk_channel: Optional[:class:`VoiceChannel`] + The new channel that is the AFK channel. Could be ``None`` for no AFK channel. + afk_timeout: int + The number of seconds until someone is moved to the AFK channel. + owner: :class:`Member` + The new owner of the guild to transfer ownership to. Note that you must + be owner of the guild to do this. + verification_level: :class:`VerificationLevel` + The new verification level for the guild. + vanity_code: str + The new vanity code for the guild. + system_channel: Optional[:class:`TextChannel`] + The new channel that is used for the system channel. Could be ``None`` for no system channel. + reason: Optional[str] + The reason for editing this guild. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have permissions to edit the guild. + HTTPException + Editing the guild failed. + InvalidArgument + The image format passed in to ``icon`` is invalid. It must be + PNG or JPG. This is also raised if you are not the owner of the + guild and request an ownership transfer. + """ + + http = self._state.http + try: + icon_bytes = fields['icon'] + except KeyError: + icon = self.icon + else: + if icon_bytes is not None: + icon = utils._bytes_to_base64_data(icon_bytes) + else: + icon = None + + try: + vanity_code = fields['vanity_code'] + except KeyError: + pass + else: + yield from http.change_vanity_code(self.id, vanity_code, reason=reason) + + try: + splash_bytes = fields['splash'] + except KeyError: + splash = self.splash + else: + if splash_bytes is not None: + splash = utils._bytes_to_base64_data(splash_bytes) + else: + splash = None + + fields['icon'] = icon + fields['splash'] = splash + + try: + afk_channel = fields.pop('afk_channel') + except KeyError: + pass + else: + if afk_channel is None: + fields['afk_channel_id'] = afk_channel + else: + fields['afk_channel_id'] = afk_channel.id + + try: + system_channel = fields.pop('system_channel') + except KeyError: + pass + else: + if system_channel is None: + fields['system_channel_id'] = system_channel + else: + fields['system_channel_id'] = system_channel.id + + if 'owner' in fields: + if self.owner != self.me: + raise InvalidArgument('To transfer ownership you must be the owner of the guild.') + + fields['owner_id'] = fields['owner'].id + + if 'region' in fields: + fields['region'] = str(fields['region']) + + level = fields.get('verification_level', self.verification_level) + if not isinstance(level, VerificationLevel): + raise InvalidArgument('verification_level field must of type VerificationLevel') + + fields['verification_level'] = level.value + + yield from http.edit_guild(self.id, reason=reason, **fields) + + + @asyncio.coroutine + def bans(self): + """|coro| + + Retrieves all the users that are banned from the guild. + + This coroutine returns a list of BanEntry objects. Which is a + namedtuple with a ``user`` field to denote the :class:`User` + that got banned along with a ``reason`` field specifying + why the user was banned that could be set to ``None``. + + You must have :attr:`~Permissions.ban_members` permission + to get this information. + + Raises + ------- + Forbidden + You do not have proper permissions to get the information. + HTTPException + An error occurred while fetching the information. + + Returns + -------- + List[BanEntry] + A list of BanEntry objects. + """ + + data = yield from self._state.http.get_bans(self.id) + return [BanEntry(user=User(state=self._state, data=e['user']), + reason=e['reason']) + for e in data] + + @asyncio.coroutine + def prune_members(self, *, days, reason=None): + """|coro| + + Prunes the guild from its inactive members. + + The inactive members are denoted if they have not logged on in + ``days`` number of days and they have no roles. + + You must have the :attr:`~Permissions.kick_members` permission + to use this. + + To check how many members you would prune without actually pruning, + see the :meth:`estimate_pruned_members` function. + + Parameters + ----------- + days: int + The number of days before counting as inactive. + reason: Optional[str] + The reason for doing this action. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have permissions to prune members. + HTTPException + An error occurred while pruning members. + InvalidArgument + An integer was not passed for ``days``. + + Returns + --------- + int + The number of members pruned. + """ + + if not isinstance(days, int): + raise InvalidArgument('Expected int for ``days``, received {0.__class__.__name__} instead.'.format(days)) + + data = yield from self._state.http.prune_members(self.id, days, reason=reason) + return data['pruned'] + + @asyncio.coroutine + def webhooks(self): + """|coro| + + Gets the list of webhooks from this guild. + + Requires :attr:`~.Permissions.manage_webhooks` permissions. + + Raises + ------- + Forbidden + You don't have permissions to get the webhooks. + + Returns + -------- + List[:class:`Webhook`] + The webhooks for this guild. + """ + + data = yield from self._state.http.guild_webhooks(self.id) + return [Webhook.from_state(d, state=self._state) for d in data] + + @asyncio.coroutine + def estimate_pruned_members(self, *, days): + """|coro| + + Similar to :meth:`prune_members` except instead of actually + pruning members, it returns how many members it would prune + from the guild had it been called. + + Parameters + ----------- + days: int + The number of days before counting as inactive. + + Raises + ------- + Forbidden + You do not have permissions to prune members. + HTTPException + An error occurred while fetching the prune members estimate. + InvalidArgument + An integer was not passed for ``days``. + + Returns + --------- + int + The number of members estimated to be pruned. + """ + + if not isinstance(days, int): + raise InvalidArgument('Expected int for ``days``, received {0.__class__.__name__} instead.'.format(days)) + + data = yield from self._state.http.estimate_pruned_members(self.id, days) + return data['pruned'] + + @asyncio.coroutine + def invites(self): + """|coro| + + Returns a list of all active instant invites from the guild. + + You must have :attr:`~Permissions.manage_guild` to get this information. + + Raises + ------- + Forbidden + You do not have proper permissions to get the information. + HTTPException + An error occurred while fetching the information. + + Returns + ------- + List[:class:`Invite`] + The list of invites that are currently active. + """ + + data = yield from self._state.http.invites_from(self.id) + result = [] + for invite in data: + channel = self.get_channel(int(invite['channel']['id'])) + invite['channel'] = channel + invite['guild'] = self + result.append(Invite(state=self._state, data=invite)) + + return result + + @asyncio.coroutine + def create_custom_emoji(self, *, name, image, reason=None): + """|coro| + + Creates a custom :class:`Emoji` for the guild. + + This endpoint is only allowed for user bots or white listed + bots. If this is done by a user bot then this is a local + emoji that can only be used inside the guild. If done by + a whitelisted bot, then this emoji is "global". + + There is currently a limit of 50 local emotes per guild. + + Parameters + ----------- + name: str + The emoji name. Must be at least 2 characters. + image: bytes + The *bytes-like* object representing the image data to use. + Only JPG and PNG images are supported. + reason: Optional[str] + The reason for creating this emoji. Shows up on the audit log. + + Returns + -------- + :class:`Emoji` + The created emoji. + + Raises + ------- + Forbidden + You are not allowed to create emojis. + HTTPException + An error occurred creating an emoji. + """ + + img = utils._bytes_to_base64_data(image) + data = yield from self._state.http.create_custom_emoji(self.id, name, img, reason=reason) + return self._state.store_emoji(self, data) + + @asyncio.coroutine + def create_role(self, *, reason=None, **fields): + """|coro| + + Creates a :class:`Role` for the guild. + + All fields are optional. + + Parameters + ----------- + name: str + The role name. Defaults to 'new role'. + permissions: :class:`Permissions` + The permissions to have. Defaults to no permissions. + colour: :class:`Colour` + The colour for the role. Defaults to :meth:`Colour.default`. + This is aliased to ``color`` as well. + hoist: bool + Indicates if the role should be shown separately in the member list. + Defaults to False. + mentionable: bool + Indicates if the role should be mentionable by others. + Defaults to False. + reason: Optional[str] + The reason for creating this role. Shows up on the audit log. + + Returns + -------- + :class:`Role` + The newly created role. + + Raises + ------- + Forbidden + You do not have permissions to change the role. + HTTPException + Editing the role failed. + InvalidArgument + An invalid keyword argument was given. + """ + + try: + perms = fields.pop('permissions') + except KeyError: + fields['permissions'] = 0 + else: + fields['permissions'] = perms.value + + try: + colour = fields.pop('colour') + except KeyError: + colour = fields.get('color', Colour.default()) + finally: + fields['color'] = colour.value + + valid_keys = ('name', 'permissions', 'color', 'hoist', 'mentionable') + for key in fields: + if key not in valid_keys: + raise InvalidArgument('%r is not a valid field.' % key) + + data = yield from self._state.http.create_role(self.id, reason=reason, **fields) + role = Role(guild=self, data=data, state=self._state) + + # TODO: add to cache + return role + + @asyncio.coroutine + def kick(self, user, *, reason=None): + """|coro| + + Kicks a user from the guild. + + The user must meet the :class:`abc.Snowflake` abc. + + You must have :attr:`Permissions.kick_members` permissions to + do this. + + Parameters + ----------- + user: :class:`abc.Snowflake` + The user to kick from their guild. + reason: Optional[str] + The reason the user got kicked. + + Raises + ------- + Forbidden + You do not have the proper permissions to kick. + HTTPException + Kicking failed. + """ + yield from self._state.http.kick(user.id, self.id, reason=reason) + + @asyncio.coroutine + def ban(self, user, *, reason=None, delete_message_days=1): + """|coro| + + Bans a user from the guild. + + The user must meet the :class:`abc.Snowflake` abc. + + You must have :attr:`Permissions.ban_members` permissions to + do this. + + Parameters + ----------- + user: :class:`abc.Snowflake` + The user to ban from their guild. + delete_message_days: int + The number of days worth of messages to delete from the user + in the guild. The minimum is 0 and the maximum is 7. + reason: Optional[str] + The reason the user got banned. + + Raises + ------- + Forbidden + You do not have the proper permissions to ban. + HTTPException + Banning failed. + """ + yield from self._state.http.ban(user.id, self.id, delete_message_days, reason=reason) + + @asyncio.coroutine + def unban(self, user, *, reason=None): + """|coro| + + Unbans a user from the guild. + + The user must meet the :class:`abc.Snowflake` abc. + + You must have :attr:`Permissions.ban_members` permissions to + do this. + + Parameters + ----------- + user: :class:`abc.Snowflake` + The user to unban. + reason: Optional[str] + The reason for doing this action. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have the proper permissions to unban. + HTTPException + Unbanning failed. + """ + yield from self._state.http.unban(user.id, self.id, reason=reason) + + @asyncio.coroutine + def vanity_invite(self): + """|coro| + + Returns the guild's special vanity invite. + + The guild must be partnered, i.e. have 'VANITY_URL' in + :attr:`~Guild.features`. + + You must have :attr:`Permissions.manage_guild` to use this as well. + + Returns + -------- + :class:`Invite` + The special vanity invite. + + Raises + ------- + Forbidden + You do not have the proper permissions to get this. + HTTPException + Retrieving the vanity invite failed. + """ + + # we start with { code: abc } + payload = yield from self._state.http.get_vanity_code(self.id) + + # get the vanity URL channel since default channels aren't + # reliable or a thing anymore + data = yield from self._state.http.get_invite(payload['code']) + + payload['guild'] = self + payload['channel'] = self.get_channel(int(data['channel']['id'])) + payload['revoked'] = False + payload['temporary'] = False + payload['max_uses'] = 0 + payload['max_age'] = 0 + return Invite(state=self._state, data=payload) + + def ack(self): + """|coro| + + Marks every message in this guild as read. + + The user must not be a bot user. + + Raises + ------- + HTTPException + Acking failed. + ClientException + You must not be a bot user. + """ + + state = self._state + if state.is_bot: + raise ClientException('Must not be a bot account to ack messages.') + return state.http.ack_guild(self.id) + + def audit_logs(self, *, limit=100, before=None, after=None, reverse=None, user=None, action=None): + """Return an :class:`AsyncIterator` that enables receiving the guild's audit logs. + + You must have :attr:`Permissions.view_audit_logs` permission to use this. + + Parameters + ----------- + limit: Optional[int] + The number of entries to retrieve. If ``None`` retrieve all entries. + before: Union[:class:`abc.Snowflake`, datetime] + Retrieve entries before this date or entry. + If a date is provided it must be a timezone-naive datetime representing UTC time. + after: Union[:class:`abc.Snowflake`, datetime] + Retrieve entries after this date or entry. + If a date is provided it must be a timezone-naive datetime representing UTC time. + reverse: bool + If set to true, return entries in oldest->newest order. If unspecified, + this defaults to ``False`` for most cases. However if passing in a + ``after`` parameter then this is set to ``True``. This avoids getting entries + out of order in the ``after`` case. + user: :class:`abc.Snowflake` + The moderator to filter entries from. + action: :class:`AuditLogAction` + The action to filter with. + + Yields + -------- + :class:`AuditLogEntry` + The audit log entry. + + Raises + ------- + Forbidden + You are not allowed to fetch audit logs + HTTPException + An error occurred while fetching the audit logs. + + Examples + ---------- + + Getting the first 100 entries: :: + + async for entry in guild.audit_logs(limit=100): + print('{0.user} did {0.action} to {0.target}'.format(entry)) + + Getting entries for a specific action: :: + + async for entry in guild.audit_logs(action=discord.AuditLogAction.ban): + print('{0.user} banned {0.target}'.format(entry)) + + Getting entries made by a specific user: :: + + entries = await guild.audit_logs(limit=None, user=guild.me).flatten() + await channel.send('I made {} moderation actions.'.format(len(entries))) + """ + if user: + user = user.id + + if action: + action = action.value + + return AuditLogIterator(self, before=before, after=after, limit=limit, + reverse=reverse, user_id=user, action_type=action) diff --git a/discord.py-rewrite/discord/http.py b/discord.py-rewrite/discord/http.py new file mode 100644 index 0000000..1c020ee --- /dev/null +++ b/discord.py-rewrite/discord/http.py @@ -0,0 +1,759 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import aiohttp +import asyncio +import json +import sys +import logging +import weakref +import datetime +from urllib.parse import quote as _uriquote + +log = logging.getLogger(__name__) + +from .errors import HTTPException, Forbidden, NotFound, LoginFailure, GatewayNotFound +from . import __version__, utils + +@asyncio.coroutine +def json_or_text(response): + text = yield from response.text(encoding='utf-8') + if response.headers['content-type'] == 'application/json': + return json.loads(text) + return text + +class Route: + BASE = 'https://discordapp.com/api/v7' + + def __init__(self, method, path, **parameters): + self.path = path + self.method = method + url = (self.BASE + self.path) + if parameters: + self.url = url.format(**parameters) + else: + self.url = url + + # major parameters: + self.channel_id = parameters.get('channel_id') + self.guild_id = parameters.get('guild_id') + + @property + def bucket(self): + # the bucket is just method + path w/ major parameters + return '{0.method}:{0.channel_id}:{0.guild_id}:{0.path}'.format(self) + +class MaybeUnlock: + def __init__(self, lock): + self.lock = lock + self._unlock = True + + def __enter__(self): + return self + + def defer(self): + self._unlock = False + + def __exit__(self, type, value, traceback): + if self._unlock: + self.lock.release() + +class HTTPClient: + """Represents an HTTP client sending HTTP requests to the Discord API.""" + + SUCCESS_LOG = '{method} {url} has received {text}' + REQUEST_LOG = '{method} {url} with {json} has returned {status}' + + def __init__(self, connector=None, *, proxy=None, proxy_auth=None, loop=None): + self.loop = asyncio.get_event_loop() if loop is None else loop + self.connector = connector + self._session = aiohttp.ClientSession(connector=connector, loop=self.loop) + self._locks = weakref.WeakValueDictionary() + self._global_over = asyncio.Event(loop=self.loop) + self._global_over.set() + self.token = None + self.bot_token = False + self.proxy = proxy + self.proxy_auth = proxy_auth + + user_agent = 'DiscordBot (https://github.com/Rapptz/discord.py {0}) Python/{1[0]}.{1[1]} aiohttp/{2}' + self.user_agent = user_agent.format(__version__, sys.version_info, aiohttp.__version__) + + @asyncio.coroutine + def request(self, route, *, header_bypass_delay=None, **kwargs): + bucket = route.bucket + method = route.method + url = route.url + + lock = self._locks.get(bucket) + if lock is None: + lock = asyncio.Lock(loop=self.loop) + if bucket is not None: + self._locks[bucket] = lock + + # header creation + headers = { + 'User-Agent': self.user_agent, + } + + if self.token is not None: + headers['Authorization'] = 'Bot ' + self.token if self.bot_token else self.token + # some checking if it's a JSON request + if 'json' in kwargs: + headers['Content-Type'] = 'application/json' + kwargs['data'] = utils.to_json(kwargs.pop('json')) + + try: + reason = kwargs.pop('reason') + except KeyError: + pass + else: + if reason: + headers['X-Audit-Log-Reason'] = _uriquote(reason, safe='/ ') + + kwargs['headers'] = headers + + # Proxy support + if self.proxy is not None: + kwargs['proxy'] = self.proxy + if self.proxy_auth is not None: + kwargs['proxy_auth'] = self.proxy_auth + + if not self._global_over.is_set(): + # wait until the global lock is complete + yield from self._global_over.wait() + + yield from lock + with MaybeUnlock(lock) as maybe_lock: + for tries in range(5): + r = yield from self._session.request(method, url, **kwargs) + log.debug('%s %s with %s has returned %s', method, url, kwargs.get('data'), r.status) + try: + # even errors have text involved in them so this is safe to call + data = yield from json_or_text(r) + + # check if we have rate limit header information + remaining = r.headers.get('X-Ratelimit-Remaining') + if remaining == '0' and r.status != 429: + # we've depleted our current bucket + if header_bypass_delay is None: + delta = utils._parse_ratelimit_header(r) + else: + delta = header_bypass_delay + + log.info('A rate limit bucket has been exhausted (bucket: %s, retry: %s).', bucket, delta) + maybe_lock.defer() + self.loop.call_later(delta, lock.release) + + # the request was successful so just return the text/json + if 300 > r.status >= 200: + log.debug('%s %s has received %s', method, url, data) + return data + + # we are being rate limited + if r.status == 429: + fmt = 'We are being rate limited. Retrying in %.2f seconds. Handled under the bucket "%s"' + + # sleep a bit + retry_after = data['retry_after'] / 1000.0 + log.info(fmt, retry_after, bucket) + + # check if it's a global rate limit + is_global = data.get('global', False) + if is_global: + log.info('Global rate limit has been hit. Retrying in %.2f seconds.', retry_after) + self._global_over.clear() + + yield from asyncio.sleep(retry_after, loop=self.loop) + log.debug('Done sleeping for the rate limit. Retrying...') + + # release the global lock now that the + # global rate limit has passed + if is_global: + self._global_over.set() + log.debug('Global rate limit is now over.') + + continue + + # we've received a 500 or 502, unconditional retry + if r.status in {500, 502}: + yield from asyncio.sleep(1 + tries * 2, loop=self.loop) + continue + + # the usual error cases + if r.status == 403: + raise Forbidden(r, data) + elif r.status == 404: + raise NotFound(r, data) + else: + raise HTTPException(r, data) + finally: + # clean-up just in case + yield from r.release() + # We've run out of retries, raise. + raise HTTPException(r, data) + + def get_attachment(self, url): + resp = yield from self._session.get(url) + try: + if resp.status == 200: + return (yield from resp.read()) + elif resp.status == 404: + raise NotFound(resp, 'attachment not found') + elif resp.status == 403: + raise Forbidden(resp, 'cannot retrieve attachment') + else: + raise HTTPException(resp, 'failed to get attachment') + finally: + yield from resp.release() + + # state management + + @asyncio.coroutine + def close(self): + yield from self._session.close() + + def _token(self, token, *, bot=True): + self.token = token + self.bot_token = bot + self._ack_token = None + + # login management + + @asyncio.coroutine + def static_login(self, token, *, bot): + old_token, old_bot = self.token, self.bot_token + self._token(token, bot=bot) + + try: + data = yield from self.request(Route('GET', '/users/@me')) + except HTTPException as e: + self._token(old_token, bot=old_bot) + if e.response.status == 401: + raise LoginFailure('Improper token has been passed.') from e + raise e + + return data + + def logout(self): + return self.request(Route('POST', '/auth/logout')) + + # Group functionality + + def start_group(self, user_id, recipients): + payload = { + 'recipients': recipients + } + + return self.request(Route('POST', '/users/{user_id}/channels', user_id=user_id), json=payload) + + def leave_group(self, channel_id): + return self.request(Route('DELETE', '/channels/{channel_id}', channel_id=channel_id)) + + def add_group_recipient(self, channel_id, user_id): + r = Route('PUT', '/channels/{channel_id}/recipients/{user_id}', channel_id=channel_id, user_id=user_id) + return self.request(r) + + def remove_group_recipient(self, channel_id, user_id): + r = Route('DELETE', '/channels/{channel_id}/recipients/{user_id}', channel_id=channel_id, user_id=user_id) + return self.request(r) + + def edit_group(self, channel_id, **options): + valid_keys = ('name', 'icon') + payload = { + k: v for k, v in options.items() if k in valid_keys + } + + return self.request(Route('PATCH', '/channels/{channel_id}', channel_id=channel_id), json=payload) + + def convert_group(self, channel_id): + return self.request(Route('POST', '/channels/{channel_id}/convert', channel_id=channel_id)) + + # Message management + + def start_private_message(self, user_id): + payload = { + 'recipient_id': user_id + } + + return self.request(Route('POST', '/users/@me/channels'), json=payload) + + def send_message(self, channel_id, content, *, tts=False, embed=None, nonce=None): + r = Route('POST', '/channels/{channel_id}/messages', channel_id=channel_id) + payload = {} + + if content: + payload['content'] = content + + if tts: + payload['tts'] = True + + if embed: + payload['embed'] = embed + + if nonce: + payload['nonce'] = nonce + + return self.request(r, json=payload) + + def send_typing(self, channel_id): + return self.request(Route('POST', '/channels/{channel_id}/typing', channel_id=channel_id)) + + def send_files(self, channel_id, *, files, content=None, tts=False, embed=None, nonce=None): + r = Route('POST', '/channels/{channel_id}/messages', channel_id=channel_id) + form = aiohttp.FormData() + + payload = {'tts': tts} + if content: + payload['content'] = content + if embed: + payload['embed'] = embed + if nonce: + payload['nonce'] = nonce + + form.add_field('payload_json', utils.to_json(payload)) + if len(files) == 1: + fp = files[0] + form.add_field('file', fp[0], filename=fp[1], content_type='application/octet-stream') + else: + for index, (buffer, filename) in enumerate(files): + form.add_field('file%s' % index, buffer, filename=filename, content_type='application/octet-stream') + + return self.request(r, data=form) + + @asyncio.coroutine + def ack_message(self, channel_id, message_id): + r = Route('POST', '/channels/{channel_id}/messages/{message_id}/ack', channel_id=channel_id, + message_id=message_id) + data = yield from self.request(r, json={'token': self._ack_token}) + self._ack_token = data['token'] + + def ack_guild(self, guild_id): + return self.request(Route('POST', '/guilds/{guild_id}/ack', guild_id=guild_id)) + + def delete_message(self, channel_id, message_id, *, reason=None): + r = Route('DELETE', '/channels/{channel_id}/messages/{message_id}', channel_id=channel_id, + message_id=message_id) + return self.request(r, reason=reason) + + def delete_messages(self, channel_id, message_ids, *, reason=None): + r = Route('POST', '/channels/{channel_id}/messages/bulk_delete', channel_id=channel_id) + payload = { + 'messages': message_ids + } + + return self.request(r, json=payload, reason=reason) + + def edit_message(self, message_id, channel_id, **fields): + r = Route('PATCH', '/channels/{channel_id}/messages/{message_id}', channel_id=channel_id, + message_id=message_id) + return self.request(r, json=fields) + + def add_reaction(self, message_id, channel_id, emoji): + r = Route('PUT', '/channels/{channel_id}/messages/{message_id}/reactions/{emoji}/@me', + channel_id=channel_id, message_id=message_id, emoji=emoji) + return self.request(r, header_bypass_delay=0.25) + + def remove_reaction(self, message_id, channel_id, emoji, member_id): + r = Route('DELETE', '/channels/{channel_id}/messages/{message_id}/reactions/{emoji}/{member_id}', + channel_id=channel_id, message_id=message_id, member_id=member_id, emoji=emoji) + return self.request(r, header_bypass_delay=0.25) + + def get_reaction_users(self, message_id, channel_id, emoji, limit, after=None): + r = Route('GET', '/channels/{channel_id}/messages/{message_id}/reactions/{emoji}', + channel_id=channel_id, message_id=message_id, emoji=emoji) + + params = {'limit': limit} + if after: + params['after'] = after + return self.request(r, params=params) + + def clear_reactions(self, message_id, channel_id): + r = Route('DELETE', '/channels/{channel_id}/messages/{message_id}/reactions', + channel_id=channel_id, message_id=message_id) + + return self.request(r) + + def get_message(self, channel_id, message_id): + r = Route('GET', '/channels/{channel_id}/messages/{message_id}', channel_id=channel_id, message_id=message_id) + return self.request(r) + + def logs_from(self, channel_id, limit, before=None, after=None, around=None): + params = { + 'limit': limit + } + + if before: + params['before'] = before + if after: + params['after'] = after + if around: + params['around'] = around + + return self.request(Route('GET', '/channels/{channel_id}/messages', channel_id=channel_id), params=params) + + def pin_message(self, channel_id, message_id): + return self.request(Route('PUT', '/channels/{channel_id}/pins/{message_id}', + channel_id=channel_id, message_id=message_id)) + + def unpin_message(self, channel_id, message_id): + return self.request(Route('DELETE', '/channels/{channel_id}/pins/{message_id}', + channel_id=channel_id, message_id=message_id)) + + def pins_from(self, channel_id): + return self.request(Route('GET', '/channels/{channel_id}/pins', channel_id=channel_id)) + + # Member management + + def kick(self, user_id, guild_id, reason=None): + r = Route('DELETE', '/guilds/{guild_id}/members/{user_id}', guild_id=guild_id, user_id=user_id) + if reason: + # thanks aiohttp + r.url = '{0.url}?reason={1}'.format(r, _uriquote(reason)) + + return self.request(r) + + def ban(self, user_id, guild_id, delete_message_days=1, reason=None): + r = Route('PUT', '/guilds/{guild_id}/bans/{user_id}', guild_id=guild_id, user_id=user_id) + params = { + 'delete-message-days': delete_message_days, + } + + if reason: + # thanks aiohttp + r.url = '{0.url}?reason={1}'.format(r, _uriquote(reason)) + + return self.request(r, params=params) + + def unban(self, user_id, guild_id, *, reason=None): + r = Route('DELETE', '/guilds/{guild_id}/bans/{user_id}', guild_id=guild_id, user_id=user_id) + return self.request(r, reason=reason) + + def guild_voice_state(self, user_id, guild_id, *, mute=None, deafen=None, reason=None): + r = Route('PATCH', '/guilds/{guild_id}/members/{user_id}', guild_id=guild_id, user_id=user_id) + payload = {} + if mute is not None: + payload['mute'] = mute + + if deafen is not None: + payload['deaf'] = deafen + + return self.request(r, json=payload, reason=reason) + + def edit_profile(self, password, username, avatar, **fields): + payload = { + 'password': password, + 'username': username, + 'avatar': avatar + } + + if 'email' in fields: + payload['email'] = fields['email'] + + if 'new_password' in fields: + payload['new_password'] = fields['new_password'] + + return self.request(Route('PATCH', '/users/@me'), json=payload) + + def change_my_nickname(self, guild_id, nickname, *, reason=None): + r = Route('PATCH', '/guilds/{guild_id}/members/@me/nick', guild_id=guild_id) + payload = { + 'nick': nickname + } + return self.request(r, json=payload, reason=reason) + + def change_nickname(self, guild_id, user_id, nickname, *, reason=None): + r = Route('PATCH', '/guilds/{guild_id}/members/{user_id}', guild_id=guild_id, user_id=user_id) + payload = { + 'nick': nickname + } + return self.request(r, json=payload, reason=reason) + + def edit_member(self, guild_id, user_id, *, reason=None, **fields): + r = Route('PATCH', '/guilds/{guild_id}/members/{user_id}', guild_id=guild_id, user_id=user_id) + return self.request(r, json=fields, reason=reason) + + # Channel management + + def edit_channel(self, channel_id, *, reason=None, **options): + r = Route('PATCH', '/channels/{channel_id}', channel_id=channel_id) + valid_keys = ('name', 'topic', 'bitrate', 'nsfw', 'user_limit', 'position', 'permission_overwrites') + payload = { + k: v for k, v in options.items() if k in valid_keys + } + + return self.request(r, reason=reason, json=payload) + + def bulk_channel_update(self, guild_id, data, *, reason=None): + r = Route('PATCH', '/guilds/{guild_id}/channels', guild_id=guild_id) + return self.request(r, json=data, reason=reason) + + def create_channel(self, guild_id, name, channel_type, permission_overwrites=None, *, reason=None): + payload = { + 'name': name, + 'type': channel_type + } + + if permission_overwrites is not None: + payload['permission_overwrites'] = permission_overwrites + + return self.request(Route('POST', '/guilds/{guild_id}/channels', guild_id=guild_id), json=payload, reason=reason) + + def delete_channel(self, channel_id, *, reason=None): + return self.request(Route('DELETE', '/channels/{channel_id}', channel_id=channel_id), reason=reason) + + # Webhook management + + def create_webhook(self, channel_id, *, name=None, avatar=None): + payload = {} + if name is not None: + payload['name'] = name + if avatar is not None: + payload['avatar'] = avatar + + return self.request(Route('POST', '/channels/{channel_id}/webhooks', channel_id=channel_id), json=payload) + + def channel_webhooks(self, channel_id): + return self.request(Route('GET', '/channels/{channel_id}/webhooks', channel_id=channel_id)) + + def guild_webhooks(self, guild_id): + return self.request(Route('GET', '/guilds/{guild_id}/webhooks', guild_id=guild_id)) + + def get_webhook(self, webhook_id): + return self.request(Route('GET', '/webhooks/{webhook_id}', webhook_id=webhook_id)) + + # Guild management + + def leave_guild(self, guild_id): + return self.request(Route('DELETE', '/users/@me/guilds/{guild_id}', guild_id=guild_id)) + + def delete_guild(self, guild_id): + return self.request(Route('DELETE', '/guilds/{guild_id}', guild_id=guild_id)) + + def create_guild(self, name, region, icon): + payload = { + 'name': name, + 'icon': icon, + 'region': region + } + + return self.request(Route('POST', '/guilds'), json=payload) + + def edit_guild(self, guild_id, *, reason=None, **fields): + valid_keys = ('name', 'region', 'icon', 'afk_timeout', 'owner_id', + 'afk_channel_id', 'splash', 'verification_level', + 'system_channel_id') + + payload = { + k: v for k, v in fields.items() if k in valid_keys + } + + return self.request(Route('PATCH', '/guilds/{guild_id}', guild_id=guild_id), json=payload, reason=reason) + + def get_bans(self, guild_id): + return self.request(Route('GET', '/guilds/{guild_id}/bans', guild_id=guild_id)) + + def get_vanity_code(self, guild_id): + return self.request(Route('GET', '/guilds/{guild_id}/vanity-url', guild_id=guild_id)) + + def change_vanity_code(self, guild_id, code, *, reason=None): + payload = { 'code': code } + return self.request(Route('PATCH', '/guilds/{guild_id}/vanity-url', guild_id=guild_id), json=payload, reason=reason) + + def prune_members(self, guild_id, days, *, reason=None): + params = { + 'days': days + } + return self.request(Route('POST', '/guilds/{guild_id}/prune', guild_id=guild_id), params=params, reason=reason) + + def estimate_pruned_members(self, guild_id, days): + params = { + 'days': days + } + return self.request(Route('GET', '/guilds/{guild_id}/prune', guild_id=guild_id), params=params) + + def create_custom_emoji(self, guild_id, name, image, *, reason=None): + payload = { + 'name': name, + 'image': image + } + + r = Route('POST', '/guilds/{guild_id}/emojis', guild_id=guild_id) + return self.request(r, json=payload, reason=reason) + + def delete_custom_emoji(self, guild_id, emoji_id, *, reason=None): + r = Route('DELETE', '/guilds/{guild_id}/emojis/{emoji_id}', guild_id=guild_id, emoji_id=emoji_id) + return self.request(r, reason=reason) + + def edit_custom_emoji(self, guild_id, emoji_id, *, name, reason=None): + payload = { + 'name': name + } + r = Route('PATCH', '/guilds/{guild_id}/emojis/{emoji_id}', guild_id=guild_id, emoji_id=emoji_id) + return self.request(r, json=payload, reason=reason) + + def get_audit_logs(self, guild_id, limit=100, before=None, after=None, user_id=None, action_type=None): + params = { 'limit': limit } + if before: + params['before'] = before + if after: + params['after'] = after + if user_id: + params['user_id'] = user_id + if action_type: + params['action_type'] = action_type + + r = Route('GET', '/guilds/{guild_id}/audit-logs', guild_id=guild_id) + return self.request(r, params=params) + + # Invite management + + def create_invite(self, channel_id, *, reason=None, **options): + r = Route('POST', '/channels/{channel_id}/invites', channel_id=channel_id) + payload = { + 'max_age': options.get('max_age', 0), + 'max_uses': options.get('max_uses', 0), + 'temporary': options.get('temporary', False), + 'unique': options.get('unique', True) + } + + return self.request(r, reason=reason, json=payload) + + def get_invite(self, invite_id): + return self.request(Route('GET', '/invite/{invite_id}', invite_id=invite_id)) + + def invites_from(self, guild_id): + return self.request(Route('GET', '/guilds/{guild_id}/invites', guild_id=guild_id)) + + def invites_from_channel(self, channel_id): + return self.request(Route('GET', '/channels/{channel_id}/invites', channel_id=channel_id)) + + def delete_invite(self, invite_id, *, reason=None): + return self.request(Route('DELETE', '/invite/{invite_id}', invite_id=invite_id), reason=reason) + + # Role management + + def edit_role(self, guild_id, role_id, *, reason=None, **fields): + r = Route('PATCH', '/guilds/{guild_id}/roles/{role_id}', guild_id=guild_id, role_id=role_id) + valid_keys = ('name', 'permissions', 'color', 'hoist', 'mentionable') + payload = { + k: v for k, v in fields.items() if k in valid_keys + } + return self.request(r, json=payload, reason=reason) + + def delete_role(self, guild_id, role_id, *, reason=None): + r = Route('DELETE', '/guilds/{guild_id}/roles/{role_id}', guild_id=guild_id, role_id=role_id) + return self.request(r, reason=reason) + + def replace_roles(self, user_id, guild_id, role_ids, *, reason=None): + return self.edit_member(guild_id=guild_id, user_id=user_id, roles=role_ids, reason=reason) + + def create_role(self, guild_id, *, reason=None, **fields): + r = Route('POST', '/guilds/{guild_id}/roles', guild_id=guild_id) + return self.request(r, json=fields, reason=reason) + + def move_role_position(self, guild_id, positions, *, reason=None): + r = Route('PATCH', '/guilds/{guild_id}/roles', guild_id=guild_id) + return self.request(r, json=positions, reason=reason) + + def add_role(self, guild_id, user_id, role_id, *, reason=None): + r = Route('PUT', '/guilds/{guild_id}/members/{user_id}/roles/{role_id}', + guild_id=guild_id, user_id=user_id, role_id=role_id) + return self.request(r, reason=reason) + + def remove_role(self, guild_id, user_id, role_id, *, reason=None): + r = Route('DELETE', '/guilds/{guild_id}/members/{user_id}/roles/{role_id}', + guild_id=guild_id, user_id=user_id, role_id=role_id) + return self.request(r, reason=reason) + + def edit_channel_permissions(self, channel_id, target, allow, deny, type, *, reason=None): + payload = { + 'id': target, + 'allow': allow, + 'deny': deny, + 'type': type + } + r = Route('PUT', '/channels/{channel_id}/permissions/{target}', channel_id=channel_id, target=target) + return self.request(r, json=payload, reason=reason) + + def delete_channel_permissions(self, channel_id, target, *, reason=None): + r = Route('DELETE', '/channels/{channel_id}/permissions/{target}', channel_id=channel_id, target=target) + return self.request(r, reason=reason) + + # Voice management + + def move_member(self, user_id, guild_id, channel_id, *, reason=None): + return self.edit_member(guild_id=guild_id, user_id=user_id, channel_id=channel_id, reason=reason) + + # Relationship related + + def remove_relationship(self, user_id): + r = Route('DELETE', '/users/@me/relationships/{user_id}', user_id=user_id) + return self.request(r) + + def add_relationship(self, user_id, type=None): + r = Route('PUT', '/users/@me/relationships/{user_id}', user_id=user_id) + payload = {} + if type is not None: + payload['type'] = type + + return self.request(r, json=payload) + + def send_friend_request(self, username, discriminator): + r = Route('POST', '/users/@me/relationships') + payload = { + 'username': username, + 'discriminator': int(discriminator) + } + return self.request(r, json=payload) + + # Misc + + def application_info(self): + return self.request(Route('GET', '/oauth2/applications/@me')) + + @asyncio.coroutine + def get_gateway(self): + try: + data = yield from self.request(Route('GET', '/gateway')) + except HTTPException as e: + raise GatewayNotFound() from e + return data.get('url') + '?encoding=json&v=6' + + @asyncio.coroutine + def get_bot_gateway(self): + try: + data = yield from self.request(Route('GET', '/gateway/bot')) + except HTTPException as e: + raise GatewayNotFound() from e + else: + return data['shards'], data['url'] + '?encoding=json&v=6' + + def get_user_info(self, user_id): + return self.request(Route('GET', '/users/{user_id}', user_id=user_id)) + + def get_user_profile(self, user_id): + return self.request(Route('GET', '/users/{user_id}/profile', user_id=user_id)) diff --git a/discord.py-rewrite/discord/invite.py b/discord.py-rewrite/discord/invite.py new file mode 100644 index 0000000..2af3a66 --- /dev/null +++ b/discord.py-rewrite/discord/invite.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import asyncio + +from .utils import parse_time +from .mixins import Hashable +from .object import Object + +class Invite(Hashable): + """Represents a Discord :class:`Guild` or :class:`abc.GuildChannel` invite. + + Depending on the way this object was created, some of the attributes can + have a value of ``None``. + + .. container:: operations + + .. describe:: x == y + + Checks if two invites are equal. + + .. describe:: x != y + + Checks if two invites are not equal. + + .. describe:: hash(x) + + Returns the invite hash. + + .. describe:: str(x) + + Returns the invite URL. + + Attributes + ----------- + max_age: int + How long the before the invite expires in seconds. A value of 0 indicates that it doesn't expire. + code: str + The URL fragment used for the invite. + guild: :class:`Guild` + The guild the invite is for. + revoked: bool + Indicates if the invite has been revoked. + created_at: `datetime.datetime` + A datetime object denoting the time the invite was created. + temporary: bool + Indicates that the invite grants temporary membership. + If True, members who joined via this invite will be kicked upon disconnect. + uses: int + How many times the invite has been used. + max_uses: int + How many times the invite can be used. + inviter: :class:`User` + The user who created the invite. + channel: :class:`abc.GuildChannel` + The channel the invite is for. + """ + + + __slots__ = ( 'max_age', 'code', 'guild', 'revoked', 'created_at', 'uses', + 'temporary', 'max_uses', 'inviter', 'channel', '_state' ) + + def __init__(self, *, state, data): + self._state = state + self.max_age = data.get('max_age') + self.code = data.get('code') + self.guild = data.get('guild') + self.revoked = data.get('revoked') + self.created_at = parse_time(data.get('created_at')) + self.temporary = data.get('temporary') + self.uses = data.get('uses') + self.max_uses = data.get('max_uses') + + inviter_data = data.get('inviter') + self.inviter = None if inviter_data is None else self._state.store_user(inviter_data) + self.channel = data.get('channel') + + @classmethod + def from_incomplete(cls, *, state, data): + guild_id = int(data['guild']['id']) + channel_id = int(data['channel']['id']) + guild = state._get_guild(guild_id) + if guild is not None: + channel = guild.get_channel(channel_id) + else: + guild = Object(id=guild_id) + channel = Object(id=channel_id) + guild.name = data['guild']['name'] + channel.name = data['channel']['name'] + + data['guild'] = guild + data['channel'] = channel + return cls(state=state, data=data) + + def __str__(self): + return self.url + + def __repr__(self): + return ''.format(self) + + def __hash__(self): + return hash(self.code) + + @property + def id(self): + """Returns the proper code portion of the invite.""" + return self.code + + @property + def url(self): + """A property that retrieves the invite URL.""" + return 'http://discord.gg/' + self.code + + @asyncio.coroutine + def delete(self, *, reason=None): + """|coro| + + Revokes the instant invite. + + Parameters + ----------- + reason: Optional[str] + The reason for deleting this invite. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have permissions to revoke invites. + NotFound + The invite is invalid or expired. + HTTPException + Revoking the invite failed. + """ + + yield from self._state.http.delete_invite(self.code, reason=reason) diff --git a/discord.py-rewrite/discord/iterators.py b/discord.py-rewrite/discord/iterators.py new file mode 100644 index 0000000..e6f5234 --- /dev/null +++ b/discord.py-rewrite/discord/iterators.py @@ -0,0 +1,484 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import sys +import asyncio +import datetime + +from .errors import NoMoreItems +from .utils import time_snowflake, maybe_coroutine +from .object import Object +from .audit_logs import AuditLogEntry + +PY35 = sys.version_info >= (3, 5) + +class _AsyncIterator: + __slots__ = () + + def get(self, **attrs): + def predicate(elem): + for attr, val in attrs.items(): + nested = attr.split('__') + obj = elem + for attribute in nested: + obj = getattr(obj, attribute) + + if obj != val: + return False + return True + + return self.find(predicate) + + @asyncio.coroutine + def find(self, predicate): + while True: + try: + elem = yield from self.next() + except NoMoreItems: + return None + + ret = yield from maybe_coroutine(predicate, elem) + if ret: + return elem + + def map(self, func): + return _MappedAsyncIterator(self, func) + + def filter(self, predicate): + return _FilteredAsyncIterator(self, predicate) + + @asyncio.coroutine + def flatten(self): + ret = [] + while True: + try: + item = yield from self.next() + except NoMoreItems: + return ret + else: + ret.append(item) + + if PY35: + @asyncio.coroutine + def __aiter__(self): + return self + + @asyncio.coroutine + def __anext__(self): + try: + msg = yield from self.next() + except NoMoreItems: + raise StopAsyncIteration() + else: + return msg + +def _identity(x): + return x + +class _MappedAsyncIterator(_AsyncIterator): + def __init__(self, iterator, func): + self.iterator = iterator + self.func = func + + @asyncio.coroutine + def next(self): + # this raises NoMoreItems and will propagate appropriately + item = yield from self.iterator.next() + return (yield from maybe_coroutine(self.func, item)) + +class _FilteredAsyncIterator(_AsyncIterator): + def __init__(self, iterator, predicate): + self.iterator = iterator + + if predicate is None: + predicate = _identity + + self.predicate = predicate + + @asyncio.coroutine + def next(self): + getter = self.iterator.next + pred = self.predicate + while True: + # propagate NoMoreItems similar to _MappedAsyncIterator + item = yield from getter() + ret = yield from maybe_coroutine(pred, item) + if ret: + return item + +class ReactionIterator(_AsyncIterator): + def __init__(self, message, emoji, limit=100, after=None): + self.message = message + self.limit = limit + self.after = after + state = message._state + self.getter = state.http.get_reaction_users + self.state = state + self.emoji = emoji + self.guild = message.guild + self.channel_id = message.channel.id + self.users = asyncio.Queue(loop=state.loop) + + @asyncio.coroutine + def next(self): + if self.users.empty(): + yield from self.fill_users() + + try: + return self.users.get_nowait() + except asyncio.QueueEmpty: + raise NoMoreItems() + + @asyncio.coroutine + def fill_users(self): + # this is a hack because >circular imports< + from .user import User + + if self.limit > 0: + retrieve = self.limit if self.limit <= 100 else 100 + + after = self.after.id if self.after else None + data = yield from self.getter(self.message.id, self.channel_id, self.emoji, retrieve, after=after) + + if data: + self.limit -= retrieve + self.after = Object(id=int(data[0]['id'])) + + if self.guild is None: + for element in reversed(data): + yield from self.users.put(User(state=self.state, data=element)) + else: + for element in reversed(data): + member_id = int(element['id']) + member = self.guild.get_member(member_id) + if member is not None: + yield from self.users.put(member) + else: + yield from self.users.put(User(state=self.state, data=element)) + +class HistoryIterator(_AsyncIterator): + """Iterator for receiving a channel's message history. + + The messages endpoint has two behaviours we care about here: + If `before` is specified, the messages endpoint returns the `limit` + newest messages before `before`, sorted with newest first. For filling over + 100 messages, update the `before` parameter to the oldest message received. + Messages will be returned in order by time. + If `after` is specified, it returns the `limit` oldest messages after + `after`, sorted with newest first. For filling over 100 messages, update the + `after` parameter to the newest message received. If messages are not + reversed, they will be out of order (99-0, 199-100, so on) + + A note that if both before and after are specified, before is ignored by the + messages endpoint. + + Parameters + ----------- + messageable: :class:`abc.Messageable` + Messageable class to retrieve message history fro. + limit : int + Maximum number of messages to retrieve + before : :class:`Message` or id-like + Message before which all messages must be. + after : :class:`Message` or id-like + Message after which all messages must be. + around : :class:`Message` or id-like + Message around which all messages must be. Limit max 101. Note that if + limit is an even number, this will return at most limit+1 messages. + reverse: bool + If set to true, return messages in oldest->newest order. Recommended + when using with "after" queries with limit over 100, otherwise messages + will be out of order. + """ + + def __init__(self, messageable, limit, + before=None, after=None, around=None, reverse=None): + + if isinstance(before, datetime.datetime): + before = Object(id=time_snowflake(before, high=False)) + if isinstance(after, datetime.datetime): + after = Object(id=time_snowflake(after, high=True)) + if isinstance(around, datetime.datetime): + around = Object(id=time_snowflake(around)) + + self.messageable = messageable + self.limit = limit + self.before = before + self.after = after + self.around = around + + if reverse is None: + self.reverse = after is not None + else: + self.reverse = reverse + + self._filter = None # message dict -> bool + + self.state = self.messageable._state + self.logs_from = self.state.http.logs_from + self.messages = asyncio.Queue(loop=self.state.loop) + + if self.around: + if self.limit is None: + raise ValueError('history does not support around with limit=None') + if self.limit > 101: + raise ValueError("history max limit 101 when specifying around parameter") + elif self.limit == 101: + self.limit = 100 # Thanks discord + elif self.limit == 1: + raise ValueError("Use get_message.") + + self._retrieve_messages = self._retrieve_messages_around_strategy + if self.before and self.after: + self._filter = lambda m: self.after.id < int(m['id']) < self.before.id + elif self.before: + self._filter = lambda m: int(m['id']) < self.before.id + elif self.after: + self._filter = lambda m: self.after.id < int(m['id']) + elif self.before and self.after: + if self.reverse: + self._retrieve_messages = self._retrieve_messages_after_strategy + self._filter = lambda m: int(m['id']) < self.before.id + else: + self._retrieve_messages = self._retrieve_messages_before_strategy + self._filter = lambda m: int(m['id']) > self.after.id + elif self.after: + self._retrieve_messages = self._retrieve_messages_after_strategy + else: + self._retrieve_messages = self._retrieve_messages_before_strategy + + @asyncio.coroutine + def next(self): + if self.messages.empty(): + yield from self.fill_messages() + + try: + return self.messages.get_nowait() + except asyncio.QueueEmpty: + raise NoMoreItems() + + def _get_retrieve(self): + l = self.limit + if l is None: + r = 100 + elif l <= 100: + r = l + else: + r = 100 + + self.retrieve = r + return r > 0 + + @asyncio.coroutine + def flatten(self): + # this is similar to fill_messages except it uses a list instead + # of a queue to place the messages in. + result = [] + channel = yield from self.messageable._get_channel() + self.channel = channel + while self._get_retrieve(): + data = yield from self._retrieve_messages(self.retrieve) + if len(data) < 100: + self.limit = 0 # terminate the infinite loop + + if self.reverse: + data = reversed(data) + if self._filter: + data = filter(self._filter, data) + + for element in data: + result.append(self.state.create_message(channel=channel, data=element)) + return result + + @asyncio.coroutine + def fill_messages(self): + if not hasattr(self, 'channel'): + # do the required set up + channel = yield from self.messageable._get_channel() + self.channel = channel + + if self._get_retrieve(): + data = yield from self._retrieve_messages(self.retrieve) + if self.limit is None and len(data) < 100: + self.limit = 0 # terminate the infinite loop + + if self.reverse: + data = reversed(data) + if self._filter: + data = filter(self._filter, data) + + channel = self.channel + for element in data: + yield from self.messages.put(self.state.create_message(channel=channel, data=element)) + + @asyncio.coroutine + def _retrieve_messages(self, retrieve): + """Retrieve messages and update next parameters.""" + pass + + @asyncio.coroutine + def _retrieve_messages_before_strategy(self, retrieve): + """Retrieve messages using before parameter.""" + before = self.before.id if self.before else None + data = yield from self.logs_from(self.channel.id, retrieve, before=before) + if len(data): + if self.limit is not None: + self.limit -= retrieve + self.before = Object(id=int(data[-1]['id'])) + return data + + @asyncio.coroutine + def _retrieve_messages_after_strategy(self, retrieve): + """Retrieve messages using after parameter.""" + after = self.after.id if self.after else None + data = yield from self.logs_from(self.channel.id, retrieve, after=after) + if len(data): + if self.limit is not None: + self.limit -= retrieve + self.after = Object(id=int(data[0]['id'])) + return data + + @asyncio.coroutine + def _retrieve_messages_around_strategy(self, retrieve): + """Retrieve messages using around parameter.""" + if self.around: + around = self.around.id if self.around else None + data = yield from self.logs_from(self.channel.id, retrieve, around=around) + self.around = None + return data + return [] + +class AuditLogIterator(_AsyncIterator): + def __init__(self, guild, limit=None, before=None, after=None, reverse=None, user_id=None, action_type=None): + if isinstance(before, datetime.datetime): + before = Object(id=time_snowflake(before, high=False)) + if isinstance(after, datetime.datetime): + after = Object(id=time_snowflake(after, high=True)) + + + self.guild = guild + self.loop = guild._state.loop + self.request = guild._state.http.get_audit_logs + self.limit = limit + self.before = before + self.user_id = user_id + self.action_type = action_type + self.after = after + self._users = {} + self._state = guild._state + + if reverse is None: + self.reverse = after is not None + else: + self.reverse = reverse + + self._filter = None # entry dict -> bool + + self.entries = asyncio.Queue(loop=self.loop) + + if self.before and self.after: + if self.reverse: + self._strategy = self._after_strategy + self._filter = lambda m: int(m['id']) < self.before.id + else: + self._strategy = self._before_strategy + self._filter = lambda m: int(m['id']) > self.after.id + elif self.after: + self._strategy = self._after_strategy + else: + self._strategy = self._before_strategy + + @asyncio.coroutine + def _before_strategy(self, retrieve): + before = self.before.id if self.before else None + data = yield from self.request(self.guild.id, limit=retrieve, user_id=self.user_id, + action_type=self.action_type, before=before) + + entries = data.get('audit_log_entries', []) + if len(data) and entries: + if self.limit is not None: + self.limit -= retrieve + self.before = Object(id=int(entries[-1]['id'])) + return data.get('users', []), entries + + @asyncio.coroutine + def _after_strategy(self, retrieve): + after = self.after.id if self.after else None + data = yield from self.request(self.guild.id, limit=retrieve, user_id=self.user_id, + action_type=self.action_type, after=after) + entries = data.get('audit_log_entries', []) + if len(data) and entries: + if self.limit is not None: + self.limit -= retrieve + self.after = Object(id=int(entries[0]['id'])) + return data.get('users', []), entries + + @asyncio.coroutine + def next(self): + if self.entries.empty(): + yield from self._fill() + + try: + return self.entries.get_nowait() + except asyncio.QueueEmpty: + raise NoMoreItems() + + def _get_retrieve(self): + l = self.limit + if l is None: + r = 100 + elif l <= 100: + r = l + else: + r = 100 + + self.retrieve = r + return r > 0 + + @asyncio.coroutine + def _fill(self): + from .user import User + + if self._get_retrieve(): + users, data = yield from self._strategy(self.retrieve) + if self.limit is None and len(data) < 100: + self.limit = 0 # terminate the infinite loop + + if self.reverse: + data = reversed(data) + if self._filter: + data = filter(self._filter, data) + + for user in users: + u = User(data=user, state=self._state) + self._users[u.id] = u + + for element in data: + # TODO: remove this if statement later + if element['action_type'] is None: + continue + + yield from self.entries.put(AuditLogEntry(data=element, users=self._users, guild=self.guild)) diff --git a/discord.py-rewrite/discord/member.py b/discord.py-rewrite/discord/member.py new file mode 100644 index 0000000..711bc76 --- /dev/null +++ b/discord.py-rewrite/discord/member.py @@ -0,0 +1,559 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import asyncio +import itertools +import copy + +import discord.abc + +from . import utils +from .user import BaseUser, User +from .game import Game +from .permissions import Permissions +from .enums import Status, try_enum +from .colour import Colour +from .object import Object + +class VoiceState: + """Represents a Discord user's voice state. + + Attributes + ------------ + deaf: bool + Indicates if the user is currently deafened by the guild. + mute: bool + Indicates if the user is currently muted by the guild. + self_mute: bool + Indicates if the user is currently muted by their own accord. + self_deaf: bool + Indicates if the user is currently deafened by their own accord. + afk: bool + Indicates if the user is currently in the AFK channel in the guild. + channel: :class:`VoiceChannel` + The voice channel that the user is currently connected to. None if the user + is not currently in a voice channel. + """ + + __slots__ = ( 'session_id', 'deaf', 'mute', 'self_mute', + 'self_deaf', 'afk', 'channel' ) + + def __init__(self, *, data, channel=None): + self.session_id = data.get('session_id') + self._update(data, channel) + + def _update(self, data, channel): + self.self_mute = data.get('self_mute', False) + self.self_deaf = data.get('self_deaf', False) + self.afk = data.get('suppress', False) + self.mute = data.get('mute', False) + self.deaf = data.get('deaf', False) + self.channel = channel + + def __repr__(self): + return ''.format(self) + +def flatten_user(cls): + for attr, value in itertools.chain(BaseUser.__dict__.items(), User.__dict__.items()): + # ignore private/special methods + if attr.startswith('_'): + continue + + # don't override what we already have + if attr in cls.__dict__: + continue + + # if it's a slotted attribute or a property, redirect it + # slotted members are implemented as member_descriptors in Type.__dict__ + if not hasattr(value, '__annotations__'): + def getter(self, x=attr): + return getattr(self._user, x) + setattr(cls, attr, property(getter, doc='Equivalent to :attr:`User.%s`' % attr)) + else: + # probably a member function by now + def generate_function(x): + def general(self, *args, **kwargs): + return getattr(self._user, x)(*args, **kwargs) + + general.__name__ = x + return general + + func = generate_function(attr) + func.__doc__ = value.__doc__ + setattr(cls, attr, func) + + return cls + +_BaseUser = discord.abc.User + +@flatten_user +class Member(discord.abc.Messageable, _BaseUser): + """Represents a Discord member to a :class:`Guild`. + + This implements a lot of the functionality of :class:`User`. + + .. container:: operations + + .. describe:: x == y + + Checks if two members are equal. + Note that this works with :class:`User` instances too. + + .. describe:: x != y + + Checks if two members are not equal. + Note that this works with :class:`User` instances too. + + .. describe:: hash(x) + + Returns the member's hash. + + .. describe:: str(x) + + Returns the member's name with the discriminator. + + Attributes + ---------- + roles + A list of :class:`Role` that the member belongs to. Note that the first element of this + list is always the default '@everyone' role. These roles are sorted by their position + in the role hierarchy. + joined_at : `datetime.datetime` + A datetime object that specifies the date and time in UTC that the member joined the guild for + the first time. + status : :class:`Status` + The member's status. There is a chance that the status will be a ``str`` + if it is a value that is not recognised by the enumerator. + game : :class:`Game` + The game that the user is currently playing. Could be None if no game is being played. + guild : :class:`Guild` + The guild that the member belongs to. + nick : Optional[str] + The guild specific nickname of the user. + """ + + __slots__ = ('roles', 'joined_at', 'status', 'game', 'guild', 'nick', '_user', '_state') + + def __init__(self, *, data, guild, state): + self._state = state + self._user = state.store_user(data['user']) + self.guild = guild + self.joined_at = utils.parse_time(data.get('joined_at')) + self._update_roles(data) + self.status = Status.offline + game = data.get('game', {}) + self.game = Game(**game) if game else None + self.nick = data.get('nick', None) + + def __str__(self): + return str(self._user) + + def __repr__(self): + return ''.format(self, self._user) + + def __eq__(self, other): + return isinstance(other, _BaseUser) and other.id == self.id + + def __ne__(self, other): + return not self.__eq__(other) + + def __hash__(self): + return hash(self._user.id) + + @asyncio.coroutine + def _get_channel(self): + ch = yield from self.create_dm() + return ch + + def _update_roles(self, data): + # update the roles + self.roles = [self.guild.default_role] + for roleid in map(int, data['roles']): + role = utils.find(lambda r: r.id == roleid, self.guild.roles) + if role is not None: + self.roles.append(role) + + # sort the roles by hierarchy since they can be "randomised" + self.roles.sort() + + def _update(self, data, user=None): + if user: + self._user.name = user['username'] + self._user.discriminator = user['discriminator'] + self._user.avatar = user['avatar'] + self._user.bot = user.get('bot', False) + + # the nickname change is optional, + # if it isn't in the payload then it didn't change + try: + self.nick = data['nick'] + except KeyError: + pass + + self._update_roles(data) + + def _presence_update(self, data, user): + self.status = try_enum(Status, data['status']) + game = data.get('game', {}) + self.game = Game(**game) if game else None + u = self._user + u.name = user.get('username', u.name) + u.avatar = user.get('avatar', u.avatar) + u.discriminator = user.get('discriminator', u.discriminator) + + def _copy(self): + c = copy.copy(self) + c._user = copy.copy(self._user) + return c + + @property + def colour(self): + """A property that returns a :class:`Colour` denoting the rendered colour + for the member. If the default colour is the one rendered then an instance + of :meth:`Colour.default` is returned. + + There is an alias for this under ``color``. + """ + + roles = self.roles[1:] # remove @everyone + + # highest order of the colour is the one that gets rendered. + # if the highest is the default colour then the next one with a colour + # is chosen instead + for role in reversed(roles): + if role.colour.value: + return role.colour + return Colour.default() + + color = colour + + @property + def mention(self): + """Returns a string that mentions the member.""" + if self.nick: + return '<@!%s>' % self.id + return '<@%s>' % self.id + + @property + def display_name(self): + """Returns the user's display name. + + For regular users this is just their username, but + if they have a guild specific nickname then that + is returned instead. + """ + return self.nick if self.nick is not None else self.name + + def mentioned_in(self, message): + """Checks if the member is mentioned in the specified message. + + Parameters + ----------- + message: :class:`Message` + The message to check if you're mentioned in. + """ + if self._user.mentioned_in(message): + return True + + for role in message.role_mentions: + has_role = utils.get(self.roles, id=role.id) is not None + if has_role: + return True + + return False + + def permissions_in(self, channel): + """An alias for :meth:`abc.GuildChannel.permissions_for`. + + Basically equivalent to: + + .. code-block:: python3 + + channel.permissions_for(self) + + Parameters + ----------- + channel + The channel to check your permissions for. + """ + return channel.permissions_for(self) + + @property + def top_role(self): + """Returns the member's highest role. + + This is useful for figuring where a member stands in the role + hierarchy chain. + """ + return self.roles[-1] + + @property + def guild_permissions(self): + """Returns the member's guild permissions. + + This only takes into consideration the guild permissions + and not most of the implied permissions or any of the + channel permission overwrites. For 100% accurate permission + calculation, please use either :meth:`permissions_in` or + :meth:`abc.GuildChannel.permissions_for`. + + This does take into consideration guild ownership and the + administrator implication. + """ + + if self.guild.owner == self: + return Permissions.all() + + base = Permissions.none() + for r in self.roles: + base.value |= r.permissions.value + + if base.administrator: + return Permissions.all() + + return base + + @property + def voice(self): + """Optional[:class:`VoiceState`]: Returns the member's current voice state.""" + return self.guild._voice_state_for(self._user.id) + + @asyncio.coroutine + def ban(self, **kwargs): + """|coro| + + Bans this member. Equivalent to :meth:`Guild.ban` + """ + yield from self.guild.ban(self, **kwargs) + + @asyncio.coroutine + def unban(self, *, reason=None): + """|coro| + + Unbans this member. Equivalent to :meth:`Guild.unban` + """ + yield from self.guild.unban(self, reason=reason) + + @asyncio.coroutine + def kick(self, *, reason=None): + """|coro| + + Kicks this member. Equivalent to :meth:`Guild.kick` + """ + yield from self.guild.kick(self, reason=reason) + + @asyncio.coroutine + def edit(self, *, reason=None, **fields): + """|coro| + + Edits the member's data. + + Depending on the parameter passed, this requires different permissions listed below: + + +---------------+--------------------------------------+ + | Parameter | Permission | + +---------------+--------------------------------------+ + | nick | :attr:`Permissions.manage_nicknames` | + +---------------+--------------------------------------+ + | mute | :attr:`Permissions.mute_members` | + +---------------+--------------------------------------+ + | deafen | :attr:`Permissions.deafen_members` | + +---------------+--------------------------------------+ + | roles | :attr:`Permissions.manage_roles` | + +---------------+--------------------------------------+ + | voice_channel | :attr:`Permissions.move_members` | + +---------------+--------------------------------------+ + + All parameters are optional. + + Parameters + ----------- + nick: str + The member's new nickname. Use ``None`` to remove the nickname. + mute: bool + Indicates if the member should be guild muted or un-muted. + deafen: bool + Indicates if the member should be guild deafened or un-deafened. + roles: List[:class:`Roles`] + The member's new list of roles. This *replaces* the roles. + voice_channel: :class:`VoiceChannel` + The voice channel to move the member to. + reason: Optional[str] + The reason for editing this member. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have the proper permissions to the action requested. + HTTPException + The operation failed. + """ + http = self._state.http + guild_id = self.guild.id + payload = {} + + try: + nick = fields['nick'] + except KeyError: + # nick not present so... + pass + else: + nick = nick if nick else '' + if self._state.self_id == self.id: + yield from http.change_my_nickname(guild_id, nick, reason=reason) + else: + payload['nick'] = nick + + deafen = fields.get('deafen') + if deafen is not None: + payload['deaf'] = deafen + + mute = fields.get('mute') + if mute is not None: + payload['mute'] = mute + + try: + vc = fields['voice_channel'] + except KeyError: + pass + else: + payload['channel_id'] = vc.id + + try: + roles = fields['roles'] + except KeyError: + pass + else: + payload['roles'] = tuple(r.id for r in roles) + + yield from http.edit_member(guild_id, self.id, reason=reason, **payload) + + # TODO: wait for WS event for modify-in-place behaviour + + @asyncio.coroutine + def move_to(self, channel, *, reason=None): + """|coro| + + Moves a member to a new voice channel (they must be connected first). + + You must have the :attr:`~Permissions.move_members` permission to + use this. + + This raises the same exceptions as :meth:`edit`. + + Parameters + ----------- + channel: :class:`VoiceChannel` + The new voice channel to move the member to. + reason: Optional[str] + The reason for doing this action. Shows up on the audit log. + """ + yield from self.edit(voice_channel=channel, reason=reason) + + @asyncio.coroutine + def add_roles(self, *roles, reason=None, atomic=True): + """|coro| + + Gives the member a number of :class:`Role`\s. + + You must have the :attr:`~Permissions.manage_roles` permission to + use this. + + Parameters + ----------- + \*roles + An argument list of :class:`abc.Snowflake` representing a :class:`Role` + to give to the member. + reason: Optional[str] + The reason for adding these roles. Shows up on the audit log. + atomic: bool + Whether to atomically add roles. This will ensure that multiple + operations will always be applied regardless of the current + state of the cache. + + Raises + ------- + Forbidden + You do not have permissions to add these roles. + HTTPException + Adding roles failed. + """ + + if not atomic: + new_roles = utils._unique(Object(id=r.id) for s in (self.roles[1:], roles) for r in s) + yield from self.edit(roles=new_roles, reason=reason) + else: + req = self._state.http.add_role + guild_id = self.guild.id + user_id = self.id + for role in roles: + yield from req(guild_id, user_id, role.id, reason=reason) + + @asyncio.coroutine + def remove_roles(self, *roles, reason=None, atomic=True): + """|coro| + + Removes :class:`Role`\s from this member. + + You must have the :attr:`~Permissions.manage_roles` permission to + use this. + + Parameters + ----------- + \*roles + An argument list of :class:`abc.Snowflake` representing a :class:`Role` + to remove from the member. + reason: Optional[str] + The reason for removing these roles. Shows up on the audit log. + atomic: bool + Whether to atomically remove roles. This will ensure that multiple + operations will always be applied regardless of the current + state of the cache. + + Raises + ------- + Forbidden + You do not have permissions to remove these roles. + HTTPException + Removing the roles failed. + """ + + if not atomic: + new_roles = [Object(id=r.id) for r in self.roles[1:]] # remove @everyone + for role in roles: + try: + new_roles.remove(Object(id=role.id)) + except ValueError: + pass + + yield from self.edit(roles=new_roles, reason=reason) + else: + req = self._state.http.remove_role + guild_id = self.guild.id + user_id = self.id + for role in roles: + yield from req(guild_id, user_id, role.id, reason=reason) diff --git a/discord.py-rewrite/discord/message.py b/discord.py-rewrite/discord/message.py new file mode 100644 index 0000000..15e7cfb --- /dev/null +++ b/discord.py-rewrite/discord/message.py @@ -0,0 +1,742 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import asyncio +import re + +from . import utils, compat +from .reaction import Reaction +from .emoji import Emoji, PartialReactionEmoji +from .calls import CallMessage +from .enums import MessageType, try_enum +from .errors import InvalidArgument, ClientException, HTTPException, NotFound +from .embeds import Embed + +class Attachment: + """Represents an attachment from Discord. + + Attributes + ------------ + id: int + The attachment ID. + size: int + The attachment size in bytes. + height: Optional[int] + The attachment's height, in pixels. Only applicable to images. + width: Optional[int] + The attachment's width, in pixels. Only applicable to images. + filename: str + The attachment's filename. + url: str + The attachment URL. If the message this attachment was attached + to is deleted, then this will 404. + proxy_url: str + The proxy URL. This is a cached version of the :attr:`~Attachment.url` in the + case of images. When the message is deleted, this URL might be valid for a few + minutes or not valid at all. + """ + + __slots__ = ('id', 'size', 'height', 'width', 'filename', 'url', 'proxy_url', '_http') + + def __init__(self, *, data, state): + self.id = int(data['id']) + self.size = data['size'] + self.height = data.get('height') + self.width = data.get('width') + self.filename = data['filename'] + self.url = data.get('url') + self.proxy_url = data.get('proxy_url') + self._http = state.http + + @asyncio.coroutine + def save(self, fp): + """|coro| + + Saves this attachment into a file-like object. + + Parameters + ----------- + fp: Union[BinaryIO, str] + The file-like object to save this attachment to or the filename + to use. If a filename is passed then a file is created with that + filename and used instead. + + Raises + -------- + HTTPException + Saving the attachment failed. + NotFound + The attachment was deleted. + + Returns + -------- + int + The number of bytes written. + """ + + data = yield from self._http.get_attachment(self.url) + if isinstance(fp, str): + with open(fp, 'wb') as f: + return f.write(data) + else: + return fp.write(data) + +class Message: + """Represents a message from Discord. + + There should be no need to create one of these manually. + + Attributes + ----------- + tts: bool + Specifies if the message was done with text-to-speech. + type: :class:`MessageType` + The type of message. In most cases this should not be checked, but it is helpful + in cases where it might be a system message for :attr:`system_content`. + author + A :class:`Member` that sent the message. If :attr:`channel` is a + private channel, then it is a :class:`User` instead. + content: str + The actual contents of the message. + nonce + The value used by the discord guild and the client to verify that the message is successfully sent. + This is typically non-important. + embeds: List[:class:`Embed`] + A list embeds the message has. + channel + The :class:`TextChannel` that the message was sent from. + Could be a :class:`DMChannel` or :class:`GroupChannel` if it's a private message. + call: Optional[:class:`CallMessage`] + The call that the message refers to. This is only applicable to messages of type + :attr:`MessageType.call`. + mention_everyone: bool + Specifies if the message mentions everyone. + + .. note:: + + This does not check if the ``@everyone`` text is in the message itself. + Rather this boolean indicates if the ``@everyone`` text is in the message + **and** it did end up mentioning everyone. + + mentions: list + A list of :class:`Member` that were mentioned. If the message is in a private message + then the list will be of :class:`User` instead. For messages that are not of type + :attr:`MessageType.default`\, this array can be used to aid in system messages. + For more information, see :attr:`system_content`. + + .. warning:: + + The order of the mentions list is not in any particular order so you should + not rely on it. This is a discord limitation, not one with the library. + + channel_mentions: list + A list of :class:`abc.GuildChannel` that were mentioned. If the message is in a private message + then the list is always empty. + role_mentions: list + A list of :class:`Role` that were mentioned. If the message is in a private message + then the list is always empty. + id: int + The message ID. + webhook_id: Optional[int] + If this message was sent by a webhook, then this is the webhook ID's that sent this + message. + attachments: List[:class:`Attachment`] + A list of attachments given to a message. + pinned: bool + Specifies if the message is currently pinned. + reactions : List[:class:`Reaction`] + Reactions to a message. Reactions can be either custom emoji or standard unicode emoji. + """ + + __slots__ = ( '_edited_timestamp', 'tts', 'content', 'channel', 'webhook_id', + 'mention_everyone', 'embeds', 'id', 'mentions', 'author', + '_cs_channel_mentions', '_cs_raw_mentions', 'attachments', + '_cs_clean_content', '_cs_raw_channel_mentions', 'nonce', 'pinned', + 'role_mentions', '_cs_raw_role_mentions', 'type', 'call', + '_cs_system_content', '_cs_guild', '_state', 'reactions' ) + + def __init__(self, *, state, channel, data): + self._state = state + self.id = int(data['id']) + self.webhook_id = utils._get_as_snowflake(data, 'webhook_id') + self.reactions = [Reaction(message=self, data=d) for d in data.get('reactions', [])] + self._update(channel, data) + + def __repr__(self): + return ''.format(self) + + def _try_patch(self, data, key, transform=None): + try: + value = data[key] + except KeyError: + pass + else: + if transform is None: + setattr(self, key, value) + else: + setattr(self, key, transform(value)) + + def _add_reaction(self, data, emoji, user_id): + reaction = utils.find(lambda r: r.emoji == emoji, self.reactions) + is_me = data['me'] = user_id == self._state.self_id + + if reaction is None: + reaction = Reaction(message=self, data=data, emoji=emoji) + self.reactions.append(reaction) + else: + reaction.count += 1 + if is_me: + reaction.me = is_me + + return reaction + + def _remove_reaction(self, data, emoji, user_id): + reaction = utils.find(lambda r: r.emoji == emoji, self.reactions) + + if reaction is None: + # already removed? + raise ValueError('Emoji already removed?') + + # if reaction isn't in the list, we crash. This means discord + # sent bad data, or we stored improperly + reaction.count -= 1 + + if user_id == self._state.self_id: + reaction.me = False + if reaction.count == 0: + # this raises ValueError if something went wrong as well. + self.reactions.remove(reaction) + + return reaction + + def _update(self, channel, data): + self.channel = channel + self._edited_timestamp = utils.parse_time(data.get('edited_timestamp')) + self._try_patch(data, 'pinned') + self._try_patch(data, 'mention_everyone') + self._try_patch(data, 'tts') + self._try_patch(data, 'type', lambda x: try_enum(MessageType, x)) + self._try_patch(data, 'content') + self._try_patch(data, 'attachments', lambda x: [Attachment(data=a, state=self._state) for a in x]) + self._try_patch(data, 'embeds', lambda x: list(map(Embed.from_data, x))) + self._try_patch(data, 'nonce') + + for handler in ('author', 'mentions', 'mention_roles', 'call'): + try: + getattr(self, '_handle_%s' % handler)(data[handler]) + except KeyError: + continue + + # clear the cached properties + cached = filter(lambda attr: attr.startswith('_cs_'), self.__slots__) + for attr in cached: + try: + delattr(self, attr) + except AttributeError: + pass + + def _handle_author(self, author): + self.author = self._state.store_user(author) + if self.guild is not None: + found = self.guild.get_member(self.author.id) + if found is not None: + self.author = found + + def _handle_mentions(self, mentions): + self.mentions = [] + if self.guild is None: + self.mentions = [self._state.store_user(m) for m in mentions] + return + + for mention in mentions: + id_search = int(mention['id']) + member = self.guild.get_member(id_search) + if member is not None: + self.mentions.append(member) + + def _handle_mention_roles(self, role_mentions): + self.role_mentions = [] + if self.guild is not None: + for role_id in map(int, role_mentions): + role = utils.get(self.guild.roles, id=role_id) + if role is not None: + self.role_mentions.append(role) + + def _handle_call(self, call): + if call is None or self.type is not MessageType.call: + self.call = None + return + + # we get the participant source from the mentions array or + # the author + + participants = [] + for uid in map(int, call.get('participants', [])): + if uid == self.author.id: + participants.append(self.author) + else: + user = utils.find(lambda u: u.id == uid, self.mentions) + if user is not None: + participants.append(user) + + call['participants'] = participants + self.call = CallMessage(message=self, **call) + + @utils.cached_slot_property('_cs_guild') + def guild(self): + """Optional[:class:`Guild`]: The guild that the message belongs to, if applicable.""" + return getattr(self.channel, 'guild', None) + + @utils.cached_slot_property('_cs_raw_mentions') + def raw_mentions(self): + """A property that returns an array of user IDs matched with + the syntax of <@user_id> in the message content. + + This allows you receive the user IDs of mentioned users + even in a private message context. + """ + return [int(x) for x in re.findall(r'<@!?([0-9]+)>', self.content)] + + @utils.cached_slot_property('_cs_raw_channel_mentions') + def raw_channel_mentions(self): + """A property that returns an array of channel IDs matched with + the syntax of <#channel_id> in the message content. + """ + return [int(x) for x in re.findall(r'<#([0-9]+)>', self.content)] + + @utils.cached_slot_property('_cs_raw_role_mentions') + def raw_role_mentions(self): + """A property that returns an array of role IDs matched with + the syntax of <@&role_id> in the message content. + """ + return [int(x) for x in re.findall(r'<@&([0-9]+)>', self.content)] + + @utils.cached_slot_property('_cs_channel_mentions') + def channel_mentions(self): + if self.guild is None: + return [] + it = filter(None, map(lambda m: self.guild.get_channel(m), self.raw_channel_mentions)) + return utils._unique(it) + + @utils.cached_slot_property('_cs_clean_content') + def clean_content(self): + """A property that returns the content in a "cleaned up" + manner. This basically means that mentions are transformed + into the way the client shows it. e.g. ``<#id>`` will transform + into ``#name``. + + This will also transform @everyone and @here mentions into + non-mentions. + """ + + transformations = { + re.escape('<#%s>' % channel.id): '#' + channel.name + for channel in self.channel_mentions + } + + mention_transforms = { + re.escape('<@%s>' % member.id): '@' + member.display_name + for member in self.mentions + } + + # add the <@!user_id> cases as well.. + second_mention_transforms = { + re.escape('<@!%s>' % member.id): '@' + member.display_name + for member in self.mentions + } + + transformations.update(mention_transforms) + transformations.update(second_mention_transforms) + + if self.guild is not None: + role_transforms = { + re.escape('<@&%s>' % role.id): '@' + role.name + for role in self.role_mentions + } + transformations.update(role_transforms) + + def repl(obj): + return transformations.get(re.escape(obj.group(0)), '') + + pattern = re.compile('|'.join(transformations.keys())) + result = pattern.sub(repl, self.content) + + transformations = { + '@everyone': '@\u200beveryone', + '@here': '@\u200bhere' + } + + def repl2(obj): + return transformations.get(obj.group(0), '') + + pattern = re.compile('|'.join(transformations.keys())) + return pattern.sub(repl2, result) + + @property + def created_at(self): + """datetime.datetime: The message's creation time in UTC.""" + return utils.snowflake_time(self.id) + + @property + def edited_at(self): + """Optional[datetime.datetime]: A naive UTC datetime object containing the edited time of the message.""" + return self._edited_timestamp + + @utils.cached_slot_property('_cs_system_content') + def system_content(self): + """A property that returns the content that is rendered + regardless of the :attr:`Message.type`. + + In the case of :attr:`MessageType.default`\, this just returns the + regular :attr:`Message.content`. Otherwise this returns an English + message denoting the contents of the system message. + """ + + if self.type is MessageType.default: + return self.content + + if self.type is MessageType.pins_add: + return '{0.name} pinned a message to this channel.'.format(self.author) + + if self.type is MessageType.recipient_add: + return '{0.name} added {1.name} to the group.'.format(self.author, self.mentions[0]) + + if self.type is MessageType.recipient_remove: + return '{0.name} removed {1.name} from the group.'.format(self.author, self.mentions[0]) + + if self.type is MessageType.channel_name_change: + return '{0.author.name} changed the channel name: {0.content}'.format(self) + + if self.type is MessageType.channel_icon_change: + return '{0.author.name} changed the channel icon.'.format(self) + + if self.type is MessageType.new_member: + formats = [ + "{0} just joined the server - glhf!", + "{0} just joined. Everyone, look busy!", + "{0} just joined. Can I get a heal?", + "{0} joined your party.", + "{0} joined. You must construct additional pylons.", + "Ermagherd. {0} is here.", + "Welcome, {0}. Stay awhile and listen.", + "Welcome, {0}. We were expecting you ( ͡° ͜ʖ ͡°)", + "Welcome, {0}. We hope you brought pizza.", + "Welcome {0}. Leave your weapons by the door.", + "A wild {0} appeared.", + "Swoooosh. {0} just landed.", + "Brace yourselves. {0} just joined the server.", + "{0} just joined. Hide your bananas.", + "{0} just arrived. Seems OP - please nerf.", + "{0} just slid into the server.", + "A {0} has spawned in the server.", + "Big {0} showed up!", + "Where’s {0}? In the server!", + "{0} hopped into the server. Kangaroo!!", + "{0} just showed up. Hold my beer.", + "Challenger approaching - {0} has appeared!", + "It's a bird! It's a plane! Nevermind, it's just {0}.", + "It's {0}! Praise the sun! [T]/", + "Never gonna give {0} up. Never gonna let {0} down.", + "Ha! {0} has joined! You activated my trap card!", + "Cheers, love! {0}'s here!", + "Hey! Listen! {0} has joined!", + "We've been expecting you {0}", + "It's dangerous to go alone, take {0}!", + "{0} has joined the server! It's super effective!", + "Cheers, love! {0} is here!", + "{0} is here, as the prophecy foretold.", + "{0} has arrived. Party's over.", + "Ready player {0}", + "{0} is here to kick butt and chew bubblegum. And {0} is all out of gum.", + "Hello. Is it {0} you're looking for?", + "{0} has joined. Stay a while and listen!", + "Roses are red, violets are blue, {0} joined this server with you", + ] + + index = int(self.created_at.timestamp()) % len(formats) + return formats[index].format(self.author.name) + + if self.type is MessageType.call: + # we're at the call message type now, which is a bit more complicated. + # we can make the assumption that Message.channel is a PrivateChannel + # with the type ChannelType.group or ChannelType.private + call_ended = self.call.ended_timestamp is not None + + if self.channel.me in self.call.participants: + return '{0.author.name} started a call.'.format(self) + elif call_ended: + return 'You missed a call from {0.author.name}'.format(self) + else: + return '{0.author.name} started a call \N{EM DASH} Join the call.'.format(self) + + @asyncio.coroutine + def delete(self): + """|coro| + + Deletes the message. + + Your own messages could be deleted without any proper permissions. However to + delete other people's messages, you need the :attr:`~Permissions.manage_messages` + permission. + + Raises + ------ + Forbidden + You do not have proper permissions to delete the message. + HTTPException + Deleting the message failed. + """ + yield from self._state.http.delete_message(self.channel.id, self.id) + + @asyncio.coroutine + def edit(self, **fields): + """|coro| + + Edits the message. + + The content must be able to be transformed into a string via ``str(content)``. + + Parameters + ----------- + content: Optional[str] + The new content to replace the message with. + Could be ``None`` to remove the content. + embed: Optional[:class:`Embed`] + The new embed to replace the original with. + Could be ``None`` to remove the embed. + delete_after: Optional[float] + If provided, the number of seconds to wait in the background + before deleting the message we just edited. If the deletion fails, + then it is silently ignored. + + Raises + ------- + HTTPException + Editing the message failed. + """ + + try: + content = fields['content'] + except KeyError: + pass + else: + if content is not None: + fields['content'] = str(content) + + try: + embed = fields['embed'] + except KeyError: + pass + else: + if embed is not None: + fields['embed'] = embed.to_dict() + + data = yield from self._state.http.edit_message(self.id, self.channel.id, **fields) + self._update(channel=self.channel, data=data) + + try: + delete_after = fields['delete_after'] + except KeyError: + pass + else: + if delete_after is not None: + @asyncio.coroutine + def delete(): + yield from asyncio.sleep(delete_after, loop=self._state.loop) + try: + yield from self._state.http.delete_message(self.channel.id, self.id) + except: + pass + + compat.create_task(delete(), loop=self._state.loop) + + @asyncio.coroutine + def pin(self): + """|coro| + + Pins the message. You must have :attr:`~Permissions.manage_messages` + permissions to do this in a non-private channel context. + + Raises + ------- + Forbidden + You do not have permissions to pin the message. + NotFound + The message or channel was not found or deleted. + HTTPException + Pinning the message failed, probably due to the channel + having more than 50 pinned messages. + """ + + yield from self._state.http.pin_message(self.channel.id, self.id) + self.pinned = True + + @asyncio.coroutine + def unpin(self): + """|coro| + + Unpins the message. You must have :attr:`~Permissions.manage_messages` + permissions to do this in a non-private channel context. + + Raises + ------- + Forbidden + You do not have permissions to unpin the message. + NotFound + The message or channel was not found or deleted. + HTTPException + Unpinning the message failed. + """ + + yield from self._state.http.unpin_message(self.channel.id, self.id) + self.pinned = False + + @asyncio.coroutine + def add_reaction(self, emoji): + """|coro| + + Add a reaction to the message. + + The emoji may be a unicode emoji or a custom guild :class:`Emoji`. + + You must have the :attr:`~Permissions.add_reactions` and + :attr:`~Permissions.read_message_history` permissions to use this. + + Parameters + ------------ + emoji: Union[:class:`Emoji`, :class:`Reaction`, :class:`PartialReactionEmoji`, str] + The emoji to react with. + + Raises + -------- + HTTPException + Adding the reaction failed. + Forbidden + You do not have the proper permissions to react to the message. + NotFound + The emoji you specified was not found. + InvalidArgument + The emoji parameter is invalid. + """ + + if isinstance(emoji, Reaction): + emoji = emoji.emoji + + if isinstance(emoji, Emoji): + emoji = '%s:%s' % (emoji.name, emoji.id) + elif isinstance(emoji, PartialReactionEmoji): + emoji = emoji._as_reaction() + elif isinstance(emoji, str): + pass # this is okay + else: + raise InvalidArgument('emoji argument must be str, Emoji, or Reaction not {.__class__.__name__}.'.format(emoji)) + + yield from self._state.http.add_reaction(self.id, self.channel.id, emoji) + + @asyncio.coroutine + def remove_reaction(self, emoji, member): + """|coro| + + Remove a reaction by the member from the message. + + The emoji may be a unicode emoji or a custom guild :class:`Emoji`. + + If the reaction is not your own (i.e. ``member`` parameter is not you) then + the :attr:`~Permissions.manage_messages` permission is needed. + + The ``member`` parameter must represent a member and meet + the :class:`abc.Snowflake` abc. + + Parameters + ------------ + emoji: Union[:class:`Emoji`, :class:`Reaction`, :class:`PartialReactionEmoji`, str] + The emoji to remove. + member: :class:`abc.Snowflake` + The member for which to remove the reaction. + + Raises + -------- + HTTPException + Removing the reaction failed. + Forbidden + You do not have the proper permissions to remove the reaction. + NotFound + The member or emoji you specified was not found. + InvalidArgument + The emoji parameter is invalid. + """ + + if isinstance(emoji, Reaction): + emoji = emoji.emoji + + if isinstance(emoji, Emoji): + emoji = '%s:%s' % (emoji.name, emoji.id) + elif isinstance(emoji, PartialReactionEmoji): + emoji = emoji._as_reaction() + elif isinstance(emoji, str): + pass # this is okay + else: + raise InvalidArgument('emoji argument must be str, Emoji, or Reaction not {.__class__.__name__}.'.format(emoji)) + + yield from self._state.http.remove_reaction(self.id, self.channel.id, emoji, member.id) + + @asyncio.coroutine + def clear_reactions(self): + """|coro| + + Removes all the reactions from the message. + + You need :attr:`~Permissions.manage_messages` permission + to use this. + + Raises + -------- + HTTPException + Removing the reactions failed. + Forbidden + You do not have the proper permissions to remove all the reactions. + """ + yield from self._state.http.clear_reactions(self.id, self.channel.id) + + def ack(self): + """|coro| + + Marks this message as read. + + The user must not be a bot user. + + Raises + ------- + HTTPException + Acking failed. + ClientException + You must not be a bot user. + """ + + state = self._state + if state.is_bot: + raise ClientException('Must not be a bot account to ack messages.') + return state.http.ack_message(self.channel.id, self.id) diff --git a/discord.py-rewrite/discord/mixins.py b/discord.py-rewrite/discord/mixins.py new file mode 100644 index 0000000..8dd1cef --- /dev/null +++ b/discord.py-rewrite/discord/mixins.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +class EqualityComparable: + __slots__ = () + + def __eq__(self, other): + return isinstance(other, self.__class__) and other.id == self.id + + def __ne__(self, other): + if isinstance(other, self.__class__): + return other.id != self.id + return True + +class Hashable(EqualityComparable): + __slots__ = () + + def __hash__(self): + return self.id >> 22 diff --git a/discord.py-rewrite/discord/object.py b/discord.py-rewrite/discord/object.py new file mode 100644 index 0000000..9cb40ec --- /dev/null +++ b/discord.py-rewrite/discord/object.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from . import utils +from .mixins import Hashable + +class Object(Hashable): + """Represents a generic Discord object. + + The purpose of this class is to allow you to create 'miniature' + versions of data classes if you want to pass in just an ID. Most functions + that take in a specific data class with an ID can also take in this class + as a substitute instead. Note that even though this is the case, not all + objects (if any) actually inherit from this class. + + There are also some cases where some websocket events are received + in :issue:`strange order <21>` and when such events happened you would + receive this class rather than the actual data class. These cases are + extremely rare. + + .. container:: operations + + .. describe:: x == y + + Checks if two objects are equal. + + .. describe:: x != y + + Checks if two objects are not equal. + + .. describe:: hash(x) + + Returns the object's hash. + + Attributes + ----------- + id : str + The ID of the object. + """ + + def __init__(self, id): + self.id = id + + @property + def created_at(self): + """Returns the snowflake's creation time in UTC.""" + return utils.snowflake_time(self.id) diff --git a/discord.py-rewrite/discord/opus.py b/discord.py-rewrite/discord/opus.py new file mode 100644 index 0000000..0054da4 --- /dev/null +++ b/discord.py-rewrite/discord/opus.py @@ -0,0 +1,278 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import ctypes +import ctypes.util +import array +from .errors import DiscordException +import logging +import sys +import os.path + +log = logging.getLogger(__name__) +c_int_ptr = ctypes.POINTER(ctypes.c_int) +c_int16_ptr = ctypes.POINTER(ctypes.c_int16) +c_float_ptr = ctypes.POINTER(ctypes.c_float) + +class EncoderStruct(ctypes.Structure): + pass + +EncoderStructPtr = ctypes.POINTER(EncoderStruct) + +# A list of exported functions. +# The first argument is obviously the name. +# The second one are the types of arguments it takes. +# The third is the result type. +exported_functions = [ + ('opus_strerror', [ctypes.c_int], ctypes.c_char_p), + ('opus_encoder_get_size', [ctypes.c_int], ctypes.c_int), + ('opus_encoder_create', [ctypes.c_int, ctypes.c_int, ctypes.c_int, c_int_ptr], EncoderStructPtr), + ('opus_encode', [EncoderStructPtr, c_int16_ptr, ctypes.c_int, ctypes.c_char_p, ctypes.c_int32], ctypes.c_int32), + ('opus_encoder_ctl', None, ctypes.c_int32), + ('opus_encoder_destroy', [EncoderStructPtr], None) +] + +def libopus_loader(name): + # create the library... + lib = ctypes.cdll.LoadLibrary(name) + + # register the functions... + for item in exported_functions: + try: + func = getattr(lib, item[0]) + except Exception as e: + raise e + + try: + if item[1]: + func.argtypes = item[1] + + func.restype = item[2] + except KeyError: + pass + + return lib + +try: + if sys.platform == 'win32': + _basedir = os.path.dirname(os.path.abspath(__file__)) + _bitness = 'x64' if sys.maxsize > 2**32 else 'x86' + _filename = os.path.join(_basedir, 'bin', 'libopus-0.{}.dll'.format(_bitness)) + _lib = libopus_loader(_filename) + else: + _lib = libopus_loader(ctypes.util.find_library('opus')) +except Exception as e: + _lib = None + +def load_opus(name): + """Loads the libopus shared library for use with voice. + + If this function is not called then the library uses the function + `ctypes.util.find_library`__ and then loads that one + if available. + + .. _find library: https://docs.python.org/3.5/library/ctypes.html#finding-shared-libraries + __ `find library`_ + + Not loading a library leads to voice not working. + + This function propagates the exceptions thrown. + + Warning + -------- + The bitness of the library must match the bitness of your python + interpreter. If the library is 64-bit then your python interpreter + must be 64-bit as well. Usually if there's a mismatch in bitness then + the load will throw an exception. + + Note + ---- + On Windows, the .dll extension is not necessary. However, on Linux + the full extension is required to load the library, e.g. ``libopus.so.1``. + On Linux however, `find library`_ will usually find the library automatically + without you having to call this. + + Parameters + ---------- + name: str + The filename of the shared library. + """ + global _lib + _lib = libopus_loader(name) + +def is_loaded(): + """Function to check if opus lib is successfully loaded either + via the ``ctypes.util.find_library`` call of :func:`load_opus`. + + This must return ``True`` for voice to work. + + Returns + ------- + bool + Indicates if the opus library has been loaded. + """ + global _lib + return _lib is not None + +class OpusError(DiscordException): + """An exception that is thrown for libopus related errors. + + Attributes + ---------- + code : int + The error code returned. + """ + + def __init__(self, code): + self.code = code + msg = _lib.opus_strerror(self.code).decode('utf-8') + log.info('"%s" has happened', msg) + super().__init__(msg) + +class OpusNotLoaded(DiscordException): + """An exception that is thrown for when libopus is not loaded.""" + pass + + +# Some constants... +OK = 0 +APPLICATION_AUDIO = 2049 +APPLICATION_VOIP = 2048 +APPLICATION_LOWDELAY = 2051 +CTL_SET_BITRATE = 4002 +CTL_SET_BANDWIDTH = 4008 +CTL_SET_FEC = 4012 +CTL_SET_PLP = 4014 +CTL_SET_SIGNAL = 4024 + +band_ctl = { + 'narrow': 1101, + 'medium': 1102, + 'wide': 1103, + 'superwide': 1104, + 'full': 1105, +} + +signal_ctl = { + 'auto': -1000, + 'voice': 3001, + 'music': 3002, +} + +class Encoder: + SAMPLING_RATE = 48000 + CHANNELS = 2 + FRAME_LENGTH = 20 + SAMPLE_SIZE = 4 # (bit_rate / 8) * CHANNELS (bit_rate == 16) + SAMPLES_PER_FRAME = int(SAMPLING_RATE / 1000 * FRAME_LENGTH) + + FRAME_SIZE = SAMPLES_PER_FRAME * SAMPLE_SIZE + + def __init__(self, application=APPLICATION_AUDIO): + self.application = application + + if not is_loaded(): + raise OpusNotLoaded() + + self._state = self._create_state() + self.set_bitrate(128) + self.set_fec(True) + self.set_expected_packet_loss_percent(0.15) + self.set_bandwidth('full') + self.set_signal_type('auto') + + def __del__(self): + if hasattr(self, '_state'): + _lib.opus_encoder_destroy(self._state) + self._state = None + + def _create_state(self): + ret = ctypes.c_int() + result = _lib.opus_encoder_create(self.SAMPLING_RATE, self.CHANNELS, self.application, ctypes.byref(ret)) + + if ret.value != 0: + log.info('error has happened in state creation') + raise OpusError(ret.value) + + return result + + def set_bitrate(self, kbps): + kbps = min(128, max(16, int(kbps))) + + ret = _lib.opus_encoder_ctl(self._state, CTL_SET_BITRATE, kbps * 1024) + if ret < 0: + log.info('error has happened in set_bitrate') + raise OpusError(ret) + + return kbps + + def set_bandwidth(self, req): + if req not in band_ctl: + raise KeyError('%r is not a valid bandwidth setting. Try one of: %s' % (req, ','.join(band_ctl))) + + k = band_ctl[req] + ret = _lib.opus_encoder_ctl(self._state, CTL_SET_BANDWIDTH, k) + + if ret < 0: + log.info('error has happened in set_bandwidth') + raise OpusError(ret) + + def set_signal_type(self, req): + if req not in signal_ctl: + raise KeyError('%r is not a valid signal setting. Try one of: %s' % (req, ','.join(signal_ctl))) + + k = signal_ctl[req] + ret = _lib.opus_encoder_ctl(self._state, CTL_SET_SIGNAL, k) + + if ret < 0: + log.info('error has happened in set_signal_type') + raise OpusError(ret) + + def set_fec(self, enabled=True): + ret = _lib.opus_encoder_ctl(self._state, CTL_SET_FEC, 1 if enabled else 0) + + if ret < 0: + log.info('error has happened in set_fec') + raise OpusError(ret) + + def set_expected_packet_loss_percent(self, percentage): + ret = _lib.opus_encoder_ctl(self._state, CTL_SET_PLP, min(100, max(0, int(percentage * 100)))) + + if ret < 0: + log.info('error has happened in set_expected_packet_loss_percent') + raise OpusError(ret) + + def encode(self, pcm, frame_size): + max_data_bytes = len(pcm) + pcm = ctypes.cast(pcm, c_int16_ptr) + data = (ctypes.c_char * max_data_bytes)() + + ret = _lib.opus_encode(self._state, pcm, frame_size, data, max_data_bytes) + if ret < 0: + log.info('error has happened in encode') + raise OpusError(ret) + + return array.array('b', data[:ret]).tobytes() diff --git a/discord.py-rewrite/discord/permissions.py b/discord.py-rewrite/discord/permissions.py new file mode 100644 index 0000000..12434c7 --- /dev/null +++ b/discord.py-rewrite/discord/permissions.py @@ -0,0 +1,608 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +class Permissions: + """Wraps up the Discord permission value. + + The properties provided are two way. You can set and retrieve individual + bits using the properties as if they were regular bools. This allows + you to edit permissions. + + .. container:: operations + + .. describe:: x == y + + Checks if two permissions are equal. + .. describe:: x != y + + Checks if two permissions are not equal. + .. describe:: x <= y + + Checks if a permission is a subset of another permission. + .. describe:: x >= y + + Checks if a permission is a superset of another permission. + .. describe:: x < y + + Checks if a permission is a strict subset of another permission. + .. describe:: x > y + + Checks if a permission is a strict superset of another permission. + .. describe:: hash(x) + + Return the permission's hash. + .. describe:: iter(x) + + Returns an iterator of ``(perm, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + + Attributes + ----------- + value + The raw value. This value is a bit array field of a 53-bit integer + representing the currently available permissions. You should query + permissions via the properties rather than using this raw value. + """ + + __slots__ = ('value',) + def __init__(self, permissions=0): + if not isinstance(permissions, int): + raise TypeError('Expected int parameter, received %s instead.' % permissions.__class__.__name__) + + self.value = permissions + + def __eq__(self, other): + return isinstance(other, Permissions) and self.value == other.value + + def __ne__(self, other): + return not self.__eq__(other) + + def __hash__(self): + return hash(self.value) + + def __repr__(self): + return '' % self.value + + def _perm_iterator(self): + for attr in dir(self): + # check if it's a property, because if so it's a permission + is_property = isinstance(getattr(self.__class__, attr), property) + if is_property: + yield (attr, getattr(self, attr)) + + def __iter__(self): + return self._perm_iterator() + + def is_subset(self, other): + """Returns True if self has the same or fewer permissions as other.""" + if isinstance(other, Permissions): + return (self.value & other.value) == self.value + else: + raise TypeError("cannot compare {} with {}".format(self.__class__.__name__, other.__class__name)) + + def is_superset(self, other): + """Returns True if self has the same or more permissions as other.""" + if isinstance(other, Permissions): + return (self.value | other.value) == self.value + else: + raise TypeError("cannot compare {} with {}".format(self.__class__.__name__, other.__class__name)) + + def is_strict_subset(self, other): + """Returns True if the permissions on other are a strict subset of those on self.""" + return self.is_subset(other) and self != other + + def is_strict_superset(self, other): + """Returns True if the permissions on other are a strict superset of those on self.""" + return self.is_superset(other) and self != other + + __le__ = is_subset + __ge__ = is_superset + __lt__ = is_strict_subset + __gt__ = is_strict_superset + + @classmethod + def none(cls): + """A factory method that creates a :class:`Permissions` with all + permissions set to False.""" + return cls(0) + + @classmethod + def all(cls): + """A factory method that creates a :class:`Permissions` with all + permissions set to True.""" + return cls(0b01111111111101111111110011111111) + + @classmethod + def all_channel(cls): + """A :class:`Permissions` with all channel-specific permissions set to + True and the guild-specific ones set to False. The guild-specific + permissions are currently: + + - manage_guild + - kick_members + - ban_members + - administrator + - change_nicknames + - manage_nicknames + """ + return cls(0b00110011111101111111110001010001) + + @classmethod + def general(cls): + """A factory method that creates a :class:`Permissions` with all + "General" permissions from the official Discord UI set to True.""" + return cls(0b01111100000000000000000010111111) + + @classmethod + def text(cls): + """A factory method that creates a :class:`Permissions` with all + "Text" permissions from the official Discord UI set to True.""" + return cls(0b00000000000001111111110001000000) + + @classmethod + def voice(cls): + """A factory method that creates a :class:`Permissions` with all + "Voice" permissions from the official Discord UI set to True.""" + return cls(0b00000011111100000000000000000000) + + def update(self, **kwargs): + """Bulk updates this permission object. + + Allows you to set multiple attributes by using keyword + arguments. The names must be equivalent to the properties + listed. Extraneous key/value pairs will be silently ignored. + + Parameters + ------------ + \*\*kwargs + A list of key/value pairs to bulk update permissions with. + """ + for key, value in kwargs.items(): + try: + is_property = isinstance(getattr(self.__class__, key), property) + except AttributeError: + continue + + if is_property: + setattr(self, key, value) + + def _bit(self, index): + return bool((self.value >> index) & 1) + + def _set(self, index, value): + if value == True: + self.value |= (1 << index) + elif value == False: + self.value &= ~(1 << index) + else: + raise TypeError('Value to set for Permissions must be a bool.') + + def handle_overwrite(self, allow, deny): + # Basically this is what's happening here. + # We have an original bit array, e.g. 1010 + # Then we have another bit array that is 'denied', e.g. 1111 + # And then we have the last one which is 'allowed', e.g. 0101 + # We want original OP denied to end up resulting in + # whatever is in denied to be set to 0. + # So 1010 OP 1111 -> 0000 + # Then we take this value and look at the allowed values. + # And whatever is allowed is set to 1. + # So 0000 OP2 0101 -> 0101 + # The OP is base & ~denied. + # The OP2 is base | allowed. + self.value = (self.value & ~deny) | allow + + @property + def create_instant_invite(self): + """Returns True if the user can create instant invites.""" + return self._bit(0) + + @create_instant_invite.setter + def create_instant_invite(self, value): + self._set(0, value) + + @property + def kick_members(self): + """Returns True if the user can kick users from the guild.""" + return self._bit(1) + + @kick_members.setter + def kick_members(self, value): + self._set(1, value) + + @property + def ban_members(self): + """Returns True if a user can ban users from the guild.""" + return self._bit(2) + + @ban_members.setter + def ban_members(self, value): + self._set(2, value) + + @property + def administrator(self): + """Returns True if a user is an administrator. This role overrides all other permissions. + + This also bypasses all channel-specific overrides. + """ + return self._bit(3) + + @administrator.setter + def administrator(self, value): + self._set(3, value) + + @property + def manage_channels(self): + """Returns True if a user can edit, delete, or create channels in the guild. + + This also corresponds to the "manage channel" channel-specific override.""" + return self._bit(4) + + @manage_channels.setter + def manage_channels(self, value): + self._set(4, value) + + @property + def manage_guild(self): + """Returns True if a user can edit guild properties.""" + return self._bit(5) + + @manage_guild.setter + def manage_guild(self, value): + self._set(5, value) + + @property + def add_reactions(self): + """Returns True if a user can add reactions to messages.""" + return self._bit(6) + + @add_reactions.setter + def add_reactions(self, value): + self._set(6, value) + + @property + def view_audit_log(self): + """Returns True if a user can view the guild's audit log.""" + return self._bit(7) + + @view_audit_log.setter + def view_audit_log(self, value): + self._set(7, value) + + # 2 unused + + @property + def read_messages(self): + """Returns True if a user can read messages from all or specific text channels.""" + return self._bit(10) + + @read_messages.setter + def read_messages(self, value): + self._set(10, value) + + @property + def send_messages(self): + """Returns True if a user can send messages from all or specific text channels.""" + return self._bit(11) + + @send_messages.setter + def send_messages(self, value): + self._set(11, value) + + @property + def send_tts_messages(self): + """Returns True if a user can send TTS messages from all or specific text channels.""" + return self._bit(12) + + @send_tts_messages.setter + def send_tts_messages(self, value): + self._set(12, value) + + @property + def manage_messages(self): + """Returns True if a user can delete or pin messages in a text channel. Note that there are currently no ways to edit other people's messages.""" + return self._bit(13) + + @manage_messages.setter + def manage_messages(self, value): + self._set(13, value) + + @property + def embed_links(self): + """Returns True if a user's messages will automatically be embedded by Discord.""" + return self._bit(14) + + @embed_links.setter + def embed_links(self, value): + self._set(14, value) + + @property + def attach_files(self): + """Returns True if a user can send files in their messages.""" + return self._bit(15) + + @attach_files.setter + def attach_files(self, value): + self._set(15, value) + + @property + def read_message_history(self): + """Returns True if a user can read a text channel's previous messages.""" + return self._bit(16) + + @read_message_history.setter + def read_message_history(self, value): + self._set(16, value) + + @property + def mention_everyone(self): + """Returns True if a user's @everyone will mention everyone in the text channel.""" + return self._bit(17) + + @mention_everyone.setter + def mention_everyone(self, value): + self._set(17, value) + + @property + def external_emojis(self): + """Returns True if a user can use emojis from other guilds.""" + return self._bit(18) + + @external_emojis.setter + def external_emojis(self, value): + self._set(18, value) + + # 1 unused + + @property + def connect(self): + """Returns True if a user can connect to a voice channel.""" + return self._bit(20) + + @connect.setter + def connect(self, value): + self._set(20, value) + + @property + def speak(self): + """Returns True if a user can speak in a voice channel.""" + return self._bit(21) + + @speak.setter + def speak(self, value): + self._set(21, value) + + @property + def mute_members(self): + """Returns True if a user can mute other users.""" + return self._bit(22) + + @mute_members.setter + def mute_members(self, value): + self._set(22, value) + + @property + def deafen_members(self): + """Returns True if a user can deafen other users.""" + return self._bit(23) + + @deafen_members.setter + def deafen_members(self, value): + self._set(23, value) + + @property + def move_members(self): + """Returns True if a user can move users between other voice channels.""" + return self._bit(24) + + @move_members.setter + def move_members(self, value): + self._set(24, value) + + @property + def use_voice_activation(self): + """Returns True if a user can use voice activation in voice channels.""" + return self._bit(25) + + @use_voice_activation.setter + def use_voice_activation(self, value): + self._set(25, value) + + @property + def change_nickname(self): + """Returns True if a user can change their nickname in the guild.""" + return self._bit(26) + + @change_nickname.setter + def change_nickname(self, value): + self._set(26, value) + + @property + def manage_nicknames(self): + """Returns True if a user can change other user's nickname in the guild.""" + return self._bit(27) + + @manage_nicknames.setter + def manage_nicknames(self, value): + self._set(27, value) + + @property + def manage_roles(self): + """Returns True if a user can create or edit roles less than their role's position. + + This also corresponds to the "manage permissions" channel-specific override. + """ + return self._bit(28) + + @manage_roles.setter + def manage_roles(self, value): + self._set(28, value) + + @property + def manage_webhooks(self): + """Returns True if a user can create, edit, or delete webhooks.""" + return self._bit(29) + + @manage_webhooks.setter + def manage_webhooks(self, value): + self._set(29, value) + + @property + def manage_emojis(self): + """Returns True if a user can create, edit, or delete emojis.""" + return self._bit(30) + + @manage_emojis.setter + def manage_emojis(self, value): + self._set(30, value) + + # 1 unused + + # after these 32 bits, there's 21 more unused ones technically + +def augment_from_permissions(cls): + cls.VALID_NAMES = { name for name in dir(Permissions) if isinstance(getattr(Permissions, name), property) } + + # make descriptors for all the valid names + for name in cls.VALID_NAMES: + # god bless Python + def getter(self, x=name): + return self._values.get(x) + def setter(self, value, x=name): + self._set(x, value) + + prop = property(getter, setter) + setattr(cls, name, prop) + + return cls + +@augment_from_permissions +class PermissionOverwrite: + """A type that is used to represent a channel specific permission. + + Unlike a regular :class:`Permissions`\, the default value of a + permission is equivalent to ``None`` and not ``False``. Setting + a value to ``False`` is **explicitly** denying that permission, + while setting a value to ``True`` is **explicitly** allowing + that permission. + + The values supported by this are the same as :class:`Permissions` + with the added possibility of it being set to ``None``. + + Supported operations: + + +-----------+------------------------------------------+ + | Operation | Description | + +===========+==========================================+ + | iter(x) | Returns an iterator of (perm, value) | + | | pairs. This allows this class to be used | + | | as an iterable in e.g. set/list/dict | + | | constructions. | + +-----------+------------------------------------------+ + + Parameters + ----------- + \*\*kwargs + Set the value of permissions by their name. + """ + + __slots__ = ('_values',) + + def __init__(self, **kwargs): + self._values = {} + + for key, value in kwargs.items(): + if key not in self.VALID_NAMES: + raise ValueError('no permission called {0}.'.format(key)) + + setattr(self, key, value) + + def _set(self, key, value): + if value not in (True, None, False): + raise TypeError('Expected bool or NoneType, received {0.__class__.__name__}'.format(value)) + + self._values[key] = value + + def pair(self): + """Returns the (allow, deny) pair from this overwrite. + + The value of these pairs is :class:`Permissions`. + """ + + allow = Permissions.none() + deny = Permissions.none() + + for key, value in self._values.items(): + if value is True: + setattr(allow, key, True) + elif value is False: + setattr(deny, key, True) + + return allow, deny + + @classmethod + def from_pair(cls, allow, deny): + """Creates an overwrite from an allow/deny pair of :class:`Permissions`.""" + ret = cls() + for key, value in allow: + if value is True: + setattr(ret, key, True) + + for key, value in deny: + if value is True: + setattr(ret, key, False) + + return ret + + def is_empty(self): + """Checks if the permission overwrite is currently empty. + + An empty permission overwrite is one that has no overwrites set + to True or False. + """ + return all(x is None for x in self._values.values()) + + def update(self, **kwargs): + """Bulk updates this permission overwrite object. + + Allows you to set multiple attributes by using keyword + arguments. The names must be equivalent to the properties + listed. Extraneous key/value pairs will be silently ignored. + + Parameters + ------------ + \*\*kwargs + A list of key/value pairs to bulk update with. + """ + for key, value in kwargs.items(): + if key not in self.VALID_NAMES: + continue + + setattr(self, key, value) + + def __iter__(self): + for key in self.VALID_NAMES: + yield key, self._values.get(key) diff --git a/discord.py-rewrite/discord/player.py b/discord.py-rewrite/discord/player.py new file mode 100644 index 0000000..f8afdfa --- /dev/null +++ b/discord.py-rewrite/discord/player.py @@ -0,0 +1,330 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import threading +import subprocess +import audioop +import logging +import shlex +import time + +from .errors import ClientException +from .opus import Encoder as OpusEncoder + +log = logging.getLogger(__name__) + +__all__ = [ 'AudioSource', 'PCMAudio', 'FFmpegPCMAudio', 'PCMVolumeTransformer' ] + +class AudioSource: + """Represents an audio stream. + + The audio stream can be Opus encoded or not, however if the audio stream + is not Opus encoded then the audio format must be 16-bit 48KHz stereo PCM. + + .. warning:: + + The audio source reads are done in a separate thread. + """ + + def read(self): + """Reads 20ms worth of audio. + + Subclasses must implement this. + + If the audio is complete, then returning an empty *bytes-like* object + to signal this is the way to do so. + + If :meth:`is_opus` method returns ``True``, then it must return + 20ms worth of Opus encoded audio. Otherwise, it must be 20ms + worth of 16-bit 48KHz stereo PCM, which is about 3,840 bytes + per frame (20ms worth of audio). + + Returns + -------- + bytes + A bytes like object that represents the PCM or Opus data. + """ + raise NotImplementedError + + def is_opus(self): + """Checks if the audio source is already encoded in Opus. + + Defaults to ``False``. + """ + return False + + def cleanup(self): + """Called when clean-up is needed to be done. + + Useful for clearing buffer data or processes after + it is done playing audio. + """ + pass + + def __del__(self): + self.cleanup() + +class PCMAudio(AudioSource): + """Represents raw 16-bit 48KHz stereo PCM audio source. + + Attributes + ----------- + stream: file-like object + A file-like object that reads byte data representing raw PCM. + """ + def __init__(self, stream): + self.stream = stream + + def read(self): + ret = self.stream.read(OpusEncoder.FRAME_SIZE) + if len(ret) != OpusEncoder.FRAME_SIZE: + return b'' + return ret + +class FFmpegPCMAudio(AudioSource): + """An audio source from FFmpeg (or AVConv). + + This launches a sub-process to a specific input file given. + + .. warning:: + + You must have the ffmpeg or avconv executable in your path environment + variable in order for this to work. + + Parameters + ------------ + source: Union[str, BinaryIO] + The input that ffmpeg will take and convert to PCM bytes. + If ``pipe`` is True then this is a file-like object that is + passed to the stdin of ffmpeg. + executable: str + The executable name (and path) to use. Defaults to ``ffmpeg``. + pipe: bool + If true, denotes that ``source`` parameter will be passed + to the stdin of ffmpeg. Defaults to ``False``. + stderr: Optional[BinaryIO] + A file-like object to pass to the Popen constructor. + Could also be an instance of ``subprocess.PIPE``. + options: Optional[str] + Extra command line arguments to pass to ffmpeg after the ``-i`` flag. + before_options: Optional[str] + Extra command line arguments to pass to ffmpeg before the ``-i`` flag. + + Raises + -------- + ClientException + The subprocess failed to be created. + """ + + def __init__(self, source, *, executable='ffmpeg', pipe=False, stderr=None, before_options=None, options=None): + stdin = None if not pipe else source + + args = [executable] + + if isinstance(before_options, str): + args.extend(shlex.split(before_options)) + + args.append('-i') + args.append('-' if pipe else source) + args.extend(('-f', 's16le', '-ar', '48000', '-ac', '2', '-loglevel', 'warning')) + + if isinstance(options, str): + args.extend(shlex.split(options)) + + args.append('pipe:1') + + try: + self._process = subprocess.Popen(args, stdin=stdin, stdout=subprocess.PIPE, stderr=stderr) + self._stdout = self._process.stdout + except FileNotFoundError: + raise ClientException(executable + ' was not found.') from None + except subprocess.SubprocessError as e: + raise ClientException('Popen failed: {0.__class__.__name__}: {0}'.format(e)) from e + + def read(self): + ret = self._stdout.read(OpusEncoder.FRAME_SIZE) + if len(ret) != OpusEncoder.FRAME_SIZE: + return b'' + return ret + + def cleanup(self): + proc = self._process + if proc is None: + return + + log.info('Preparing to terminate ffmpeg process %s.', proc.pid) + proc.kill() + if proc.poll() is None: + log.info('ffmpeg process %s has not terminated. Waiting to terminate...', proc.pid) + proc.communicate() + log.info('ffmpeg process %s should have terminated with a return code of %s.', proc.pid, proc.returncode) + else: + log.info('ffmpeg process %s successfully terminated with return code of %s.', proc.pid, proc.returncode) + + self._process = None + +class PCMVolumeTransformer(AudioSource): + """Transforms a previous :class:`AudioSource` to have volume controls. + + This does not work on audio sources that have :meth:`AudioSource.is_opus` + set to ``True``. + + Parameters + ------------ + original: :class:`AudioSource` + The original AudioSource to transform. + volume: float + The initial volume to set it to. + See :attr:`volume` for more info. + + Raises + ------- + TypeError + Not an audio source. + ClientException + The audio source is opus encoded. + """ + + def __init__(self, original, volume=1.0): + if not isinstance(original, AudioSource): + raise TypeError('expected AudioSource not {0.__class__.__name__}.'.format(original)) + + if original.is_opus(): + raise ClientException('AudioSource must not be Opus encoded.') + + self.original = original + self.volume = volume + + @property + def volume(self): + """Retrieves or sets the volume as a floating point percentage (e.g. 1.0 for 100%).""" + return self._volume + + @volume.setter + def volume(self, value): + self._volume = max(value, 0.0) + + def cleanup(self): + self.original.cleanup() + + def read(self): + ret = self.original.read() + return audioop.mul(ret, 2, min(self._volume, 2.0)) + +class AudioPlayer(threading.Thread): + DELAY = OpusEncoder.FRAME_LENGTH / 1000.0 + + def __init__(self, source, client, *, after=None): + threading.Thread.__init__(self) + self.daemon = True + self.source = source + self.client = client + self.after = after + + self._end = threading.Event() + self._resumed = threading.Event() + self._resumed.set() # we are not paused + self._current_error = None + self._connected = client._connected + self._lock = threading.Lock() + + if after is not None and not callable(after): + raise TypeError('Expected a callable for the "after" parameter.') + + def _do_run(self): + self.loops = 0 + self._start = time.time() + + # getattr lookup speed ups + play_audio = self.client.send_audio_packet + + while not self._end.is_set(): + # are we paused? + if not self._resumed.is_set(): + # wait until we aren't + self._resumed.wait() + continue + + # are we disconnected from voice? + if not self._connected.is_set(): + # wait until we are connected + self._connected.wait() + # reset our internal data + self.loops = 0 + self._start = time.time() + + self.loops += 1 + data = self.source.read() + + if not data: + self.stop() + break + + play_audio(data, encode=not self.source.is_opus()) + next_time = self._start + self.DELAY * self.loops + delay = max(0, self.DELAY + (next_time - time.time())) + time.sleep(delay) + + def run(self): + try: + self._do_run() + except Exception as e: + self._current_error = e + self.stop() + finally: + self.source.cleanup() + self._call_after() + + def _call_after(self): + if self.after is not None: + try: + self.after(self._current_error) + except: + log.exception('Calling the after function failed.') + + def stop(self): + self._end.set() + self._resumed.set() + + def pause(self): + self._resumed.clear() + + def resume(self): + self.loops = 0 + self._start = time.time() + self._resumed.set() + + def is_playing(self): + return self._resumed.is_set() and not self._end.is_set() + + def is_paused(self): + return not self._end.is_set() and not self._resumed.is_set() + + def _set_source(self, source): + with self._lock: + self.pause() + self.source = source + self.resume() diff --git a/discord.py-rewrite/discord/reaction.py b/discord.py-rewrite/discord/reaction.py new file mode 100644 index 0000000..5c7c362 --- /dev/null +++ b/discord.py-rewrite/discord/reaction.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from .iterators import ReactionIterator + +class Reaction: + """Represents a reaction to a message. + + Depending on the way this object was created, some of the attributes can + have a value of ``None``. + + .. container:: operations + + .. describe:: x == y + + Checks if two reactions are equal. This works by checking if the emoji + is the same. So two messages with the same reaction will be considered + "equal". + + .. describe:: x != y + + Checks if two reactions are not equal. + + .. describe:: hash(x) + + Returns the reaction's hash. + + .. describe:: str(x) + + Returns the string form of the reaction's emoji. + + Attributes + ----------- + emoji: :class:`Emoji` or str + The reaction emoji. May be a custom emoji, or a unicode emoji. + count: int + Number of times this reaction was made + me: bool + If the user sent this reaction. + message: :class:`Message` + Message this reaction is for. + """ + __slots__ = ('message', 'count', 'emoji', 'me') + + def __init__(self, *, message, data, emoji=None): + self.message = message + self.emoji = emoji or message._state.get_reaction_emoji(data['emoji']) + self.count = data.get('count', 1) + self.me = data.get('me') + + @property + def custom_emoji(self): + """bool: If this is a custom emoji.""" + return not isinstance(self.emoji, str) + + def __eq__(self, other): + return isinstance(other, self.__class__) and other.emoji == self.emoji + + def __ne__(self, other): + if isinstance(other, self.__class__): + return other.emoji != self.emoji + return True + + def __hash__(self): + return hash(self.emoji) + + def __str__(self): + return str(self.emoji) + + def __repr__(self): + return ''.format(self) + + def users(self, limit=None, after=None): + """|coro| + + Returns an :class:`AsyncIterator` representing the + users that have reacted to the message. + + The ``after`` parameter must represent a member + and meet the :class:`abc.Snowflake` abc. + + Parameters + ------------ + limit: int + The maximum number of results to return. + If not provided, returns all the users who + reacted to the message. + after: :class:`abc.Snowflake` + For pagination, reactions are sorted by member. + + Raises + -------- + HTTPException + Getting the users for the reaction failed. + + Examples + --------- + + Usage :: + + # I do not actually recommend doing this. + async for user in reaction.users(): + await channel.send('{0} has reacted with {1.emoji}!'.format(user, reaction)) + + Flattening into a list: :: + + users = await reaction.users().flatten() + # users is now a list... + winner = random.choice(users) + await channel.send('{} has won the raffle.'.format(winner)) + + Python 3.4 Usage :: + + iterator = reaction.users() + while True: + try: + user = yield from iterator.next() + except discord.NoMoreItems: + break + else: + await channel.send('{0} has reacted with {1.emoji}!'.format(user, reaction)) + + Yields + -------- + Union[:class:`User`, :class:`Member`] + The member (if retrievable) or the user that has reacted + to this message. The case where it can be a :class:`Member` is + in a guild message context. Sometimes it can be a :class:`User` + if the member has left the guild. + """ + + if self.custom_emoji: + emoji = '{0.name}:{0.id}'.format(self.emoji) + else: + emoji = self.emoji + + if limit is None: + limit = self.count + + return ReactionIterator(self.message, emoji, limit, after) diff --git a/discord.py-rewrite/discord/relationship.py b/discord.py-rewrite/discord/relationship.py new file mode 100644 index 0000000..575627e --- /dev/null +++ b/discord.py-rewrite/discord/relationship.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from .enums import RelationshipType, try_enum + +import asyncio + +class Relationship: + """Represents a relationship in Discord. + + A relationship is like a friendship, a person who is blocked, etc. + Only non-bot accounts can have relationships. + + Attributes + ----------- + user: :class:`User` + The user you have the relationship with. + type: :class:`RelationshipType` + The type of relationship you have. + """ + + __slots__ = ('type', 'user', '_state') + + def __init__(self, *, state, data): + self._state = state + self.type = try_enum(RelationshipType, data['type']) + self.user = state.store_user(data['user']) + + def __repr__(self): + return ''.format(self) + + @asyncio.coroutine + def delete(self): + """|coro| + + Deletes the relationship. + + Raises + ------ + HTTPException + Deleting the relationship failed. + """ + + yield from self._state.http.remove_relationship(self.user.id) + + @asyncio.coroutine + def accept(self): + """|coro| + + Accepts the relationship request. e.g. accepting a + friend request. + + Raises + ------- + HTTPException + Accepting the relationship failed. + """ + + yield from self._state.http.add_relationship(self.user.id) diff --git a/discord.py-rewrite/discord/role.py b/discord.py-rewrite/discord/role.py new file mode 100644 index 0000000..c4ccbf9 --- /dev/null +++ b/discord.py-rewrite/discord/role.py @@ -0,0 +1,284 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import asyncio + +from .permissions import Permissions +from .errors import InvalidArgument +from .colour import Colour +from .mixins import Hashable +from .utils import snowflake_time + +class Role(Hashable): + """Represents a Discord role in a :class:`Guild`. + + .. container:: operations + + .. describe:: x == y + + Checks if two roles are equal. + + .. describe:: x != y + + Checks if two roles are not equal. + + .. describe:: x > y + + Checks if a role is higher than another in the hierarchy. + + .. describe:: x < y + + Checks if a role is lower than another in the hierarchy. + + .. describe:: x >= y + + Checks if a role is higher or equal to another in the hierarchy. + + .. describe:: x <= y + + Checks if a role is lower or equal to another in the hierarchy. + + .. describe:: hash(x) + + Return the role's hash. + + .. describe:: str(x) + + Returns the role's name. + + Attributes + ---------- + id: int + The ID for the role. + name: str + The name of the role. + permissions: :class:`Permissions` + Represents the role's permissions. + guild: :class:`Guild` + The guild the role belongs to. + colour: :class:`Colour` + Represents the role colour. An alias exists under ``color``. + hoist: bool + Indicates if the role will be displayed separately from other members. + position: int + The position of the role. This number is usually positive. The bottom + role has a position of 0. + managed: bool + Indicates if the role is managed by the guild through some form of + integrations such as Twitch. + mentionable: bool + Indicates if the role can be mentioned by users. + """ + + __slots__ = ('id', 'name', 'permissions', 'color', 'colour', 'position', + 'managed', 'mentionable', 'hoist', 'guild', '_state' ) + + def __init__(self, *, guild, state, data): + self.guild = guild + self._state = state + self.id = int(data['id']) + self._update(data) + + def __str__(self): + return self.name + + def __repr__(self): + return ''.format(self) + + def __lt__(self, other): + if not isinstance(other, Role) or not isinstance(self, Role): + return NotImplemented + + if self.guild != other.guild: + raise RuntimeError('cannot compare roles from two different guilds.') + + if self.position < other.position: + return True + + if self.position == other.position: + return int(self.id) > int(other.id) + + return False + + def __le__(self, other): + r = Role.__lt__(other, self) + if r is NotImplemented: + return NotImplemented + return not r + + def __gt__(self, other): + return Role.__lt__(other, self) + + def __ge__(self, other): + r = Role.__lt__(self, other) + if r is NotImplemented: + return NotImplemented + return not r + + def _update(self, data): + self.name = data['name'] + self.permissions = Permissions(data.get('permissions', 0)) + self.position = data.get('position', 0) + self.colour = Colour(data.get('color', 0)) + self.hoist = data.get('hoist', False) + self.managed = data.get('managed', False) + self.mentionable = data.get('mentionable', False) + self.color = self.colour + + def is_default(self): + """Checks if the role is the default role.""" + return self.guild.id == self.id + + @property + def created_at(self): + """Returns the role's creation time in UTC.""" + return snowflake_time(self.id) + + @property + def mention(self): + """Returns a string that allows you to mention a role.""" + return '<@&%s>' % self.id + + @property + def members(self): + """Returns a list of :class:`Member` with this role.""" + all_members = self.guild.members + if self.is_default(): + return all_members + + return [member for member in all_members if self in member.roles] + + @asyncio.coroutine + def _move(self, position, reason): + if position <= 0: + raise InvalidArgument("Cannot move role to position 0 or below") + + if self.is_default(): + raise InvalidArgument("Cannot move default role") + + if self.position == position: + return # Save discord the extra request. + + http = self._state.http + + change_range = range(min(self.position, position), max(self.position, position) + 1) + sorted_roles = sorted((x for x in self.guild.roles if x.position in change_range and x.id != self.id), + key=lambda x: x.position) + + roles = [r.id for r in sorted_roles] + + if self.position > position: + roles.insert(0, self.id) + else: + roles.append(self.id) + + payload = [{"id": z[0], "position": z[1]} for z in zip(roles, change_range)] + yield from http.move_role_position(self.guild.id, payload, reason=reason) + + @asyncio.coroutine + def edit(self, *, reason=None, **fields): + """|coro| + + Edits the role. + + You must have the :attr:`Permissions.manage_roles` permission to + use this. + + All fields are optional. + + Parameters + ----------- + name: str + The new role name to change to. + permissions: :class:`Permissions` + The new permissions to change to. + colour: :class:`Colour` + The new colour to change to. (aliased to color as well) + hoist: bool + Indicates if the role should be shown separately in the member list. + mentionable: bool + Indicates if the role should be mentionable by others. + position: int + The new role's position. This must be below your top role's + position or it will fail. + reason: Optional[str] + The reason for editing this role. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have permissions to change the role. + HTTPException + Editing the role failed. + InvalidArgument + An invalid position was given or the default + role was asked to be moved. + """ + + position = fields.get('position') + if position is not None: + yield from self._move(position, reason=reason) + self.position = position + + try: + colour = fields['colour'] + except KeyError: + colour = fields.get('color', self.colour) + + payload = { + 'name': fields.get('name', self.name), + 'permissions': fields.get('permissions', self.permissions).value, + 'color': colour.value, + 'hoist': fields.get('hoist', self.hoist), + 'mentionable': fields.get('mentionable', self.mentionable) + } + + data = yield from self._state.http.edit_role(self.guild.id, self.id, reason=reason, **payload) + self._update(data) + + @asyncio.coroutine + def delete(self, *, reason=None): + """|coro| + + Deletes the role. + + You must have the :attr:`Permissions.manage_roles` permission to + use this. + + Parameters + ----------- + reason: Optional[str] + The reason for deleting this role. Shows up on the audit log. + + Raises + -------- + Forbidden + You do not have permissions to delete the role. + HTTPException + Deleting the role failed. + """ + + yield from self._state.http.delete_role(self.guild.id, self.id, reason=reason) diff --git a/discord.py-rewrite/discord/shard.py b/discord.py-rewrite/discord/shard.py new file mode 100644 index 0000000..8946305 --- /dev/null +++ b/discord.py-rewrite/discord/shard.py @@ -0,0 +1,346 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from .state import AutoShardedConnectionState +from .client import Client +from .gateway import * +from .errors import ClientException, InvalidArgument +from . import compat +from .enums import Status + +import asyncio +import logging +import websockets +import itertools + +log = logging.getLogger(__name__) + +class Shard: + def __init__(self, ws, client): + self.ws = ws + self._client = client + self.loop = self._client.loop + self._current = compat.create_future(self.loop) + self._current.set_result(None) # we just need an already done future + + @property + def id(self): + return self.ws.shard_id + + @asyncio.coroutine + def poll(self): + try: + yield from self.ws.poll_event() + except ResumeWebSocket as e: + log.info('Got a request to RESUME the websocket at Shard ID %s.', self.id) + coro = DiscordWebSocket.from_client(self._client, resume=True, + shard_id=self.id, + session=self.ws.session_id, + sequence=self.ws.sequence) + self.ws = yield from asyncio.wait_for(coro, timeout=180.0, loop=self.loop) + + def get_future(self): + if self._current.done(): + self._current = compat.create_task(self.poll(), loop=self.loop) + + return self._current + +@asyncio.coroutine +def _ensure_coroutine_connect(gateway, loop): + # In 3.5+ websockets.connect does not return a coroutine, but an awaitable. + # The problem is that in 3.5.0 and in some cases 3.5.1, asyncio.ensure_future and + # by proxy, asyncio.wait_for, do not accept awaitables, but rather futures or coroutines. + # By wrapping it up into this function we ensure that it's in a coroutine and not an awaitable + # even for 3.5.0 users. + ws = yield from websockets.connect(gateway, loop=loop, klass=DiscordWebSocket) + return ws + +class AutoShardedClient(Client): + """A client similar to :class:`Client` except it handles the complications + of sharding for the user into a more manageable and transparent single + process bot. + + When using this client, you will be able to use it as-if it was a regular + :class:`Client` with a single shard when implementation wise internally it + is split up into multiple shards. This allows you to not have to deal with + IPC or other complicated infrastructure. + + It is recommended to use this client only if you have surpassed at least + 1000 guilds. + + If no :attr:`shard_count` is provided, then the library will use the + Bot Gateway endpoint call to figure out how many shards to use. + + If a ``shard_ids`` parameter is given, then those shard IDs will be used + to launch the internal shards. Note that :attr:`shard_count` must be provided + if this is used. By default, when omitted, the client will launch shards from + 0 to ``shard_count - 1``. + + Attributes + ------------ + shard_ids: Optional[List[int]] + An optional list of shard_ids to launch the shards with. + """ + def __init__(self, *args, loop=None, **kwargs): + kwargs.pop('shard_id', None) + self.shard_ids = kwargs.pop('shard_ids', None) + super().__init__(*args, loop=loop, **kwargs) + + if self.shard_ids is not None: + if self.shard_count is None: + raise ClientException('When passing manual shard_ids, you must provide a shard_count.') + elif not isinstance(self.shard_ids, (list, tuple)): + raise ClientException('shard_ids parameter must be a list or a tuple.') + + self._connection = AutoShardedConnectionState(dispatch=self.dispatch, chunker=self._chunker, + syncer=self._syncer, http=self.http, loop=self.loop, **kwargs) + + # instead of a single websocket, we have multiple + # the key is the shard_id + self.shards = {} + + def _get_websocket(guild_id): + i = (guild_id >> 22) % self.shard_count + return self.shards[i].ws + + self._connection._get_websocket = _get_websocket + self._still_sharding = True + + @asyncio.coroutine + def _chunker(self, guild, *, shard_id=None): + try: + guild_id = guild.id + shard_id = shard_id or guild.shard_id + except AttributeError: + guild_id = [s.id for s in guild] + + payload = { + 'op': 8, + 'd': { + 'guild_id': guild_id, + 'query': '', + 'limit': 0 + } + } + + ws = self.shards[shard_id].ws + yield from ws.send_as_json(payload) + + @property + def latency(self): + """float: Measures latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds. + + This operates similarly to :meth:`.Client.latency` except it uses the average + latency of every shard's latency. To get a list of shard latency, check the + :attr:`latencies` property. + """ + return sum(latency for _, latency in self.latencies) / len(self.shards) + + @property + def latencies(self): + """List[Tuple[int, float]]: A list of latencies between a HEARTBEAT and a HEARTBEAT_ACK in seconds. + + This returns a list of tuples with elements ``(shard_id, latency)``. + """ + return [(shard_id, shard.ws.latency) for shard_id, shard in self.shards.items()] + + @asyncio.coroutine + def request_offline_members(self, *guilds): + """|coro| + + Requests previously offline members from the guild to be filled up + into the :attr:`Guild.members` cache. This function is usually not + called. It should only be used if you have the ``fetch_offline_members`` + parameter set to ``False``. + + When the client logs on and connects to the websocket, Discord does + not provide the library with offline members if the number of members + in the guild is larger than 250. You can check if a guild is large + if :attr:`Guild.large` is ``True``. + + Parameters + ----------- + \*guilds + An argument list of guilds to request offline members for. + + Raises + ------- + InvalidArgument + If any guild is unavailable or not large in the collection. + """ + if any(not g.large or g.unavailable for g in guilds): + raise InvalidArgument('An unavailable or non-large guild was passed.') + + _guilds = sorted(guilds, key=lambda g: g.shard_id) + for shard_id, sub_guilds in itertools.groupby(_guilds, key=lambda g: g.shard_id): + sub_guilds = list(sub_guilds) + yield from self._connection.request_offline_members(sub_guilds, shard_id=shard_id) + + @asyncio.coroutine + def pending_reads(self, shard): + try: + while self._still_sharding: + yield from shard.poll() + except asyncio.CancelledError: + pass + + @asyncio.coroutine + def launch_shard(self, gateway, shard_id): + try: + ws = yield from asyncio.wait_for(_ensure_coroutine_connect(gateway, self.loop), loop=self.loop, timeout=180.0) + except Exception as e: + log.info('Failed to connect for shard_id: %s. Retrying...', shard_id) + yield from asyncio.sleep(5.0, loop=self.loop) + return (yield from self.launch_shard(gateway, shard_id)) + + ws.token = self.http.token + ws._connection = self._connection + ws._dispatch = self.dispatch + ws.gateway = gateway + ws.shard_id = shard_id + ws.shard_count = self.shard_count + ws._max_heartbeat_timeout = self._connection.heartbeat_timeout + + try: + # OP HELLO + yield from asyncio.wait_for(ws.poll_event(), loop=self.loop, timeout=180.0) + yield from asyncio.wait_for(ws.identify(), loop=self.loop, timeout=180.0) + except asyncio.TimeoutError: + log.info('Timed out when connecting for shard_id: %s. Retrying...', shard_id) + yield from asyncio.sleep(5.0, loop=self.loop) + return (yield from self.launch_shard(gateway, shard_id)) + + # keep reading the shard while others connect + self.shards[shard_id] = ret = Shard(ws, self) + compat.create_task(self.pending_reads(ret), loop=self.loop) + yield from asyncio.sleep(5.0, loop=self.loop) + + @asyncio.coroutine + def launch_shards(self): + if self.shard_count is None: + self.shard_count, gateway = yield from self.http.get_bot_gateway() + else: + gateway = yield from self.http.get_gateway() + + self._connection.shard_count = self.shard_count + + shard_ids = self.shard_ids if self.shard_ids else range(self.shard_count) + + for shard_id in shard_ids: + yield from self.launch_shard(gateway, shard_id) + + self._still_sharding = False + + @asyncio.coroutine + def _connect(self): + yield from self.launch_shards() + + while True: + pollers = [shard.get_future() for shard in self.shards.values()] + done, pending = yield from asyncio.wait(pollers, loop=self.loop, return_when=asyncio.FIRST_COMPLETED) + for f in done: + # we wanna re-raise to the main Client.connect handler if applicable + f.result() + + @asyncio.coroutine + def close(self): + """|coro| + + Closes the connection to discord. + """ + if self.is_closed(): + return + + self._closed.set() + + for vc in self.voice_clients: + try: + yield from vc.disconnect() + except: + pass + + to_close = [shard.ws.close() for shard in self.shards.values()] + yield from asyncio.wait(to_close, loop=self.loop) + yield from self.http.close() + + @asyncio.coroutine + def change_presence(self, *, game=None, status=None, afk=False, shard_id=None): + """|coro| + + Changes the client's presence. + + The game parameter is a Game object (not a string) that represents + a game being played currently. + + Parameters + ---------- + game: Optional[:class:`Game`] + The game being played. None if no game is being played. + status: Optional[:class:`Status`] + Indicates what status to change to. If None, then + :attr:`Status.online` is used. + afk: bool + Indicates if you are going AFK. This allows the discord + client to know how to handle push notifications better + for you in case you are actually idle and not lying. + shard_id: Optional[int] + The shard_id to change the presence to. If not specified + or ``None``, then it will change the presence of every + shard the bot can see. + + Raises + ------ + InvalidArgument + If the ``game`` parameter is not :class:`Game` or None. + """ + + if status is None: + status = 'online' + status_enum = Status.online + elif status is Status.offline: + status = 'invisible' + status_enum = Status.offline + else: + status_enum = status + status = str(status) + + if shard_id is None: + for shard in self.shards.values(): + yield from shard.ws.change_presence(game=game, status=status, afk=afk) + + guilds = self._connection.guilds + else: + shard = self.shards[shard_id] + yield from shard.ws.change_presence(game=game, status=status, afk=afk) + guilds = [g for g in self._connection.guilds if g.shard_id == shard_id] + + for guild in guilds: + me = guild.me + if me is None: + continue + + me.game = game + me.status = status_enum diff --git a/discord.py-rewrite/discord/state.py b/discord.py-rewrite/discord/state.py new file mode 100644 index 0000000..b145c7e --- /dev/null +++ b/discord.py-rewrite/discord/state.py @@ -0,0 +1,955 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from .guild import Guild +from .user import User, ClientUser +from .emoji import Emoji, PartialReactionEmoji +from .message import Message +from .relationship import Relationship +from .channel import * +from .member import Member +from .role import Role +from .enums import ChannelType, try_enum, Status +from .calls import GroupCall +from . import utils, compat +from .embeds import Embed + +from collections import deque, namedtuple, OrderedDict +import copy, enum, math +import datetime +import asyncio +import logging +import weakref +import itertools + +class ListenerType(enum.Enum): + chunk = 0 + +Listener = namedtuple('Listener', ('type', 'future', 'predicate')) +log = logging.getLogger(__name__) +ReadyState = namedtuple('ReadyState', ('launch', 'guilds')) + +class ConnectionState: + def __init__(self, *, dispatch, chunker, syncer, http, loop, **options): + self.loop = loop + self.http = http + self.max_messages = max(options.get('max_messages', 5000), 100) + self.dispatch = dispatch + self.chunker = chunker + self.syncer = syncer + self.is_bot = None + self.shard_count = None + self._ready_task = None + self._fetch_offline = options.get('fetch_offline_members', True) + self.heartbeat_timeout = options.get('heartbeat_timeout', 60.0) + self._listeners = [] + + game = options.get('game', None) + if game: + game = dict(game) + + status = options.get('status', None) + if status: + if status is Status.offline: + status = 'invisible' + else: + status = str(status) + + self._game = game + self._status = status + + self.clear() + + def clear(self): + self.user = None + self._users = weakref.WeakValueDictionary() + self._emojis = {} + self._calls = {} + self._guilds = {} + self._voice_clients = {} + + # LRU of max size 128 + self._private_channels = OrderedDict() + # extra dict to look up private channels by user id + self._private_channels_by_user = {} + self._messages = deque(maxlen=self.max_messages) + + def process_listeners(self, listener_type, argument, result): + removed = [] + for i, listener in enumerate(self._listeners): + if listener.type != listener_type: + continue + + future = listener.future + if future.cancelled(): + removed.append(i) + continue + + try: + passed = listener.predicate(argument) + except Exception as e: + future.set_exception(e) + removed.append(i) + else: + if passed: + future.set_result(result) + removed.append(i) + if listener.type == ListenerType.chunk: + break + + for index in reversed(removed): + del self._listeners[index] + + @property + def self_id(self): + u = self.user + return u.id if u else None + + @property + def voice_clients(self): + return list(self._voice_clients.values()) + + def _get_voice_client(self, guild_id): + return self._voice_clients.get(guild_id) + + def _add_voice_client(self, guild_id, voice): + self._voice_clients[guild_id] = voice + + def _remove_voice_client(self, guild_id): + self._voice_clients.pop(guild_id, None) + + def _update_references(self, ws): + for vc in self.voice_clients: + vc.main_ws = ws + + def store_user(self, data): + # this way is 300% faster than `dict.setdefault`. + user_id = int(data['id']) + try: + return self._users[user_id] + except KeyError: + self._users[user_id] = user = User(state=self, data=data) + return user + + def get_user(self, id): + return self._users.get(id) + + def store_emoji(self, guild, data): + emoji_id = int(data['id']) + self._emojis[emoji_id] = emoji = Emoji(guild=guild, state=self, data=data) + return emoji + + @property + def guilds(self): + return list(self._guilds.values()) + + def _get_guild(self, guild_id): + return self._guilds.get(guild_id) + + def _add_guild(self, guild): + self._guilds[guild.id] = guild + + def _remove_guild(self, guild): + self._guilds.pop(guild.id, None) + + for emoji in guild.emojis: + self._emojis.pop(emoji.id, None) + + del guild + + @property + def emojis(self): + return list(self._emojis.values()) + + def get_emoji(self, emoji_id): + return self._emojis.get(emoji_id) + + @property + def private_channels(self): + return list(self._private_channels.values()) + + def _get_private_channel(self, channel_id): + try: + value = self._private_channels[channel_id] + except KeyError: + return None + else: + self._private_channels.move_to_end(channel_id) + return value + + def _get_private_channel_by_user(self, user_id): + return self._private_channels_by_user.get(user_id) + + def _add_private_channel(self, channel): + channel_id = channel.id + self._private_channels[channel_id] = channel + + if len(self._private_channels) > 128: + _, to_remove = self._private_channels.popitem(last=False) + if isinstance(to_remove, DMChannel): + self._private_channels_by_user.pop(to_remove.recipient.id, None) + + if isinstance(channel, DMChannel): + self._private_channels_by_user[channel.recipient.id] = channel + + def add_dm_channel(self, data): + channel = DMChannel(me=self.user, state=self, data=data) + self._add_private_channel(channel) + return channel + + def _remove_private_channel(self, channel): + self._private_channels.pop(channel.id, None) + if isinstance(channel, DMChannel): + self._private_channels_by_user.pop(channel.recipient.id, None) + + def _get_message(self, msg_id): + return utils.find(lambda m: m.id == msg_id, self._messages) + + def _add_guild_from_data(self, guild): + guild = Guild(data=guild, state=self) + self._add_guild(guild) + return guild + + def chunks_needed(self, guild): + for chunk in range(math.ceil(guild._member_count / 1000)): + yield self.receive_chunk(guild.id) + + @asyncio.coroutine + def request_offline_members(self, guilds): + # get all the chunks + chunks = [] + for guild in guilds: + chunks.extend(self.chunks_needed(guild)) + + # we only want to request ~75 guilds per chunk request. + splits = [guilds[i:i + 75] for i in range(0, len(guilds), 75)] + for split in splits: + yield from self.chunker(split) + + # wait for the chunks + if chunks: + try: + yield from utils.sane_wait_for(chunks, timeout=len(chunks) * 30.0, loop=self.loop) + except asyncio.TimeoutError: + log.info('Somehow timed out waiting for chunks.') + + @asyncio.coroutine + def _delay_ready(self): + try: + launch = self._ready_state.launch + + # only real bots wait for GUILD_CREATE streaming + if self.is_bot: + while not launch.is_set(): + # this snippet of code is basically waiting 2 seconds + # until the last GUILD_CREATE was sent + launch.set() + yield from asyncio.sleep(2, loop=self.loop) + + guilds = self._ready_state.guilds + if self._fetch_offline: + yield from self.request_offline_members(guilds) + + # remove the state + try: + del self._ready_state + except AttributeError: + pass # already been deleted somehow + + # call GUILD_SYNC after we're done chunking + if not self.is_bot: + log.info('Requesting GUILD_SYNC for %s guilds', len(self.guilds)) + yield from self.syncer([s.id for s in self.guilds]) + except asyncio.CancelledError: + pass + else: + # dispatch the event + self.dispatch('ready') + finally: + self._ready_task = None + + def parse_ready(self, data): + if self._ready_task is not None: + self._ready_task.cancel() + + self._ready_state = ReadyState(launch=asyncio.Event(), guilds=[]) + self.clear() + self.user = ClientUser(state=self, data=data['user']) + + guilds = self._ready_state.guilds + for guild_data in data['guilds']: + guild = self._add_guild_from_data(guild_data) + if (not self.is_bot and not guild.unavailable) or guild.large: + guilds.append(guild) + + for relationship in data.get('relationships', []): + try: + r_id = int(relationship['id']) + except KeyError: + continue + else: + self.user._relationships[r_id] = Relationship(state=self, data=relationship) + + for pm in data.get('private_channels', []): + factory, _ = _channel_factory(pm['type']) + self._add_private_channel(factory(me=self.user, data=pm, state=self)) + + self.dispatch('connect') + self._ready_task = compat.create_task(self._delay_ready(), loop=self.loop) + + def parse_resumed(self, data): + self.dispatch('resumed') + + def parse_message_create(self, data): + channel = self.get_channel(int(data['channel_id'])) + message = Message(channel=channel, data=data, state=self) + self.dispatch('message', message) + self._messages.append(message) + + def parse_message_delete(self, data): + message_id = int(data['id']) + channel_id = int(data['channel_id']) + self.dispatch('raw_message_delete', message_id, channel_id) + + found = self._get_message(message_id) + if found is not None: + self.dispatch('message_delete', found) + self._messages.remove(found) + + def parse_message_delete_bulk(self, data): + message_ids = { int(x) for x in data.get('ids', []) } + channel_id = int(data['channel_id']) + self.dispatch('raw_bulk_message_delete', message_ids, channel_id) + to_be_deleted = [message for message in self._messages if message.id in message_ids] + for msg in to_be_deleted: + self.dispatch('message_delete', msg) + self._messages.remove(msg) + + def parse_message_update(self, data): + message_id = int(data['id']) + self.dispatch('raw_message_edit', message_id, data) + message = self._get_message(message_id) + if message is not None: + older_message = copy.copy(message) + if 'call' in data: + # call state message edit + message._handle_call(data['call']) + elif 'content' not in data: + # embed only edit + message.embeds = [Embed.from_data(d) for d in data['embeds']] + else: + message._update(channel=message.channel, data=data) + + self.dispatch('message_edit', older_message, message) + + def parse_message_reaction_add(self, data): + message_id = int(data['message_id']) + user_id = int(data['user_id']) + channel_id = int(data['channel_id']) + + emoji_data = data['emoji'] + emoji_id = utils._get_as_snowflake(emoji_data, 'id') + emoji = PartialReactionEmoji(id=emoji_id, name=emoji_data['name']) + self.dispatch('raw_reaction_add', emoji, message_id, channel_id, user_id) + + # rich interface here + message = self._get_message(message_id) + if message is not None: + emoji = self._upgrade_partial_emoji(emoji) + reaction = message._add_reaction(data, emoji, user_id) + user = self._get_reaction_user(message.channel, user_id) + if user: + self.dispatch('reaction_add', reaction, user) + + def parse_message_reaction_remove_all(self, data): + message_id = int(data['message_id']) + channel_id = int(data['channel_id']) + self.dispatch('raw_reaction_clear', message_id, channel_id) + + message = self._get_message(message_id) + if message is not None: + old_reactions = message.reactions.copy() + message.reactions.clear() + self.dispatch('reaction_clear', message, old_reactions) + + def parse_message_reaction_remove(self, data): + message_id = int(data['message_id']) + user_id = int(data['user_id']) + channel_id = int(data['channel_id']) + + emoji_data = data['emoji'] + emoji_id = utils._get_as_snowflake(emoji_data, 'id') + emoji = PartialReactionEmoji(id=emoji_id, name=emoji_data['name']) + self.dispatch('raw_reaction_remove', emoji, message_id, channel_id, user_id) + + message = self._get_message(message_id) + if message is not None: + emoji = self._upgrade_partial_emoji(emoji) + try: + reaction = message._remove_reaction(data, emoji, user_id) + except (AttributeError, ValueError) as e: # eventual consistency lol + pass + else: + user = self._get_reaction_user(message.channel, user_id) + if user: + self.dispatch('reaction_remove', reaction, user) + + def parse_presence_update(self, data): + guild_id = utils._get_as_snowflake(data, 'guild_id') + guild = self._get_guild(guild_id) + if guild is None: + log.warning('PRESENCE_UPDATE referencing an unknown guild ID: %s. Discarding.', guild_id) + return + + user = data['user'] + member_id = int(user['id']) + member = guild.get_member(member_id) + if member is None: + if 'username' not in user: + # sometimes we receive 'incomplete' member data post-removal. + # skip these useless cases. + return + + member = Member(guild=guild, data=data, state=self) + guild._add_member(member) + + old_member = member._copy() + member._presence_update(data=data, user=user) + self.dispatch('member_update', old_member, member) + + def parse_user_update(self, data): + self.user = ClientUser(state=self, data=data) + + def parse_channel_delete(self, data): + guild = self._get_guild(utils._get_as_snowflake(data, 'guild_id')) + channel_id = int(data['id']) + if guild is not None: + channel = guild.get_channel(channel_id) + if channel is not None: + guild._remove_channel(channel) + self.dispatch('guild_channel_delete', channel) + else: + # the reason we're doing this is so it's also removed from the + # private channel by user cache as well + channel = self._get_private_channel(channel_id) + if channel is not None: + self._remove_private_channel(channel) + self.dispatch('private_channel_delete', channel) + + def parse_channel_update(self, data): + channel_type = try_enum(ChannelType, data.get('type')) + channel_id = int(data['id']) + if channel_type is ChannelType.group: + channel = self._get_private_channel(channel_id) + old_channel = copy.copy(channel) + channel._update_group(data) + self.dispatch('private_channel_update', old_channel, channel) + return + + guild_id = utils._get_as_snowflake(data, 'guild_id') + guild = self._get_guild(guild_id) + if guild is not None: + channel = guild.get_channel(channel_id) + if channel is not None: + old_channel = copy.copy(channel) + channel._update(guild, data) + self.dispatch('guild_channel_update', old_channel, channel) + else: + log.warning('CHANNEL_UPDATE referencing an unknown channel ID: %s. Discarding.', channel_id) + else: + log.warning('CHANNEL_UPDATE referencing an unknown guild ID: %s. Discarding.', guild_id) + + def parse_channel_create(self, data): + factory, ch_type = _channel_factory(data['type']) + if factory is None: + log.warning('CHANNEL_CREATE referencing an unknown channel type %s. Discarding.', data['type']) + return + + channel = None + + if ch_type in (ChannelType.group, ChannelType.private): + channel_id = int(data['id']) + if self._get_private_channel(channel_id) is None: + channel = factory(me=self.user, data=data, state=self) + self._add_private_channel(channel) + self.dispatch('private_channel_create', channel) + else: + guild_id = utils._get_as_snowflake(data, 'guild_id') + guild = self._get_guild(guild_id) + if guild is not None: + channel = factory(guild=guild, state=self, data=data) + guild._add_channel(channel) + self.dispatch('guild_channel_create', channel) + else: + log.warning('CHANNEL_CREATE referencing an unknown guild ID: %s. Discarding.', guild_id) + return + + + def parse_channel_pins_update(self, data): + channel_id = int(data['channel_id']) + channel = self.get_channel(channel_id) + if channel is None: + log.warning('CHANNEL_PINS_UPDATE referencing an unknown channel ID: %s. Discarding.', channel_id) + return + + last_pin = utils.parse_time(data['last_pin_timestamp']) if data['last_pin_timestamp'] else None + + try: + # I have not imported discord.abc in this file + # the isinstance check is also 2x slower than just checking this attribute + # so we're just gonna check it since it's easier and faster and lazier + channel.guild + except AttributeError: + self.dispatch('private_channel_pins_update', channel, last_pin) + else: + self.dispatch('guild_channel_pins_update', channel, last_pin) + + def parse_channel_recipient_add(self, data): + channel = self._get_private_channel(int(data['channel_id'])) + user = self.store_user(data['user']) + channel.recipients.append(user) + self.dispatch('group_join', channel, user) + + def parse_channel_recipient_remove(self, data): + channel = self._get_private_channel(int(data['channel_id'])) + user = self.store_user(data['user']) + try: + channel.recipients.remove(user) + except ValueError: + pass + else: + self.dispatch('group_remove', channel, user) + + def parse_guild_member_add(self, data): + guild = self._get_guild(int(data['guild_id'])) + if guild is None: + log.warning('GUILD_MEMBER_ADD referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + return + + member = Member(guild=guild, data=data, state=self) + guild._add_member(member) + guild._member_count += 1 + self.dispatch('member_join', member) + + def parse_guild_member_remove(self, data): + guild = self._get_guild(int(data['guild_id'])) + if guild is not None: + user_id = int(data['user']['id']) + member = guild.get_member(user_id) + if member is not None: + guild._remove_member(member) + guild._member_count -= 1 + self.dispatch('member_remove', member) + else: + log.warning('GUILD_MEMBER_REMOVE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + + def parse_guild_member_update(self, data): + guild = self._get_guild(int(data['guild_id'])) + user = data['user'] + user_id = int(user['id']) + if guild is None: + log.warning('GUILD_MEMBER_UPDATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + return + + member = guild.get_member(user_id) + if member is not None: + old_member = copy.copy(member) + member._update(data, user) + self.dispatch('member_update', old_member, member) + else: + log.warning('GUILD_MEMBER_UPDATE referencing an unknown member ID: %s. Discarding.', user_id) + + def parse_guild_emojis_update(self, data): + guild = self._get_guild(int(data['guild_id'])) + if guild is None: + log.warning('GUILD_EMOJIS_UPDATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + return + + before_emojis = guild.emojis + guild.emojis = tuple(map(lambda d: self.store_emoji(guild, d), data['emojis'])) + self.dispatch('guild_emojis_update', guild, before_emojis, guild.emojis) + + def _get_create_guild(self, data): + if data.get('unavailable') == False: + # GUILD_CREATE with unavailable in the response + # usually means that the guild has become available + # and is therefore in the cache + guild = self._get_guild(int(data['id'])) + if guild is not None: + guild.unavailable = False + guild._from_data(data) + return guild + + return self._add_guild_from_data(data) + + @asyncio.coroutine + def _chunk_and_dispatch(self, guild, unavailable): + chunks = list(self.chunks_needed(guild)) + yield from self.chunker(guild) + if chunks: + try: + yield from utils.sane_wait_for(chunks, timeout=len(chunks), loop=self.loop) + except asyncio.TimeoutError: + log.info('Somehow timed out waiting for chunks.') + + if unavailable == False: + self.dispatch('guild_available', guild) + else: + self.dispatch('guild_join', guild) + + def parse_guild_create(self, data): + unavailable = data.get('unavailable') + if unavailable == True: + # joined a guild with unavailable == True so.. + return + + guild = self._get_create_guild(data) + + # check if it requires chunking + if guild.large: + if unavailable == False: + # check if we're waiting for 'useful' READY + # and if we are, we don't want to dispatch any + # event such as guild_join or guild_available + # because we're still in the 'READY' phase. Or + # so we say. + try: + state = self._ready_state + state.launch.clear() + state.guilds.append(guild) + except AttributeError: + # the _ready_state attribute is only there during + # processing of useful READY. + pass + else: + return + + # since we're not waiting for 'useful' READY we'll just + # do the chunk request here if wanted + if self._fetch_offline: + compat.create_task(self._chunk_and_dispatch(guild, unavailable), loop=self.loop) + return + + # Dispatch available if newly available + if unavailable == False: + self.dispatch('guild_available', guild) + else: + self.dispatch('guild_join', guild) + + def parse_guild_sync(self, data): + guild = self._get_guild(int(data['id'])) + guild._sync(data) + + def parse_guild_update(self, data): + guild = self._get_guild(int(data['id'])) + if guild is not None: + old_guild = copy.copy(guild) + guild._from_data(data) + self.dispatch('guild_update', old_guild, guild) + else: + log.warning('GUILD_UPDATE referencing an unknown guild ID: %s. Discarding.', data['id']) + + def parse_guild_delete(self, data): + guild = self._get_guild(int(data['id'])) + if guild is None: + log.warning('GUILD_DELETE referencing an unknown guild ID: %s. Discarding.', data['id']) + return + + if data.get('unavailable', False) and guild is not None: + # GUILD_DELETE with unavailable being True means that the + # guild that was available is now currently unavailable + guild.unavailable = True + self.dispatch('guild_unavailable', guild) + return + + # do a cleanup of the messages cache + self._messages = deque((msg for msg in self._messages if msg.guild != guild), maxlen=self.max_messages) + + self._remove_guild(guild) + self.dispatch('guild_remove', guild) + + def parse_guild_ban_add(self, data): + # we make the assumption that GUILD_BAN_ADD is done + # before GUILD_MEMBER_REMOVE is called + # hence we don't remove it from cache or do anything + # strange with it, the main purpose of this event + # is mainly to dispatch to another event worth listening to for logging + guild = self._get_guild(int(data['guild_id'])) + if guild is not None: + try: + user = User(data=data['user'], state=self) + except KeyError: + pass + else: + member = guild.get_member(user.id) or user + self.dispatch('member_ban', guild, member) + + def parse_guild_ban_remove(self, data): + guild = self._get_guild(int(data['guild_id'])) + if guild is not None: + if 'user' in data: + user = self.store_user(data['user']) + self.dispatch('member_unban', guild, user) + + def parse_guild_role_create(self, data): + guild = self._get_guild(int(data['guild_id'])) + if guild is None: + log.warning('GUILD_ROLE_CREATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + return + + role_data = data['role'] + role = Role(guild=guild, data=role_data, state=self) + guild._add_role(role) + self.dispatch('guild_role_create', role) + + def parse_guild_role_delete(self, data): + guild = self._get_guild(int(data['guild_id'])) + if guild is not None: + role_id = int(data['role_id']) + role = utils.find(lambda r: r.id == role_id, guild.roles) + try: + guild._remove_role(role) + except ValueError: + return + else: + self.dispatch('guild_role_delete', role) + else: + log.warning('GUILD_ROLE_DELETE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + + def parse_guild_role_update(self, data): + guild = self._get_guild(int(data['guild_id'])) + if guild is not None: + role_data = data['role'] + role_id = int(role_data['id']) + role = utils.find(lambda r: r.id == role_id, guild.roles) + if role is not None: + old_role = copy.copy(role) + role._update(role_data) + self.dispatch('guild_role_update', old_role, role) + else: + log.warning('GUILD_ROLE_UPDATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + + def parse_guild_members_chunk(self, data): + guild_id = int(data['guild_id']) + guild = self._get_guild(guild_id) + members = data.get('members', []) + for member in members: + m = Member(guild=guild, data=member, state=self) + existing = guild.get_member(m.id) + if existing is None or existing.joined_at is None: + guild._add_member(m) + + log.info('Processed a chunk for %s members in guild ID %s.', len(members), guild_id) + self.process_listeners(ListenerType.chunk, guild, len(members)) + + def parse_voice_state_update(self, data): + guild = self._get_guild(utils._get_as_snowflake(data, 'guild_id')) + channel_id = utils._get_as_snowflake(data, 'channel_id') + if guild is not None: + if int(data['user_id']) == self.user.id: + voice = self._get_voice_client(guild.id) + if voice is not None: + ch = guild.get_channel(channel_id) + if ch is not None: + voice.channel = ch + + member, before, after = guild._update_voice_state(data, channel_id) + if member is not None: + self.dispatch('voice_state_update', member, before, after) + else: + log.warning('VOICE_STATE_UPDATE referencing an unknown member ID: %s. Discarding.', data['user_id']) + else: + # in here we're either at private or group calls + call = self._calls.get(channel_id) + if call is not None: + call._update_voice_state(data) + + def parse_voice_server_update(self, data): + try: + key_id = int(data['guild_id']) + except KeyError: + key_id = int(data['channel_id']) + + vc = self._get_voice_client(key_id) + if vc is not None: + compat.create_task(vc._create_socket(key_id, data)) + + def parse_typing_start(self, data): + channel = self.get_channel(int(data['channel_id'])) + if channel is not None: + member = None + user_id = utils._get_as_snowflake(data, 'user_id') + if isinstance(channel, DMChannel): + member = channel.recipient + elif isinstance(channel, TextChannel): + member = channel.guild.get_member(user_id) + elif isinstance(channel, GroupChannel): + member = utils.find(lambda x: x.id == user_id, channel.recipients) + + if member is not None: + timestamp = datetime.datetime.utcfromtimestamp(data.get('timestamp')) + self.dispatch('typing', channel, member, timestamp) + + def parse_relationship_add(self, data): + key = int(data['id']) + old = self.user.get_relationship(key) + new = Relationship(state=self, data=data) + self.user._relationships[key] = new + if old is not None: + self.dispatch('relationship_update', old, new) + else: + self.dispatch('relationship_add', new) + + def parse_relationship_remove(self, data): + key = int(data['id']) + try: + old = self.user._relationships.pop(key) + except KeyError: + pass + else: + self.dispatch('relationship_remove', old) + + def _get_reaction_user(self, channel, user_id): + if isinstance(channel, TextChannel): + return channel.guild.get_member(user_id) + return self.get_user(user_id) + + def get_reaction_emoji(self, data): + emoji_id = utils._get_as_snowflake(data, 'id') + + if not emoji_id: + return data['name'] + + try: + return self._emojis[emoji_id] + except KeyError: + return PartialReactionEmoji(id=emoji_id, name=data['name']) + + def _upgrade_partial_emoji(self, emoji): + emoji_id = emoji.id + if not emoji_id: + return emoji.name + try: + return self._emojis[emoji_id] + except KeyError: + return emoji + + def get_channel(self, id): + if id is None: + return None + + pm = self._get_private_channel(id) + if pm is not None: + return pm + + for guild in self.guilds: + channel = guild.get_channel(id) + if channel is not None: + return channel + + def create_message(self, *, channel, data): + return Message(state=self, channel=channel, data=data) + + def receive_chunk(self, guild_id): + future = compat.create_future(self.loop) + listener = Listener(ListenerType.chunk, future, lambda s: s.id == guild_id) + self._listeners.append(listener) + return future + +class AutoShardedConnectionState(ConnectionState): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._ready_task = None + + @asyncio.coroutine + def request_offline_members(self, guilds, *, shard_id): + # get all the chunks + chunks = [] + for guild in guilds: + chunks.extend(self.chunks_needed(guild)) + + # we only want to request ~75 guilds per chunk request. + splits = [guilds[i:i + 75] for i in range(0, len(guilds), 75)] + for split in splits: + yield from self.chunker(split, shard_id=shard_id) + + # wait for the chunks + if chunks: + try: + yield from utils.sane_wait_for(chunks, timeout=len(chunks) * 30.0, loop=self.loop) + except asyncio.TimeoutError: + log.info('Somehow timed out waiting for chunks.') + + @asyncio.coroutine + def _delay_ready(self): + launch = self._ready_state.launch + while not launch.is_set(): + # this snippet of code is basically waiting 2 seconds + # until the last GUILD_CREATE was sent + launch.set() + yield from asyncio.sleep(2.0 * self.shard_count, loop=self.loop) + + if self._fetch_offline: + guilds = sorted(self._ready_state.guilds, key=lambda g: g.shard_id) + + for shard_id, sub_guilds in itertools.groupby(guilds, key=lambda g: g.shard_id): + sub_guilds = list(sub_guilds) + yield from self.request_offline_members(sub_guilds, shard_id=shard_id) + self.dispatch('shard_ready', shard_id) + + # remove the state + try: + del self._ready_state + except AttributeError: + pass # already been deleted somehow + + # regular users cannot shard so we won't worry about it here. + + # clear the current task + self._ready_task = None + + # dispatch the event + self.dispatch('ready') + + def parse_ready(self, data): + if not hasattr(self, '_ready_state'): + self._ready_state = ReadyState(launch=asyncio.Event(), guilds=[]) + + self.user = ClientUser(state=self, data=data['user']) + + guilds = self._ready_state.guilds + for guild_data in data['guilds']: + guild = self._add_guild_from_data(guild_data) + if guild.large: + guilds.append(guild) + + for pm in data.get('private_channels', []): + factory, _ = _channel_factory(pm['type']) + self._add_private_channel(factory(me=self.user, data=pm, state=self)) + + self.dispatch('connect') + if self._ready_task is None: + self._ready_task = compat.create_task(self._delay_ready(), loop=self.loop) diff --git a/discord.py-rewrite/discord/user.py b/discord.py-rewrite/discord/user.py new file mode 100644 index 0000000..7bc5c2e --- /dev/null +++ b/discord.py-rewrite/discord/user.py @@ -0,0 +1,617 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from .utils import snowflake_time, _bytes_to_base64_data, parse_time, valid_icon_size +from .enums import DefaultAvatar, RelationshipType, UserFlags +from .errors import ClientException, InvalidArgument + +from collections import namedtuple + +import discord.abc +import asyncio + +VALID_STATIC_FORMATS = {"jpeg", "jpg", "webp", "png"} +VALID_AVATAR_FORMATS = VALID_STATIC_FORMATS | {"gif"} + +class Profile(namedtuple('Profile', 'flags user mutual_guilds connected_accounts premium_since')): + __slots__ = () + + @property + def nitro(self): + return self.premium_since is not None + + premium = nitro + + def _has_flag(self, o): + v = o.value + return (self.flags & v) == v + + @property + def staff(self): + return self._has_flag(UserFlags.staff) + + @property + def hypesquad(self): + return self._has_flag(UserFlags.hypesquad) + + @property + def partner(self): + return self._has_flag(UserFlags.partner) + + +_BaseUser = discord.abc.User + +class BaseUser(_BaseUser): + __slots__ = ('name', 'id', 'discriminator', 'avatar', 'bot', '_state') + + def __init__(self, *, state, data): + self._state = state + self.name = data['username'] + self.id = int(data['id']) + self.discriminator = data['discriminator'] + self.avatar = data['avatar'] + self.bot = data.get('bot', False) + + def __str__(self): + return '{0.name}#{0.discriminator}'.format(self) + + def __eq__(self, other): + return isinstance(other, _BaseUser) and other.id == self.id + + def __ne__(self, other): + return not self.__eq__(other) + + def __hash__(self): + return self.id >> 22 + + @property + def avatar_url(self): + """Returns a friendly URL version of the avatar the user has. + + If the user does not have a traditional avatar, their default + avatar URL is returned instead. + + This is equivalent to calling :meth:`avatar_url_as` with + the default parameters (i.e. webp/gif detection and a size of 1024). + """ + return self.avatar_url_as(format=None, size=1024) + + def is_avatar_animated(self): + """bool: Returns True if the user has an animated avatar.""" + return self.avatar and self.avatar.startswith('a_') + + def avatar_url_as(self, *, format=None, static_format='webp', size=1024): + """Returns a friendly URL version of the avatar the user has. + + If the user does not have a traditional avatar, their default + avatar URL is returned instead. + + The format must be one of 'webp', 'jpeg', 'jpg', 'png' or 'gif', and + 'gif' is only valid for animated avatars. The size must be a power of 2 + between 16 and 1024. + + Parameters + ----------- + format: Optional[str] + The format to attempt to convert the avatar to. + If the format is ``None``, then it is automatically + detected into either 'gif' or static_format depending on the + avatar being animated or not. + static_format: 'str' + Format to attempt to convert only non-animated avatars to. + Defaults to 'webp' + size: int + The size of the image to display. + + Returns + -------- + str + The resulting CDN URL. + + Raises + ------ + InvalidArgument + Bad image format passed to ``format`` or ``static_format``, or + invalid ``size``. + """ + if not valid_icon_size(size): + raise InvalidArgument("size must be a power of 2 between 16 and 1024") + if format is not None and format not in VALID_AVATAR_FORMATS: + raise InvalidArgument("format must be None or one of {}".format(VALID_AVATAR_FORMATS)) + if format == "gif" and not self.is_avatar_animated(): + raise InvalidArgument("non animated avatars do not support gif format") + if static_format not in VALID_STATIC_FORMATS: + raise InvalidArgument("static_format must be one of {}".format(VALID_STATIC_FORMATS)) + + if self.avatar is None: + return self.default_avatar_url + + if format is None: + if self.is_avatar_animated(): + format = 'gif' + else: + format = static_format + + return 'https://cdn.discordapp.com/avatars/{0.id}/{0.avatar}.{1}?size={2}'.format(self, format, size) + + @property + def default_avatar(self): + """Returns the default avatar for a given user. This is calculated by the user's descriminator""" + return DefaultAvatar(int(self.discriminator) % len(DefaultAvatar)) + + @property + def default_avatar_url(self): + """Returns a URL for a user's default avatar.""" + return 'https://cdn.discordapp.com/embed/avatars/{}.png'.format(self.default_avatar.value) + + @property + def mention(self): + """Returns a string that allows you to mention the given user.""" + return '<@{0.id}>'.format(self) + + def permissions_in(self, channel): + """An alias for :meth:`abc.GuildChannel.permissions_for`. + + Basically equivalent to: + + .. code-block:: python3 + + channel.permissions_for(self) + + Parameters + ----------- + channel + The channel to check your permissions for. + """ + return channel.permissions_for(self) + + @property + def created_at(self): + """Returns the user's creation time in UTC. + + This is when the user's discord account was created.""" + return snowflake_time(self.id) + + @property + def display_name(self): + """Returns the user's display name. + + For regular users this is just their username, but + if they have a guild specific nickname then that + is returned instead. + """ + return self.name + + def mentioned_in(self, message): + """Checks if the user is mentioned in the specified message. + + Parameters + ----------- + message : :class:`Message` + The message to check if you're mentioned in. + """ + + if message.mention_everyone: + return True + + for user in message.mentions: + if user.id == self.id: + return True + + return False + +class ClientUser(BaseUser): + """Represents your Discord user. + + .. container:: operations + + .. describe:: x == y + + Checks if two users are equal. + + .. describe:: x != y + + Checks if two users are not equal. + + .. describe:: hash(x) + + Return the user's hash. + + .. describe:: str(x) + + Returns the user's name with discriminator. + + Attributes + ----------- + name: str + The user's username. + id: int + The user's unique ID. + discriminator: str + The user's discriminator. This is given when the username has conflicts. + avatar: Optional[str] + The avatar hash the user has. Could be None. + bot: bool + Specifies if the user is a bot account. + verified: bool + Specifies if the user is a verified account. + email: Optional[str] + The email the user used when registering. + mfa_enabled: bool + Specifies if the user has MFA turned on and working. + premium: bool + Specifies if the user is a premium user (e.g. has Discord Nitro). + """ + __slots__ = ('email', 'verified', 'mfa_enabled', 'premium', '_relationships') + + def __init__(self, *, state, data): + super().__init__(state=state, data=data) + self.verified = data.get('verified', False) + self.email = data.get('email') + self.mfa_enabled = data.get('mfa_enabled', False) + self.premium = data.get('premium', False) + self._relationships = {} + + def __repr__(self): + return ''.format(self) + + + def get_relationship(self, user_id): + """Retrieves the :class:`Relationship` if applicable. + + Parameters + ----------- + user_id: int + The user ID to check if we have a relationship with them. + + Returns + -------- + Optional[:class:`Relationship`] + The relationship if available or ``None`` + """ + return self._relationships.get(user_id) + + @property + def relationships(self): + """Returns a list of :class:`Relationship` that the user has.""" + return list(self._relationships.values()) + + @property + def friends(self): + """Returns a list of :class:`User`\s that the user is friends with.""" + return [r.user for r in self._relationships.values() if r.type is RelationshipType.friend] + + @property + def blocked(self): + """Returns a list of :class:`User`\s that the user has blocked.""" + return [r.user for r in self._relationships.values() if r.type is RelationshipType.blocked] + + @asyncio.coroutine + def edit(self, **fields): + """|coro| + + Edits the current profile of the client. + + If a bot account is used then a password field is optional, + otherwise it is required. + + Note + ----- + To upload an avatar, a *bytes-like object* must be passed in that + represents the image being uploaded. If this is done through a file + then the file must be opened via ``open('some_filename', 'rb')`` and + the *bytes-like object* is given through the use of ``fp.read()``. + + The only image formats supported for uploading is JPEG and PNG. + + Parameters + ----------- + password : str + The current password for the client's account. + Only applicable to user accounts. + new_password: str + The new password you wish to change to. + Only applicable to user accounts. + email: str + The new email you wish to change to. + Only applicable to user accounts. + username :str + The new username you wish to change to. + avatar: bytes + A *bytes-like object* representing the image to upload. + Could be ``None`` to denote no avatar. + + Raises + ------ + HTTPException + Editing your profile failed. + InvalidArgument + Wrong image format passed for ``avatar``. + ClientException + Password is required for non-bot accounts. + """ + + try: + avatar_bytes = fields['avatar'] + except KeyError: + avatar = self.avatar + else: + if avatar_bytes is not None: + avatar = _bytes_to_base64_data(avatar_bytes) + else: + avatar = None + + not_bot_account = not self.bot + password = fields.get('password') + if not_bot_account and password is None: + raise ClientException('Password is required for non-bot accounts.') + + args = { + 'password': password, + 'username': fields.get('username', self.name), + 'avatar': avatar + } + + if not_bot_account: + args['email'] = fields.get('email', self.email) + + if 'new_password' in fields: + args['new_password'] = fields['new_password'] + + http = self._state.http + + data = yield from http.edit_profile(**args) + if not_bot_account: + self.email = data['email'] + try: + http._token(data['token'], bot=False) + except KeyError: + pass + + # manually update data by calling __init__ explicitly. + self.__init__(state=self._state, data=data) + + @asyncio.coroutine + def create_group(self, *recipients): + """|coro| + + Creates a group direct message with the recipients + provided. These recipients must be have a relationship + of type :attr:`RelationshipType.friend`. + + Bot accounts cannot create a group. + + Parameters + ----------- + \*recipients + An argument list of :class:`User` to have in + your group. + + Return + ------- + :class:`GroupChannel` + The new group channel. + + Raises + ------- + HTTPException + Failed to create the group direct message. + ClientException + Attempted to create a group with only one recipient. + This does not include yourself. + """ + + from .channel import GroupChannel + + if len(recipients) < 2: + raise ClientException('You must have two or more recipients to create a group.') + + users = [str(u.id) for u in recipients] + data = yield from self._state.http.create_group(self.id, users) + return GroupChannel(me=self, data=data, state=self._state) + +class User(BaseUser, discord.abc.Messageable): + """Represents a Discord user. + + .. container:: operations + + .. describe:: x == y + + Checks if two users are equal. + + .. describe:: x != y + + Checks if two users are not equal. + + .. describe:: hash(x) + + Return the user's hash. + + .. describe:: str(x) + + Returns the user's name with discriminator. + + Attributes + ----------- + name: str + The user's username. + id: int + The user's unique ID. + discriminator: str + The user's discriminator. This is given when the username has conflicts. + avatar: Optional[str] + The avatar hash the user has. Could be None. + bot: bool + Specifies if the user is a bot account. + """ + + __slots__ = ('__weakref__') + + def __repr__(self): + return ''.format(self) + + @asyncio.coroutine + def _get_channel(self): + ch = yield from self.create_dm() + return ch + + @property + def dm_channel(self): + """Returns the :class:`DMChannel` associated with this user if it exists. + + If this returns ``None``, you can create a DM channel by calling the + :meth:`create_dm` coroutine function. + """ + return self._state._get_private_channel_by_user(self.id) + + @asyncio.coroutine + def create_dm(self): + """Creates a :class:`DMChannel` with this user. + + This should be rarely called, as this is done transparently for most + people. + """ + found = self.dm_channel + if found is not None: + return found + + state = self._state + data = yield from state.http.start_private_message(self.id) + return state.add_dm_channel(data) + + @property + def relationship(self): + """Returns the :class:`Relationship` with this user if applicable, ``None`` otherwise.""" + return self._state.user.get_relationship(self.id) + + def is_friend(self): + """bool: Checks if the user is your friend.""" + r = self.relationship + if r is None: + return False + return r.type is RelationshipType.friend + + def is_blocked(self): + """bool: Checks if the user is blocked.""" + r = self.relationship + if r is None: + return False + return r.type is RelationshipType.blocked + + @asyncio.coroutine + def block(self): + """|coro| + + Blocks the user. + + Raises + ------- + Forbidden + Not allowed to block this user. + HTTPException + Blocking the user failed. + """ + + yield from self._state.http.add_relationship(self.id, type=RelationshipType.blocked.value) + + @asyncio.coroutine + def unblock(self): + """|coro| + + Unblocks the user. + + Raises + ------- + Forbidden + Not allowed to unblock this user. + HTTPException + Unblocking the user failed. + """ + yield from self._state.http.remove_relationship(self.id) + + @asyncio.coroutine + def remove_friend(self): + """|coro| + + Removes the user as a friend. + + Raises + ------- + Forbidden + Not allowed to remove this user as a friend. + HTTPException + Removing the user as a friend failed. + """ + yield from self._state.http.remove_relationship(self.id) + + @asyncio.coroutine + def send_friend_request(self): + """|coro| + + Sends the user a friend request. + + Raises + ------- + Forbidden + Not allowed to send a friend request to the user. + HTTPException + Sending the friend request failed. + """ + yield from self._state.http.send_friend_request(username=self.name, discriminator=self.discriminator) + + @asyncio.coroutine + def profile(self): + """|coro| + + Gets the user's profile. This can only be used by non-bot accounts. + + Raises + ------- + Forbidden + Not allowed to fetch profiles. + HTTPException + Fetching the profile failed. + + Returns + -------- + :class:`Profile` + The profile of the user. + """ + + state = self._state + data = yield from state.http.get_user_profile(self.id) + + def transform(d): + return state._get_guild(int(d['id'])) + + since = data.get('premium_since') + mutual_guilds = list(filter(None, map(transform, data.get('mutual_guilds', [])))) + return Profile(flags=data['user'].get('flags', 0), + premium_since=parse_time(since), + mutual_guilds=mutual_guilds, + user=self, + connected_accounts=data['connected_accounts']) diff --git a/discord.py-rewrite/discord/utils.py b/discord.py-rewrite/discord/utils.py new file mode 100644 index 0000000..66c235b --- /dev/null +++ b/discord.py-rewrite/discord/utils.py @@ -0,0 +1,294 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from re import split as re_split +from .errors import InvalidArgument +import datetime +from base64 import b64encode +from email.utils import parsedate_to_datetime +import asyncio +import json +import warnings, functools + +DISCORD_EPOCH = 1420070400000 + +class cached_property: + def __init__(self, function): + self.function = function + self.__doc__ = getattr(function, '__doc__') + + def __get__(self, instance, owner): + if instance is None: + return self + + value = self.function(instance) + setattr(instance, self.function.__name__, value) + + return value + +class CachedSlotProperty: + def __init__(self, name, function): + self.name = name + self.function = function + self.__doc__ = getattr(function, '__doc__') + + def __get__(self, instance, owner): + if instance is None: + return self + + try: + return getattr(instance, self.name) + except AttributeError: + value = self.function(instance) + setattr(instance, self.name, value) + return value + +def cached_slot_property(name): + def decorator(func): + return CachedSlotProperty(name, func) + return decorator + +def parse_time(timestamp): + if timestamp: + return datetime.datetime(*map(int, re_split(r'[^\d]', timestamp.replace('+00:00', '')))) + return None + +def deprecated(instead=None): + def actual_decorator(func): + @functools.wraps(func) + def decorated(*args, **kwargs): + warnings.simplefilter('always', DeprecationWarning) # turn off filter + if instead: + fmt = "{0.__name__} is deprecated, use {1} instead." + else: + fmt = '{0.__name__} is deprecated.' + + warnings.warn(fmt.format(func, instead), stacklevel=3, category=DeprecationWarning) + warnings.simplefilter('default', DeprecationWarning) # reset filter + return func(*args, **kwargs) + return decorated + return actual_decorator + +def oauth_url(client_id, permissions=None, guild=None, redirect_uri=None): + """A helper function that returns the OAuth2 URL for inviting the bot + into guilds. + + Parameters + ----------- + client_id : str + The client ID for your bot. + permissions : :class:`Permissions` + The permissions you're requesting. If not given then you won't be requesting any + permissions. + guild : :class:`Guild` + The guild to pre-select in the authorization screen, if available. + redirect_uri : str + An optional valid redirect URI. + """ + url = 'https://discordapp.com/oauth2/authorize?client_id={}&scope=bot'.format(client_id) + if permissions is not None: + url = url + '&permissions=' + str(permissions.value) + if guild is not None: + url = url + "&guild_id=" + guild.id + if redirect_uri is not None: + from urllib.parse import urlencode + url = url + "&response_type=code&" + urlencode({'redirect_uri': redirect_uri}) + return url + + +def snowflake_time(id): + """Returns the creation date in UTC of a discord id.""" + return datetime.datetime.utcfromtimestamp(((id >> 22) + DISCORD_EPOCH) / 1000) + +def time_snowflake(datetime_obj, high=False): + """Returns a numeric snowflake pretending to be created at the given date. + + When using as the lower end of a range, use time_snowflake(high=False) - 1 to be inclusive, high=True to be exclusive + When using as the higher end of a range, use time_snowflake(high=True) + 1 to be inclusive, high=False to be exclusive + + Parameters + ----------- + datetime_obj + A timezone-naive datetime object representing UTC time. + high + Whether or not to set the lower 22 bit to high or low. + """ + unix_seconds = (datetime_obj - type(datetime_obj)(1970, 1, 1)).total_seconds() + discord_millis = int(unix_seconds * 1000 - DISCORD_EPOCH) + + return (discord_millis << 22) + (2**22-1 if high else 0) + +def find(predicate, seq): + """A helper to return the first element found in the sequence + that meets the predicate. For example: :: + + member = find(lambda m: m.name == 'Mighty', channel.guild.members) + + would find the first :class:`Member` whose name is 'Mighty' and return it. + If an entry is not found, then ``None`` is returned. + + This is different from `filter`_ due to the fact it stops the moment it finds + a valid entry. + + + .. _filter: https://docs.python.org/3.6/library/functions.html#filter + + Parameters + ----------- + predicate + A function that returns a boolean-like result. + seq : iterable + The iterable to search through. + """ + + for element in seq: + if predicate(element): + return element + return None + +def get(iterable, **attrs): + """A helper that returns the first element in the iterable that meets + all the traits passed in ``attrs``. This is an alternative for + :func:`discord.utils.find`. + + When multiple attributes are specified, they are checked using + logical AND, not logical OR. Meaning they have to meet every + attribute passed in and not one of them. + + To have a nested attribute search (i.e. search by ``x.y``) then + pass in ``x__y`` as the keyword argument. + + If nothing is found that matches the attributes passed, then + ``None`` is returned. + + Examples + --------- + + Basic usage: + + .. code-block:: python3 + + member = discord.utils.get(message.guild.members, name='Foo') + + Multiple attribute matching: + + .. code-block:: python3 + + channel = discord.utils.get(guild.voice_channels, name='Foo', bitrate=64000) + + Nested attribute matching: + + .. code-block:: python3 + + channel = discord.utils.get(client.get_all_channels(), guild__name='Cool', name='general') + + Parameters + ----------- + iterable + An iterable to search through. + \*\*attrs + Keyword arguments that denote attributes to search with. + """ + + def predicate(elem): + for attr, val in attrs.items(): + nested = attr.split('__') + obj = elem + for attribute in nested: + obj = getattr(obj, attribute) + + if obj != val: + return False + return True + + return find(predicate, iterable) + + +def _unique(iterable): + seen = set() + adder = seen.add + return [x for x in iterable if not (x in seen or adder(x))] + +def _get_as_snowflake(data, key): + try: + value = data[key] + except KeyError: + return None + else: + return value and int(value) + +def _get_mime_type_for_image(data): + if data.startswith(b'\x89\x50\x4E\x47\x0D\x0A\x1A\x0A'): + return 'image/png' + elif data.startswith(b'\xFF\xD8') and data.rstrip(b'\0').endswith(b'\xFF\xD9'): + return 'image/jpeg' + elif data.startswith(b'\x47\x49\x46\x38\x37\x61') or data.startswith(b'\x47\x49\x46\x38\x39\x61'): + return 'image/gif' + else: + raise InvalidArgument('Unsupported image type given') + +def _bytes_to_base64_data(data): + fmt = 'data:{mime};base64,{data}' + mime = _get_mime_type_for_image(data) + b64 = b64encode(data).decode('ascii') + return fmt.format(mime=mime, data=b64) + +def to_json(obj): + return json.dumps(obj, separators=(',', ':'), ensure_ascii=True) + +def _parse_ratelimit_header(request): + now = parsedate_to_datetime(request.headers['Date']) + reset = datetime.datetime.fromtimestamp(int(request.headers['X-Ratelimit-Reset']), datetime.timezone.utc) + return (reset - now).total_seconds() + +@asyncio.coroutine +def maybe_coroutine(f, *args, **kwargs): + value = f(*args, **kwargs) + if asyncio.iscoroutine(value): + return (yield from value) + else: + return value + +@asyncio.coroutine +def async_all(gen): + check = asyncio.iscoroutine + for elem in gen: + if check(elem): + elem = yield from elem + if not elem: + return False + return True + +@asyncio.coroutine +def sane_wait_for(futures, *, timeout, loop): + done, pending = yield from asyncio.wait(futures, timeout=timeout, loop=loop) + + if len(pending) != 0: + raise asyncio.TimeoutError() + +def valid_icon_size(size): + """Icons must be power of 2 within [16, 1024].""" + return ((size != 0) and not (size & (size - 1))) and size in range(16, 1025) diff --git a/discord.py-rewrite/discord/voice_client.py b/discord.py-rewrite/discord/voice_client.py new file mode 100644 index 0000000..9fcc4ea --- /dev/null +++ b/discord.py-rewrite/discord/voice_client.py @@ -0,0 +1,426 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +"""Some documentation to refer to: + +- Our main web socket (mWS) sends opcode 4 with a guild ID and channel ID. +- The mWS receives VOICE_STATE_UPDATE and VOICE_SERVER_UPDATE. +- We pull the session_id from VOICE_STATE_UPDATE. +- We pull the token, endpoint and server_id from VOICE_SERVER_UPDATE. +- Then we initiate the voice web socket (vWS) pointing to the endpoint. +- We send opcode 0 with the user_id, server_id, session_id and token using the vWS. +- The vWS sends back opcode 2 with an ssrc, port, modes(array) and hearbeat_interval. +- We send a UDP discovery packet to endpoint:port and receive our IP and our port in LE. +- Then we send our IP and port via vWS with opcode 1. +- When that's all done, we receive opcode 4 from the vWS. +- Finally we can transmit data to endpoint:port. +""" + +import asyncio +import socket +import logging +import struct +import threading + +log = logging.getLogger(__name__) + +try: + import nacl.secret + has_nacl = True +except ImportError: + has_nacl = False + +from . import opus +from .backoff import ExponentialBackoff +from .gateway import * +from .errors import ClientException, ConnectionClosed +from .player import AudioPlayer, AudioSource + +class VoiceClient: + """Represents a Discord voice connection. + + You do not create these, you typically get them from + e.g. :meth:`VoiceChannel.connect`. + + Warning + -------- + In order to play audio, you must have loaded the opus library + through :func:`opus.load_opus`. + + If you don't do this then the library will not be able to + transmit audio. + + Attributes + ----------- + session_id: str + The voice connection session ID. + token: str + The voice connection token. + endpoint: str + The endpoint we are connecting to. + channel: :class:`abc.Connectable` + The voice channel connected to. + loop + The event loop that the voice client is running on. + """ + def __init__(self, state, timeout, channel): + if not has_nacl: + raise RuntimeError("PyNaCl library needed in order to use voice") + + self.channel = channel + self.main_ws = None + self.timeout = timeout + self.ws = None + self.socket = None + self.loop = state.loop + self._state = state + # this will be used in the AudioPlayer thread + self._connected = threading.Event() + self._handshake_complete = asyncio.Event(loop=self.loop) + + self._connections = 0 + self.sequence = 0 + self.timestamp = 0 + self._runner = None + self._player = None + self.encoder = opus.Encoder() + + warn_nacl = not has_nacl + + @property + def guild(self): + """Optional[:class:`Guild`]: The guild we're connected to, if applicable.""" + return getattr(self.channel, 'guild', None) + + @property + def user(self): + """:class:`ClientUser`: The user connected to voice (i.e. ourselves).""" + return self._state.user + + def checked_add(self, attr, value, limit): + val = getattr(self, attr) + if val + value > limit: + setattr(self, attr, 0) + else: + setattr(self, attr, val + value) + + # connection related + + @asyncio.coroutine + def start_handshake(self): + log.info('Starting voice handshake...') + + key_id, key_name = self.channel._get_voice_client_key() + guild_id, channel_id = self.channel._get_voice_state_pair() + state = self._state + self.main_ws = ws = state._get_websocket(guild_id) + self._connections += 1 + + # request joining + yield from ws.voice_state(guild_id, channel_id) + + try: + yield from asyncio.wait_for(self._handshake_complete.wait(), timeout=self.timeout, loop=self.loop) + except asyncio.TimeoutError as e: + yield from self.terminate_handshake(remove=True) + raise e + + log.info('Voice handshake complete. Endpoint found %s (IP: %s)', self.endpoint, self.endpoint_ip) + + @asyncio.coroutine + def terminate_handshake(self, *, remove=False): + guild_id, channel_id = self.channel._get_voice_state_pair() + self._handshake_complete.clear() + yield from self.main_ws.voice_state(guild_id, None, self_mute=True) + + log.info('The voice handshake is being terminated for Channel ID %s (Guild ID %s)', channel_id, guild_id) + if remove: + log.info('The voice client has been removed for Channel ID %s (Guild ID %s)', channel_id, guild_id) + key_id, _ = self.channel._get_voice_client_key() + self._state._remove_voice_client(key_id) + + @asyncio.coroutine + def _create_socket(self, server_id, data): + self._connected.clear() + self.session_id = self.main_ws.session_id + self.server_id = server_id + self.token = data.get('token') + endpoint = data.get('endpoint') + + if endpoint is None or self.token is None: + log.warning('Awaiting endpoint... This requires waiting. ' \ + 'If timeout occurred considering raising the timeout and reconnecting.') + return + + self.endpoint = endpoint.replace(':80', '') + self.endpoint_ip = socket.gethostbyname(self.endpoint) + + if self.socket: + try: + self.socket.close() + except: + pass + + self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.socket.setblocking(False) + + if self._handshake_complete.is_set(): + # terminate the websocket and handle the reconnect loop if necessary. + self._handshake_complete.clear() + yield from self.ws.close(1006) + return + + self._handshake_complete.set() + + @asyncio.coroutine + def connect(self, *, reconnect=True, _tries=0, do_handshake=True): + log.info('Connecting to voice...') + try: + del self.secret_key + except AttributeError: + pass + + if do_handshake: + yield from self.start_handshake() + + try: + self.ws = yield from DiscordVoiceWebSocket.from_client(self) + self._connected.clear() + while not hasattr(self, 'secret_key'): + yield from self.ws.poll_event() + self._connected.set() + except (ConnectionClosed, asyncio.TimeoutError): + if reconnect and _tries < 5: + log.exception('Failed to connect to voice... Retrying...') + yield from asyncio.sleep(1 + _tries * 2.0, loop=self.loop) + yield from self.terminate_handshake() + yield from self.connect(reconnect=reconnect, _tries=_tries + 1) + else: + raise + + if self._runner is None: + self._runner = self.loop.create_task(self.poll_voice_ws(reconnect)) + + @asyncio.coroutine + def poll_voice_ws(self, reconnect): + backoff = ExponentialBackoff() + while True: + try: + yield from self.ws.poll_event() + except (ConnectionClosed, asyncio.TimeoutError) as e: + if isinstance(e, ConnectionClosed): + if e.code == 1000: + yield from self.disconnect() + break + + if not reconnect: + yield from self.disconnect() + raise e + + retry = backoff.delay() + log.exception('Disconnected from voice... Reconnecting in %.2fs.', retry) + self._connected.clear() + yield from asyncio.sleep(retry, loop=self.loop) + yield from self.terminate_handshake() + try: + yield from self.connect(reconnect=True) + except asyncio.TimeoutError: + # at this point we've retried 5 times... let's continue the loop. + log.warning('Could not connect to voice... Retrying...') + continue + + @asyncio.coroutine + def disconnect(self, *, force=False): + """|coro| + + Disconnects this voice client from voice. + """ + if not force and not self._connected.is_set(): + return + + self.stop() + self._connected.clear() + + try: + if self.ws: + yield from self.ws.close() + + yield from self.terminate_handshake(remove=True) + finally: + if self.socket: + self.socket.close() + + @asyncio.coroutine + def move_to(self, channel): + """|coro| + + Moves you to a different voice channel. + + Parameters + ----------- + channel: :class:`abc.Snowflake` + The channel to move to. Must be a voice channel. + """ + guild_id, _ = self.channel._get_voice_state_pair() + yield from self.main_ws.voice_state(guild_id, channel.id) + + def is_connected(self): + """bool: Indicates if the voice client is connected to voice.""" + return self._connected.is_set() + + # audio related + + def _get_voice_packet(self, data): + header = bytearray(12) + nonce = bytearray(24) + box = nacl.secret.SecretBox(bytes(self.secret_key)) + + # Formulate header + header[0] = 0x80 + header[1] = 0x78 + struct.pack_into('>H', header, 2, self.sequence) + struct.pack_into('>I', header, 4, self.timestamp) + struct.pack_into('>I', header, 8, self.ssrc) + + # Copy header to nonce's first 12 bytes + nonce[:12] = header + + # Encrypt and return the data + return header + box.encrypt(bytes(data), bytes(nonce)).ciphertext + + def play(self, source, *, after=None): + """Plays an :class:`AudioSource`. + + The finalizer, ``after`` is called after the source has been exhausted + or an error occurred. + + If an error happens while the audio player is running, the exception is + caught and the audio player is then stopped. + + Parameters + ----------- + source: :class:`AudioSource` + The audio source we're reading from. + after + The finalizer that is called after the stream is exhausted. + All exceptions it throws are silently discarded. This function + must have a single parameter, ``error``, that denotes an + optional exception that was raised during playing. + + Raises + ------- + ClientException + Already playing audio or not connected. + TypeError + source is not a :class:`AudioSource` or after is not a callable. + """ + + if not self._connected: + raise ClientException('Not connected to voice.') + + if self.is_playing(): + raise ClientException('Already playing audio.') + + if not isinstance(source, AudioSource): + raise TypeError('source must an AudioSource not {0.__class__.__name__}'.format(source)) + + self._player = AudioPlayer(source, self, after=after) + self._player.start() + + def is_playing(self): + """Indicates if we're currently playing audio.""" + return self._player is not None and self._player.is_playing() + + def is_paused(self): + """Indicates if we're playing audio, but if we're paused.""" + return self._player is not None and self._player.is_paused() + + def stop(self): + """Stops playing audio.""" + if self._player: + self._player.stop() + self._player = None + + def pause(self): + """Pauses the audio playing.""" + if self._player: + self._player.pause() + + def resume(self): + """Resumes the audio playing.""" + if self._player: + self._player.resume() + + @property + def source(self): + """Optional[:class:`AudioSource`]: The audio source being played, if playing. + + This property can also be used to change the audio source currently being played. + """ + return self._player.source if self._player else None + + @source.setter + def source(self, value): + if not isinstance(value, AudioSource): + raise TypeError('expected AudioSource not {0.__class__.__name__}.'.format(value)) + + if self._player is None: + raise ValueError('Not playing anything.') + + self._player._set_source(value) + + def send_audio_packet(self, data, *, encode=True): + """Sends an audio packet composed of the data. + + You must be connected to play audio. + + Parameters + ---------- + data: bytes + The *bytes-like object* denoting PCM or Opus voice data. + encode: bool + Indicates if ``data`` should be encoded into Opus. + + Raises + ------- + ClientException + You are not connected. + OpusError + Encoding the data failed. + """ + + self.checked_add('sequence', 1, 65535) + if encode: + encoded_data = self.encoder.encode(data, self.encoder.SAMPLES_PER_FRAME) + else: + encoded_data = data + packet = self._get_voice_packet(encoded_data) + try: + self.socket.sendto(packet, (self.endpoint_ip, self.voice_port)) + except BlockingIOError: + log.warning('A packet has been dropped (seq: %s, timestamp: %s)', self.sequence, self.timestamp) + + self.checked_add('timestamp', self.encoder.SAMPLES_PER_FRAME, 4294967295) diff --git a/discord.py-rewrite/discord/webhook.py b/discord.py-rewrite/discord/webhook.py new file mode 100644 index 0000000..4f84987 --- /dev/null +++ b/discord.py-rewrite/discord/webhook.py @@ -0,0 +1,659 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import aiohttp +import asyncio +import json +import time +import re + +from . import utils +from .errors import InvalidArgument, HTTPException, Forbidden, NotFound +from .user import BaseUser, User + +__all__ = ('WebhookAdapter', 'AsyncWebhookAdapter', 'RequestsWebhookAdapter', 'Webhook') + +class WebhookAdapter: + """Base class for all webhook adapters. + + Attributes + ------------ + webhook: :class:`Webhook` + The webhook that owns this adapter. + """ + + BASE = 'https://discordapp.com/api/v7' + + def _prepare(self, webhook): + self._webhook_id = webhook.id + self._webhook_token = webhook.token + self._request_url = '{0.BASE}/webhooks/{1}/{2}'.format(self, webhook.id, webhook.token) + self.webhook = webhook + + def request(self, verb, url, payload=None, multipart=None): + """Actually does the request. + + Subclasses must implement this. + + Parameters + ----------- + verb: str + The HTTP verb to use for the request. + url: str + The URL to send the request to. This will have + the query parameters already added to it, if any. + multipart: Optional[dict] + A dict containing multipart form data to send with + the request. If a filename is being uploaded, then it will + be under a ``file`` key which will have a 3-element tuple + denoting ``(filename, file, content_type)``. + payload: Optional[dict] + The JSON to send with the request, if any. + """ + raise NotImplementedError() + + def delete_webhook(self): + return self.request('DELETE', self._request_url) + + def edit_webhook(self, **payload): + return self.request('PATCH', self._request_url, payload=payload) + + def handle_execution_response(self, data, *, wait): + """Transforms the webhook execution response into something + more meaningful. + + This is mainly used to convert the data into a :class:`Message` + if necessary. + + Subclasses must implement this. + + Parameters + ------------ + data + The data that was returned from the request. + wait: bool + Whether the webhook execution was asked to wait or not. + """ + raise NotImplementedError() + + def _store_user(self, data): + # mocks a ConnectionState for appropriate use for Message + return BaseUser(state=self, data=data) + + def execute_webhook(self, *, payload, wait=False, file=None): + if file is not None: + multipart = { + 'file': file, + 'payload_json': utils.to_json(payload) + } + data = None + else: + data = payload + multipart = None + + url = '%s?wait=%d' % (self._request_url, wait) + maybe_coro = self.request('POST', url, multipart=multipart, payload=data) + return self.handle_execution_response(maybe_coro, wait=wait) + +class AsyncWebhookAdapter(WebhookAdapter): + """A webhook adapter suited for use with aiohttp. + + .. note:: + + You are responsible for cleaning up the client session. + + Parameters + ----------- + session: aiohttp.ClientSession + The session to use to send requests. + """ + + def __init__(self, session): + self.session = session + self.loop = session.loop + + @asyncio.coroutine + def request(self, verb, url, payload=None, multipart=None): + headers = {} + data = None + if payload: + headers['Content-Type'] = 'application/json' + data = utils.to_json(payload) + + if multipart: + file = multipart.pop('file', None) + data = aiohttp.FormData() + if file: + data.add_field('file', file[0], filename=file[1], content_type=file[2]) + for key, value in multipart.items(): + data.add_field(key, value) + + for tries in range(5): + r = yield from self.session.request(verb, url, headers=headers, data=data) + try: + data = yield from r.text(encoding='utf-8') + if r.headers['Content-Type'] == 'application/json': + data = json.loads(data) + + # check if we have rate limit header information + remaining = r.headers.get('X-Ratelimit-Remaining') + if remaining == '0' and r.status != 429: + delta = utils._parse_ratelimit_header(r) + yield from asyncio.sleep(delta, loop=self.loop) + + if 300 > r.status >= 200: + return data + + # we are being rate limited + if r.status == 429: + retry_after = data['retry_after'] / 1000.0 + yield from asyncio.sleep(retry_after, loop=self.loop) + continue + + if r.status in (500, 502): + yield from asyncio.sleep(1 + tries * 2, loop=self.loop) + continue + + if r.status == 403: + raise Forbidden(r, data) + elif r.status == 404: + raise NotFound(r, data) + else: + raise HTTPException(r, data) + finally: + yield from r.release() + + @asyncio.coroutine + def handle_execution_response(self, response, *, wait): + data = yield from response + if not wait: + return data + + # transform into Message object + from .message import Message + return Message(data=data, state=self, channel=self.webhook.channel) + +class RequestsWebhookAdapter(WebhookAdapter): + """A webhook adapter suited for use with ``requests``. + + Only versions of requests higher than 2.13.0 are supported. + + Parameters + ----------- + session: Optional[`requests.Session `_] + The requests session to use for sending requests. If not given then + each request will create a new session. Note if a session is given, + the webhook adapter **will not** clean it up for you. You must close + the session yourself. + sleep: bool + Whether to sleep the thread when encountering a 429 or pre-emptive + rate limit or a 5xx status code. Defaults to ``True``. If set to + ``False`` then this will raise an :exc:`HTTPException` instead. + """ + + def __init__(self, session=None, *, sleep=True): + import requests + self.session = session or requests + self.sleep = sleep + + def request(self, verb, url, payload=None, multipart=None): + headers = {} + data = None + if payload: + headers['Content-Type'] = 'application/json' + data = utils.to_json(payload) + + for tries in range(5): + r = self.session.request(verb, url, headers=headers, data=data, files=multipart) + r.encoding = 'utf-8' + data = r.text + + # compatibility with aiohttp + r.status = r.status_code + + if r.headers['Content-Type'] == 'application/json': + data = json.loads(data) + + # check if we have rate limit header information + remaining = r.headers.get('X-Ratelimit-Remaining') + if remaining == '0' and r.status != 429 and self.sleep: + delta = utils._parse_ratelimit_header(r) + time.sleep(delta) + + if 300 > r.status >= 200: + return data + + # we are being rate limited + if r.status == 429: + if self.sleep: + retry_after = data['retry_after'] / 1000.0 + time.sleep(retry_after) + continue + else: + raise HTTPException(r, data) + + if self.sleep and r.status in (500, 502): + time.sleep(1 + tries * 2) + continue + + if r.status == 403: + raise Forbidden(r, data) + elif r.status == 404: + raise NotFound(r, data) + else: + raise HTTPException(r, data) + + def handle_execution_response(self, response, *, wait): + if not wait: + return response + + # transform into Message object + from .message import Message + return Message(data=response, state=self, channel=self.webhook.channel) + +class Webhook: + """Represents a Discord webhook. + + Webhooks are a form to send messages to channels in Discord without a + bot user or authentication. + + There are two main ways to use Webhooks. The first is through the ones + received by the library such as :meth:`.Guild.webhooks` and + :meth:`.TextChannel.webhooks`. The ones received by the library will + automatically have an adapter bound using the library's HTTP session. + Those webhooks will have :meth:`~.Webhook.send`, :meth:`~.Webhook.delete` and + :meth:`~.Webhook.edit` as coroutines. + + The second form involves creating a webhook object manually without having + it bound to a websocket connection using the :meth:`~.Webhook.from_url` or + :meth:`~.Webhook.partial` classmethods. This form allows finer grained control + over how requests are done, allowing you to mix async and sync code using either + ``aiohttp`` or ``requests``. + + For example, creating a webhook from a URL and using ``aiohttp``: + + .. code-block:: python3 + + from discord import Webhook, AsyncWebhookAdapter + import aiohttp + + async def foo(): + async with aiohttp.ClientSession() as session: + webhook = Webhook.from_url('url-here', adapter=AsyncWebhookAdapter(session)) + await webhook.send('Hello World', username='Foo') + + Or creating a webhook from an ID and token and using ``requests``: + + .. code-block:: python3 + + import requests + from discord import Webhook, RequestsWebhookAdapter + + webhook = Webhook.partial(123456, 'abcdefg', adapter=RequestsWebhookAdapter()) + webhook.send('Hello World', username='Foo') + + Attributes + ------------ + id: int + The webhook's ID + token: str + The authentication token of the webhook. + guild_id: Optional[int] + The guild ID this webhook is for. + channel_id: Optional[int] + The channel ID this webhook is for. + user: Optional[:class:`abc.User`] + The user this webhook was created by. If the webhook was + received without authentication then this will be ``None``. + name: Optional[str] + The default name of the webhook. + avatar: Optional[str] + The default avatar of the webhook. + """ + + __slots__ = ('id', 'guild_id', 'channel_id', 'user', 'name', 'avatar', + 'token', '_state', '_adapter') + + def __init__(self, data, *, adapter, state=None): + self.id = int(data['id']) + self.channel_id = utils._get_as_snowflake(data, 'channel_id') + self.guild_id = utils._get_as_snowflake(data, 'guild_id') + self.name = data.get('name') + self.avatar = data.get('avatar') + self.token = data['token'] + self._state = state + self._adapter = adapter + self._adapter._prepare(self) + + user = data.get('user') + if user is None: + self.user = None + elif state is None: + self.user = BaseUser(state=None, data=user) + else: + self.user = User(state=state, data=user) + + def __repr__(self): + return '' % self.id + + @property + def url(self): + """Returns the webhook's url.""" + return 'https://discordapp.com/api/webhooks/{}/{}'.format(self.id, self.token) + + @classmethod + def partial(cls, id, token, *, adapter): + """Creates a partial :class:`Webhook`. + + A partial webhook is just a webhook object with an ID and a token. + + Parameters + ----------- + id: int + The ID of the webhook. + token: str + The authentication token of the webhook. + adapter: :class:`WebhookAdapter` + The webhook adapter to use when sending requests. This is + typically :class:`AsyncWebhookAdapter` for ``aiohttp`` or + :class:`RequestsWebhookAdapter` for ``requests``. + """ + + if not isinstance(adapter, WebhookAdapter): + raise TypeError('adapter must be a subclass of WebhookAdapter') + + data = { + 'id': id, + 'token': token + } + + return cls(data, adapter=adapter) + + @classmethod + def from_url(cls, url, *, adapter): + """Creates a partial :class:`Webhook` from a webhook URL. + + Parameters + ------------ + url: str + The URL of the webhook. + adapter: :class:`WebhookAdapter` + The webhook adapter to use when sending requests. This is + typically :class:`AsyncWebhookAdapter` for ``aiohttp`` or + :class:`RequestsWebhookAdapter` for ``requests``. + + Raises + ------- + InvalidArgument + The URL is invalid. + """ + + m = re.search(r'discordapp.com/api/webhooks/(?P[0-9]{17,21})/(?P[A-Za-z0-9\.\-\_]{60,68})', url) + if m is None: + raise InvalidArgument('Invalid webhook URL given.') + return cls(m.groupdict(), adapter=adapter) + + @classmethod + def from_state(cls, data, state): + return cls(data, adapter=AsyncWebhookAdapter(session=state.http._session), state=state) + + @property + def guild(self): + """Optional[:class:`Guild`]: The guild this webhook belongs to. + + If this is a partial webhook, then this will always return ``None``. + """ + return self._state and self._state.get_guild(self.guild_id) + + @property + def channel(self): + """Optional[:class:`TextChannel`]: The text channel this webhook belongs to. + + If this is a partial webhook, then this will always return ``None``. + """ + guild = self.guild + return guild and guild.get_channel(self.channel_id) + + @property + def created_at(self): + """Returns the webhook's creation time in UTC.""" + return utils.snowflake_time(self.id) + + @property + def avatar_url(self): + """Returns a friendly URL version of the avatar the webhook has. + + If the webhook does not have a traditional avatar, their default + avatar URL is returned instead. + + This is equivalent to calling :meth:`avatar_url_as` with the + default parameters. + """ + return self.avatar_url_as() + + def avatar_url_as(self, *, format=None, size=1024): + """Returns a friendly URL version of the avatar the webhook has. + + If the webhook does not have a traditional avatar, their default + avatar URL is returned instead. + + The format must be one of 'jpeg', 'jpg', or 'png'. + The size must be a power of 2 between 16 and 1024. + + Parameters + ----------- + format: Optional[str] + The format to attempt to convert the avatar to. + If the format is ``None``, then it is equivalent to png. + size: int + The size of the image to display. + + Returns + -------- + str + The resulting CDN URL. + + Raises + ------ + InvalidArgument + Bad image format passed to ``format`` or invalid ``size``. + """ + if self.avatar is None: + # Default is always blurple apparently + return 'https://cdn.discordapp.com/embed/avatars/0.png' + + if not utils.valid_icon_size(size): + raise InvalidArgument("size must be a power of 2 between 16 and 1024") + + format = format or 'png' + + if format not in ('png', 'jpg', 'jpeg'): + raise InvalidArgument("format must be one of 'png', 'jpg', or 'jpeg'.") + + return 'https://cdn.discordapp.com/avatars/{0.id}/{0.avatar}.{1}?size={2}'.format(self, format, size) + + def delete(self): + """|maybecoro| + + Deletes this Webhook. + + If the webhook is constructed with a :class:`RequestsWebhookAdapter` then this is + not a coroutine. + + Raises + ------- + HTTPException + Deleting the webhook failed. + NotFound + This webhook does not exist. + Forbidden + You do not have permissions to delete this webhook. + """ + return self._adapter.delete_webhook(self.id, self.token) + + def edit(self, **kwargs): + """|maybecoro| + + Edits this Webhook. + + If the webhook is constructed with a :class:`RequestsWebhookAdapter` then this is + not a coroutine. + + Parameters + ------------- + name: Optional[str] + The webhook's new default name. + avatar: Optional[bytes] + A *bytes-like* object representing the webhook's new default avatar. + + Raises + ------- + HTTPException + Editing the webhook failed. + NotFound + This webhook does not exist. + Forbidden + You do not have permissions to edit this webhook. + """ + payload = {} + + try: + name = kwargs['name'] + except KeyError: + pass + else: + if name is not None: + payload['name'] = str(name) + else: + payload['name'] = None + + try: + avatar = kwargs['avatar'] + except KeyError: + pass + else: + if avatar is not None: + payload['avatar'] = utils._bytes_to_base64_data(avatar) + else: + payload['avatar'] = None + + return self._adapter.edit_webhook(**payload) + + def send(self, content=None, *, wait=False, username=None, avatar_url=None, + tts=False, file=None, embed=None, embeds=None): + """|maybecoro| + + Sends a message using the webhook. + + If the webhook is constructed with a :class:`RequestsWebhookAdapter` then this is + not a coroutine. + + The content must be a type that can convert to a string through ``str(content)``. + + To upload a single file, the ``file`` parameter should be used with a + single :class:`File` object. + + If the ``embed`` parameter is provided, it must be of type :class:`Embed` and + it must be a rich embed type. You cannot mix the ``embed`` parameter with the + ``embeds`` parameter, which must be a list of :class:`Embed` objects to send. + + Parameters + ------------ + content + The content of the message to send. + wait: bool + Whether the server should wait before sending a response. This essentially + means that the return type of this function changes from ``None`` to + a :class:`Message` if set to ``True``. + username: str + The username to send with this message. If no username is provided + then the default username for the webhook is used. + avatar_url: str + The avatar URL to send with this message. If no avatar URL is provided + then the default avatar for the webhook is used. + tts: bool + Indicates if the message should be sent using text-to-speech. + file: :class:`File` + The file to upload. + embed: :class:`Embed` + The rich embed for the content to send. This cannot be mixed with + ``embeds`` parameter. + embeds: List[:class:`Embed`] + A list of embeds to send with the content. Maximum of 10. This cannot + be mixed with the ``embed`` parameter. + + Raises + -------- + HTTPException + Sending the message failed. + NotFound + This webhook was not found. + Forbidden + The authorization token for the webhook is incorrect. + InvalidArgument + You specified both ``embed`` and ``embeds`` or the length of + ``embeds`` was invalid. + + Returns + --------- + Optional[:class:`Message`] + The message that was sent. + """ + + payload = {} + + if embeds is not None and embed is not None: + raise InvalidArgument('Cannot mix embed and embeds keyword arguments.') + + if embeds is not None: + if len(embeds) > 10: + raise InvalidArgument('embeds has a maximum of 10 elements.') + payload['embeds'] = [e.to_dict() for e in embeds] + + if embed is not None: + payload['embeds'] = [embed.to_dict()] + + if content is not None: + payload['content'] = str(content) + + payload['tts'] = tts + if avatar_url: + payload['avatar_url'] = avatar_url + if username: + payload['username'] = username + + if file is not None: + try: + to_pass = (file.open_file(), file.filename, 'application/octet-stream') + return self._adapter.execute_webhook(wait=wait, file=to_pass, payload=payload) + finally: + file.close() + else: + return self._adapter.execute_webhook(wait=wait, payload=payload) + + def execute(self, *args, **kwargs): + """An alias for :meth:`~.Webhook.send`.""" + return self.send(*args, **kwargs) diff --git a/discord.py-rewrite/docs/Makefile b/discord.py-rewrite/docs/Makefile new file mode 100644 index 0000000..05ff4d9 --- /dev/null +++ b/discord.py-rewrite/docs/Makefile @@ -0,0 +1,183 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " coverage to run coverage check of the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/discord.py.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/discord.py.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/discord.py" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/discord.py" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +coverage: + $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage + @echo "Testing of coverage in the sources finished, look at the " \ + "results in $(BUILDDIR)/coverage/python.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/discord.py-rewrite/docs/_static/custom.js b/discord.py-rewrite/docs/_static/custom.js new file mode 100644 index 0000000..235a14e --- /dev/null +++ b/discord.py-rewrite/docs/_static/custom.js @@ -0,0 +1,31 @@ +$(document).ready(function () { + var sections = $('div.section'); + var activeLink = null; + var bottomHeightThreshold = $(document).height() - 30; + + $(window).scroll(function (event) { + var distanceFromTop = $(this).scrollTop(); + var currentSection = null; + + if(distanceFromTop + window.innerHeight > bottomHeightThreshold) { + currentSection = $(sections[sections.length - 1]); + } + else { + sections.each(function () { + var section = $(this); + if (section.offset().top - 1 < distanceFromTop) { + currentSection = section; + } + }); + } + + if (activeLink) { + activeLink.parent().removeClass('active'); + } + + if (currentSection) { + activeLink = $('.sphinxsidebar a[href="#' + currentSection.attr('id') + '"]'); + activeLink.parent().addClass('active'); + } + }); +}); diff --git a/discord.py-rewrite/docs/_static/style.css b/discord.py-rewrite/docs/_static/style.css new file mode 100644 index 0000000..16067d1 --- /dev/null +++ b/discord.py-rewrite/docs/_static/style.css @@ -0,0 +1,542 @@ +/* this stuff uses a couple of themes as a base with some custom stuff added + +In particular thanks to: + +- Alabaster for being a good base + - Which thanks Flask + KR theme +- Sphinx Readable Theme + - Which also proved to be a great base +*/ + +@import url('basic.css'); + +body { + font-family: 'Georgia', serif; + font-size: 16px; + margin: 0; + padding: 0; +} + +p { + margin-bottom: 8px; +} + +div.document { + margin: 10px auto 0 auto; + max-width: 940px; /* page width */ +} + +div.documentwrapper { + float: left; + width: 100%; +} + +div.bodywrapper { + margin: 0 0 0 220px; /* sidebar width */ +} + +div.body { + background-color: #ffffff; + color: #3e4349; + padding: 0 30px 30px 30px; +} + +div.footer { + color: #555; + font-size: 14px; + margin: 20px auto 30px auto; + text-align: right; + max-width: 880px; +} + +div.footer a { + color: #444; + text-decoration: underline; +} + +div.related { + padding: 10px 10px; + width: auto; +} + +div.sphinxsidebar { + float: left; + font-size: 14px; + line-height: 1.5em; + margin-left: -100%; + width: 220px; /* sidebar width */ +} + +div.sphinxsidebarwrapper { + font-size: 14px; + line-height: 1.5em; + padding: 10px 0 10px 10px; + + /* sticky sidebar */ + position: fixed; + width: 220px; /* sidebar width */ + height: 90%; + overflow: hidden; +} + +/* show scrollbar on hover */ +div.sphinxsidebarwrapper:hover { + overflow: auto; +} + +div.sphinxsidebar h3, +div.sphinxsidebar h4 { + color: #333; + font-size: 24px; + font-weight: normal; + margin: 0 0 5px 0; + padding: 0; +} + +div.sphinxsidebar h4 { + font-size: 1.1em; +} + +div.sphinxsidebar h3 a { + color: #333; +} + +div.sphinxsidebar p { + color: #888; +} + +div.sphinxsidebar p.searchtip { + line-height: 1.4em; +} + +div.sphinxsidebar ul { + color: #000; + margin: 10px 0 20px; + padding: 0; +} + +div.sphinxsidebar a { + color: #444; +} + +div.sphinxsidebar input { + border: 1px solid #ccc; + font-family: sans-serif; + font-size: 1em; + margin-top: 10px; +} + +/* -- body styles --------------------------------------------------------- */ + +a { + color: #2591c4; + text-decoration: none; +} + +a:hover { + color: #0b3a44; + text-decoration: underline; +} + +hr { + border: 1px solid #b1b4b6; +} + +div.body h1, +div.body h2, +div.body h3, +div.body h4, +div.body h5, +div.body h6 { font-weight: normal; } + +div.body h1, +div.body h2, +div.body h3, +div.body h4 { color: #212224; } +div.body h5 { color: #000; } +div.body h6 { color: #777; } + +div.body h1 { margin: 0 0 10px 0; } +div.body h2, +div.body h3 { margin: 10px 0px 10px 0px; } +div.body h4, +div.body h5, +div.body h6 { margin: 20px 0px 10px 0px; } + +div.body h1 { padding: 0 0 10px 0; } +div.body h2, +div.body h3 { padding: 10px 0 10px 0; } +div.body h4 { padding: 10px 0 10px 0; } +div.body h5, +div.body h6 { padding: 10px 0 0 0; } + +div.body h1, +div.body h2, +div.body h3 { border-bottom: 1px solid #ddd; } +div.body h4 { border-bottom: 1px solid #e5e5e5; } + +div.body h1 { font-size: 230%; } +div.body h2 { font-size: 180%; } +div.body h3 { font-size: 130%; } +div.body h4 { font-size: 110%; } +div.body h5 { font-size: 105%; } +div.body h6 { font-size: 100%; } + +a.headerlink { + color: #3e4349; + font-size: 0.8em; + padding: 0 4px 0 4px; + text-decoration: none; +} + +a.headerlink:hover { + background-color: #3e4349; + color: #fff; +} + +div.body ul { + list-style: disc; + margin: 1em 0; + padding-left: 1.3em; +} + +div.body ul ul, div.body ol ul { + margin: .2em 0; + padding-left: 1.2em; +} + +div.body ul li { + padding: 2px 0; +} + +div.body ul.search li { + padding: 5px 0 5px 20px; +} + +div.body ol { + counter-reset: li; + margin-left: 0; + padding-left: 0; +} + +div.body ol ol { + margin: .2em 0; +} + +div.body ol > li { + list-style: none; + margin: 0 0 0 1.9em; + padding: 2px 1px; + position: relative; +} + +div.body ol > li:before { + content: counter(li) "."; + counter-increment: li; + top: -2px; + left: -1.9em; + width: 1.9em; + padding: 4px 0; + position: absolute; + text-align: left; +} + +div.body p, +div.body dd, +div.body li { + line-height: 1.4em; +} + +/* weird margins */ +li > p { + margin: 2px; +} + +li > blockquote { + margin: 10px; +} + +div.admonition p.admonition-title + p { + display: inline; +} + +div.highlight { + background-color: #fff; +} + +div.important, div.note, div.hint, div.tip { + background-color: #eee; + border: 1px solid #ccc; +} + +div.attention, div.warning, div.caution, div.seealso { + background-color: #fef9e9; + border: 1px solid #fbe091; +} + +/* no disgusting background in the FAQ */ +div.topic { + background-color: transparent; + border: none; +} + +/* don't link-ify the FAQ page */ +a.toc-backref { + text-decoration: none; + color: #3e4349; +} + +/* bold and fix the Parameter, Raises, etc. */ +dl.field-list > dt { + font-weight: bold; +} + +div.danger, div.error { + background-color: #ffe4e4; + border: 1px solid #f66; +} + +div.admonition { + padding: 10px; +} + +p.admonition-title { + display: inline; +} + +p.admonition-title:after { + content: ':'; +} + +/* helpful admonitions */ +div.helpful { + background-color: #e4f2ff; + border: 1px solid #66b3ff; +} + +div.helpful > p.admonition-title { + display: block; +} + +div.helpful > p.admonition-title:after { + content: unset; +} + +pre { + background-color: #f5f5f5; + border: 1px solid #C6C9CB; + color: #222; + font-size: 0.75em; + line-height: 1.5em; + margin: 1.5em 0 1.5em 0; + padding: 10px; +} + +pre, tt, code { + font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; + font-size: 0.9em; +} + +tt, code { + background-color: #ecf0f3; +} + +tt.descname, code.descname { + font-size: 0.95em; +} + +tt.xref, a tt, code.xref, a code { + font-weight: normal; +} + +span.pre { + padding: 0 2px; +} + +dl.class { + margin-bottom: 50px; +} + +dl.describe > dt, +dl.function > dt, +dl.attribute > dt, +dl.classmethod > dt, +dl.method > dt, +dl.class > dt, +dl.exception > dt { + background-color: #f5f5f5; + padding: 1px 10px; +} + +dd { + margin-top: 10px; +} + + +.container.operations { + padding: 10px; + border: 1px solid #ddd; + margin-bottom: 20px; +} + +.container.operations::before { + content: 'Supported Operations'; + color: #212224; + display: block; + padding-bottom: 5px; +} + +.container.operations > dl.describe > dt { + background-color: #f8f8f8; +} + +table.docutils { + width: 100%; +} + +table.docutils.footnote { + width: auto; +} + +table.docutils thead, +table.docutils tfoot { + background: #f5f5f5; +} + +table.docutils thead tr th { + color: #000; + font-weight: normal; + padding: 7px 5px; + vertical-align: middle; +} + +table.docutils tbody tr th, +table.docutils tbody tr td { + border-bottom: 0; + border-top: solid 1px #ddd; + padding: 7px 5px; + vertical-align: top; +} +table.docutils tbody tr:last-child th, +table.docutils tbody tr:last-child td { + border-bottom: solid 1px #ddd; +} + +table.docutils thead tr td p, +table.docutils tfoot tr td p, +table.docutils tbody tr td p, +table.docutils thead tr td ul, +table.docutils tfoot tr td ul, +table.docutils tbody tr td ul, +table.docutils thead tr td ol, +table.docutils tfoot tr td ol, +table.docutils tbody tr td ol { + margin: 0 0 .5em; +} +table.docutils thead tr td p.last, +table.docutils tfoot tr td p.last, +table.docutils tbody tr td p.last, +table.docutils thead tr td ul.last, +table.docutils tfoot tr td ul.last, +table.docutils tbody tr td ul.last, +table.docutils thead tr td ol.last, +table.docutils tfoot tr td ol.last, +table.docutils tbody tr td ol.last { + margin-bottom: 0; +} + +.viewcode-back { + font-family: Arial, sans-serif; +} + +div.viewcode-block:target { + background-color: #fef9e9; + border-top: 1px solid #fbe091; + border-bottom: 1px solid #fbe091; +} + +/* hide the welcome text */ +div#welcome-to-discord-py > h1 { + display: none; +} + +.active { + background-color: #dbdbdb; + border-left: 5px solid #dbdbdb; +} + +@media screen and (max-width: 870px) { + + div.document { + width: auto; + margin: 0; + } + + div.documentwrapper { + float: none; + } + + div.bodywrapper { + margin: 0; + } + + div.body { + min-height: 0; + padding: 0 20px 30px 20px; + } + + div.footer { + background-color: #333; + color: #888; + margin: 0; + padding: 10px 20px 20px; + text-align: left; + width: auto; + } + + div.footer a { + color: #bbb; + } + + div.footer a:hover { + color: #fff; + } + + div.sphinxsidebar { + background-color: #333; + color: #fff; + float: none; + margin: 0; + padding: 10px 20px; + width: auto; + } + + /* sticky sidebar */ + div.sphinxsidebarwrapper { + position: relative; + } + + div.sphinxsidebar h3, + div.sphinxsidebar h4, + div.sphinxsidebar p, + div.sphinxsidebar h3 a { + color: #fff; + } + + div.sphinxsidebar ul { + color: #999; + } + + div.sphinxsidebar a { + color: #aaa; + } + + div.sphinxsidebar a:hover { + color: #fff; + } + + .active { + background-color: transparent; + border-left: none; + } +} diff --git a/discord.py-rewrite/docs/_templates/layout.html b/discord.py-rewrite/docs/_templates/layout.html new file mode 100644 index 0000000..6816150 --- /dev/null +++ b/discord.py-rewrite/docs/_templates/layout.html @@ -0,0 +1,29 @@ +{%- extends "basic/layout.html" %} + +{% set show_source = False %} +{% set style = 'style.css' %} + +{%- block extrahead %} + {{ super() }} + +{% endblock %} + +{%- block relbar2 %}{% endblock %} + +{% block header %} + {{ super() }} + {% if pagename == 'index' %} +
+ {% endif %} +{% endblock %} + + +{%- block footer %} + + {% if pagename == 'index' %} +
+ {% endif %} +{%- endblock %} diff --git a/discord.py-rewrite/docs/_templates/relations.html b/discord.py-rewrite/docs/_templates/relations.html new file mode 100644 index 0000000..db904e8 --- /dev/null +++ b/discord.py-rewrite/docs/_templates/relations.html @@ -0,0 +1 @@ + diff --git a/discord.py-rewrite/docs/api.rst b/discord.py-rewrite/docs/api.rst new file mode 100644 index 0000000..6e0f52b --- /dev/null +++ b/discord.py-rewrite/docs/api.rst @@ -0,0 +1,2047 @@ +.. currentmodule:: discord + +API Reference +=============== + +The following section outlines the API of discord.py. + +.. note:: + + This module uses the Python logging module to log diagnostic and errors + in an output independent way. If the logging module is not configured, + these logs will not be output anywhere. See :ref:`logging_setup` for + more information on how to set up and use the logging module with + discord.py. + +Version Related Info +--------------------- + +There are two main ways to query version information about the library. + +.. data:: version_info + + A named tuple that is similar to `sys.version_info`_. + + Just like `sys.version_info`_ the valid values for ``releaselevel`` are + 'alpha', 'beta', 'candidate' and 'final'. + + .. _sys.version_info: https://docs.python.org/3.5/library/sys.html#sys.version_info + +.. data:: __version__ + + A string representation of the version. e.g. ``'0.10.0-alpha0'``. + +Client +------- + +.. autoclass:: Client + :members: + +.. autoclass:: AutoShardedClient + :members: + +Voice +------ + +.. autoclass:: VoiceClient + :members: + +.. autoclass:: AudioSource + :members: + +.. autoclass:: PCMAudio + :members: + +.. autoclass:: FFmpegPCMAudio + :members: + +.. autoclass:: PCMVolumeTransformer + :members: + +Opus Library +~~~~~~~~~~~~~ + +.. autofunction:: discord.opus.load_opus + +.. autofunction:: discord.opus.is_loaded + +.. _discord-api-events: + +Event Reference +--------------- + +This page outlines the different types of events listened by :class:`Client`. + +There are two ways to register an event, the first way is through the use of +:meth:`Client.event`. The second way is through subclassing :class:`Client` and +overriding the specific events. For example: :: + + import discord + + class MyClient(discord.Client): + async def on_message(self, message): + if message.author != self.user: + return + + if message.content.startswith('$hello'): + await message.channel.send('Hello World!') + + +If an event handler raises an exception, :func:`on_error` will be called +to handle it, which defaults to print a traceback and ignoring the exception. + +.. warning:: + + All the events must be a |corourl|_. If they aren't, then you might get unexpected + errors. In order to turn a function into a coroutine they must either be ``async def`` + functions or in 3.4 decorated with ``@asyncio.coroutine``. + + The following two functions are examples of coroutine functions: :: + + async def on_ready(): + pass + + @asyncio.coroutine + def on_ready(): + pass + +.. function:: on_connect() + + Called when the client has successfully connected to Discord. This is not + the same as the client being fully prepared, see :func:`on_ready` for that. + + The warnings on :func:`on_ready` also apply. + +.. function:: on_ready() + + Called when the client is done preparing the data received from Discord. Usually after login is successful + and the :attr:`Client.guilds` and co. are filled up. + + .. warning:: + + This function is not guaranteed to be the first event called. + Likewise, this function is **not** guaranteed to only be called + once. This library implements reconnection logic and thus will + end up calling this event whenever a RESUME request fails. + +.. function:: on_shard_ready(shard_id) + + Similar to :func:`on_ready` except used by :class:`AutoShardedClient` + to denote when a particular shard ID has become ready. + + :param shard_id: The shard ID that is ready. + +.. function:: on_resumed() + + Called when the client has resumed a session. + +.. function:: on_error(event, \*args, \*\*kwargs) + + Usually when an event raises an uncaught exception, a traceback is + printed to stderr and the exception is ignored. If you want to + change this behaviour and handle the exception for whatever reason + yourself, this event can be overridden. Which, when done, will + supress the default action of printing the traceback. + + The information of the exception rasied and the exception itself can + be retreived with a standard call to ``sys.exc_info()``. + + If you want exception to propogate out of the :class:`Client` class + you can define an ``on_error`` handler consisting of a single empty + ``raise`` statement. Exceptions raised by ``on_error`` will not be + handled in any way by :class:`Client`. + + :param event: The name of the event that raised the exception. + :param args: The positional arguments for the event that raised the + exception. + :param kwargs: The keyword arguments for the event that raised the + execption. + +.. function:: on_socket_raw_receive(msg) + + Called whenever a message is received from the WebSocket, before + it's processed. This event is always dispatched when a message is + received and the passed data is not processed in any way. + + This is only really useful for grabbing the WebSocket stream and + debugging purposes. + + .. note:: + + This is only for the messages received from the client + WebSocket. The voice WebSocket will not trigger this event. + + :param msg: The message passed in from the WebSocket library. + Could be ``bytes`` for a binary message or ``str`` + for a regular message. + +.. function:: on_socket_raw_send(payload) + + Called whenever a send operation is done on the WebSocket before the + message is sent. The passed parameter is the message that is being + sent to the WebSocket. + + This is only really useful for grabbing the WebSocket stream and + debugging purposes. + + .. note:: + + This is only for the messages received from the client + WebSocket. The voice WebSocket will not trigger this event. + + :param payload: The message that is about to be passed on to the + WebSocket library. It can be ``bytes`` to denote a binary + message or ``str`` to denote a regular text message. + +.. function:: on_typing(channel, user, when) + + Called when someone begins typing a message. + + The ``channel`` parameter can be a :class:`abc.Messageable` instance. + Which could either be :class:`TextChannel`, :class:`GroupChannel`, or + :class:`DMChannel`. + + If the ``channel`` is a :class:`TextChannel` then the ``user`` parameter + is a :class:`Member`, otherwise it is a :class:`User`. + + :param channel: The location where the typing originated from. + :param user: The user that started typing. + :param when: A ``datetime.datetime`` object representing when typing started. + +.. function:: on_message(message) + + Called when a :class:`Message` is created and sent. + + .. warning:: + + Your bot's own messages and private messages are sent through this + event. This can lead cases of 'recursion' depending on how your bot was + programmed. If you want the bot to not reply to itself, consider + checking the user IDs. Note that :class:`~ext.commands.Bot` does not + have this problem. + + :param message: A :class:`Message` of the current message. + +.. function:: on_message_delete(message) + + Called when a message is deleted. If the message is not found in the + :attr:`Client.messages` cache, then these events will not be called. This + happens if the message is too old or the client is participating in high + traffic guilds. To fix this, increase the ``max_messages`` option of + :class:`Client`. + + :param message: A :class:`Message` of the deleted message. + +.. function:: on_raw_message_delete(message_id, channel_id) + + Called when a message is deleted. Unlike :func:`on_message_delete`, this is + called regardless of the message being in the internal message cache or not. + + :param int message_id: The message ID of the message being deleted. + :param int channel_id: The channel ID where the message was deleted. + +.. function:: on_raw_bulk_message_delete(message_ids, channel_id) + + Called when a bulk delete is triggered. This event is called regardless + of the message IDs being in the internal message cache or not. + + :param message_ids: The message IDs that were bulk deleted. + :type message_ids: Set[int] + :param int channel_id: The channel ID where the messages were deleted. + +.. function:: on_message_edit(before, after) + + Called when a :class:`Message` receives an update event. If the message is not found + in the :attr:`Client.messages` cache, then these events will not be called. + This happens if the message is too old or the client is participating in high + traffic guilds. To fix this, increase the ``max_messages`` option of :class:`Client`. + + The following non-exhaustive cases trigger this event: + + - A message has been pinned or unpinned. + - The message content has been changed. + - The message has received an embed. + + - For performance reasons, the embed server does not do this in a "consistent" manner. + + - A call message has received an update to its participants or ending time. + + :param before: A :class:`Message` of the previous version of the message. + :param after: A :class:`Message` of the current version of the message. + +.. function:: on_raw_message_edit(message_id, data) + + Called when a message is edited. Unlike :func:`on_message_edit`, this is called + regardless of the state of the internal message cache. + + Due to the inherently raw nature of this event, the data parameter coincides with + the raw data given by the `gateway `_ + + Since the data payload can be partial, care must be taken when accessing stuff in the dictionary. + One example of a common case of partial data is when the ``'content'`` key is inaccessible. This + denotes an "embed" only edit, which is an edit in which only the embeds are updated by the Discord + embed server. + + :param int message_id: The message ID of the message being edited. + :param dict data: The raw data being passed to the MESSAGE_UPDATE gateway event. + +.. function:: on_reaction_add(reaction, user) + + Called when a message has a reaction added to it. Similar to on_message_edit, + if the message is not found in the :attr:`Client.messages` cache, then this + event will not be called. + + .. note:: + + To get the :class:`Message` being reacted, access it via :attr:`Reaction.message`. + + :param reaction: A :class:`Reaction` showing the current state of the reaction. + :param user: A :class:`User` or :class:`Member` of the user who added the reaction. + +.. function:: on_raw_reaction_add(emoji, message_id, channel_id, user_id) + + Called when a reaction has a reaction added. Unlike :func:`on_reaction_add`, this is + called regardless of the state of the internal message cache. + + :param emoji: The custom or unicode emoji being reacted to. + :type emoji: :class:`PartialReactionEmoji` + :param int message_id: The message ID of the message being reacted. + :param int channel_id: The channel ID where the message belongs to. + :param int user_id: The user ID of the user who did the reaction. + +.. function:: on_reaction_remove(reaction, user) + + Called when a message has a reaction removed from it. Similar to on_message_edit, + if the message is not found in the :attr:`Client.messages` cache, then this event + will not be called. + + .. note:: + + To get the message being reacted, access it via :attr:`Reaction.message`. + + :param reaction: A :class:`Reaction` showing the current state of the reaction. + :param user: A :class:`User` or :class:`Member` of the user who removed the reaction. + +.. function:: on_raw_reaction_remove(emoji, message_id, channel_id, user_id) + + Called when a reaction has a reaction removed. Unlike :func:`on_reaction_remove`, this is + called regardless of the state of the internal message cache. + + :param emoji: The custom or unicode emoji that got un-reacted. + :type emoji: :class:`PartialReactionEmoji` + :param int message_id: The message ID of the message being un-reacted. + :param int channel_id: The channel ID where the message belongs to. + :param int user_id: The user ID of the user who removed the reaction. + +.. function:: on_reaction_clear(message, reactions) + + Called when a message has all its reactions removed from it. Similar to :func:`on_message_edit`, + if the message is not found in the :attr:`Client.messages` cache, then this event + will not be called. + + :param message: The :class:`Message` that had its reactions cleared. + :param reactions: A list of :class:`Reaction`\s that were removed. + +.. function:: on_raw_reaction_clear(message_id, channel_id) + + Called when a message has all its reactions removed. Unlike :func:`on_reaction_clear`, + this is called regardless of the state of the internal message cache. + + :param int message_id: The message ID of the message having its reactions removed. + :param int channel_id: The channel ID of where the message belongs to. + +.. function:: on_private_channel_delete(channel) + on_private_channel_create(channel) + + Called whenever a private channel is deleted or created. + + :param channel: The :class:`abc.PrivateChannel` that got created or deleted. + +.. function:: on_private_channel_update(before, after) + + Called whenever a private group DM is updated. e.g. changed name or topic. + + :param before: The :class:`GroupChannel` that got updated with the old info. + :param after: The :class:`GroupChannel` that got updated with the updated info. + +.. function:: on_private_channel_pins_update(channel, last_pin) + + Called whenever a message is pinned or unpinned from a private channel. + + :param channel: The :class:`abc.PrivateChannel` that had it's pins updated. + :param last_pin: A ``datetime.datetime`` object representing when the latest message + was pinned or ``None`` if there are no pins. + +.. function:: on_guild_channel_delete(channel) + on_guild_channel_create(channel) + + Called whenever a guild channel is deleted or created. + + Note that you can get the guild from :attr:`~abc.GuildChannel.guild`. + + :param channel: The :class:`abc.GuildChannel` that got created or deleted. + +.. function:: on_guild_channel_update(before, after) + + Called whenever a guild channel is updated. e.g. changed name, topic, permissions. + + :param before: The :class:`abc.GuildChannel` that got updated with the old info. + :param after: The :class:`abc.GuildChannel` that got updated with the updated info. + +.. function:: on_guild_channel_pins_update(channel, last_pin) + + Called whenever a message is pinned or unpinned from a guild channel. + + :param channel: The :class:`abc.GuildChannel` that had it's pins updated. + :param last_pin: A ``datetime.datetime`` object representing when the latest message + was pinned or ``None`` if there are no pins. + +.. function:: on_member_join(member) + on_member_remove(member) + + Called when a :class:`Member` leaves or joins a :class:`Guild`. + + :param member: The :class:`Member` that joined or left. + +.. function:: on_member_update(before, after) + + Called when a :class:`Member` updates their profile. + + This is called when one or more of the following things change: + + - status + - game playing + - avatar + - nickname + - roles + + :param before: The :class:`Member` that updated their profile with the old info. + :param after: The :class:`Member` that updated their profile with the updated info. + +.. function:: on_guild_join(guild) + + Called when a :class:`Guild` is either created by the :class:`Client` or when the + :class:`Client` joins a guild. + + :param guild: The :class:`Guild` that was joined. + +.. function:: on_guild_remove(guild) + + Called when a :class:`Guild` is removed from the :class:`Client`. + + This happens through, but not limited to, these circumstances: + + - The client got banned. + - The client got kicked. + - The client left the guild. + - The client or the guild owner deleted the guild. + + In order for this event to be invoked then the :class:`Client` must have + been part of the guild to begin with. (i.e. it is part of :attr:`Client.guilds`) + + :param guild: The :class:`Guild` that got removed. + +.. function:: on_guild_update(before, after) + + Called when a :class:`Guild` updates, for example: + + - Changed name + - Changed AFK channel + - Changed AFK timeout + - etc + + :param before: The :class:`Guild` prior to being updated. + :param after: The :class:`Guild` after being updated. + +.. function:: on_guild_role_create(role) + on_guild_role_delete(role) + + Called when a :class:`Guild` creates or deletes a new :class:`Role`. + + To get the guild it belongs to, use :attr:`Role.guild`. + + :param role: The :class:`Role` that was created or deleted. + +.. function:: on_guild_role_update(before, after) + + Called when a :class:`Role` is changed guild-wide. + + :param before: The :class:`Role` that updated with the old info. + :param after: The :class:`Role` that updated with the updated info. + +.. function:: on_guild_emojis_update(guild, before, after) + + Called when a :class:`Guild` adds or removes :class:`Emoji`. + + :param guild: The :class:`Guild` who got their emojis updated. + :param before: A list of :class:`Emoji` before the update. + :param after: A list of :class:`Emoji` after the update. + +.. function:: on_guild_available(guild) + on_guild_unavailable(guild) + + Called when a guild becomes available or unavailable. The guild must have + existed in the :attr:`Client.guilds` cache. + + :param guild: The :class:`Guild` that has changed availability. + +.. function:: on_voice_state_update(member, before, after) + + Called when a :class:`Member` changes their :class:`VoiceState`. + + The following, but not limited to, examples illustrate when this event is called: + + - A member joins a voice room. + - A member leaves a voice room. + - A member is muted or deafened by their own accord. + - A member is muted or deafened by a guild administrator. + + :param member: The :class:`Member` whose voice states changed. + :param before: The :class:`VoiceState` prior to the changes. + :param after: The :class:`VoiceState` after to the changes. + +.. function:: on_member_ban(guild, user) + + Called when user gets banned from a :class:`Guild`. + + :param guild: The :class:`Guild` the user got banned from. + :param user: The user that got banned. + Can be either :class:`User` or :class:`Member` depending if + the user was in the guild or not at the time of removal. + +.. function:: on_member_unban(guild, user) + + Called when a :class:`User` gets unbanned from a :class:`Guild`. + + :param guild: The :class:`Guild` the user got unbanned from. + :param user: The :class:`User` that got unbanned. + +.. function:: on_group_join(channel, user) + on_group_remove(channel, user) + + Called when someone joins or leaves a group, i.e. a :class:`PrivateChannel` + with a :attr:`PrivateChannel.type` of :attr:`ChannelType.group`. + + :param channel: The group that the user joined or left. + :param user: The user that joined or left. + +.. function:: on_relationship_add(relationship) + on_relationship_remove(relationship) + + Called when a :class:`Relationship` is added or removed from the + :class:`ClientUser`. + + :param relationship: The relationship that was added or removed. + +.. function:: on_relationship_update(before, after) + + Called when a :class:`Relationship` is updated, e.g. when you + block a friend or a friendship is accepted. + + :param before: The previous relationship status. + :param after: The updated relationship status. + +.. _discord-api-utils: + +Utility Functions +----------------- + +.. autofunction:: discord.utils.find + +.. autofunction:: discord.utils.get + +.. autofunction:: discord.utils.snowflake_time + +.. autofunction:: discord.utils.oauth_url + +Application Info +------------------ + +.. class:: AppInfo + + A namedtuple representing the bot's application info. + + .. attribute:: id + + The application's ``client_id``. + .. attribute:: name + + The application's name. + .. attribute:: description + + The application's description + .. attribute:: icon + + The application's icon hash if it exists, ``None`` otherwise. + .. attribute:: icon_url + + A property that retrieves the application's icon URL if it exists. + + If it doesn't exist an empty string is returned. + .. attribute:: owner + + The owner of the application. This is a :class:`User` instance + with the owner's information at the time of the call. + +Profile +--------- + +.. class:: Profile + + A namedtuple representing a user's Discord public profile. + + .. attribute:: user + + The :class:`User` the profile belongs to. + .. attribute:: premium + + A boolean indicating if the user has premium (i.e. Discord Nitro). + .. attribute:: nitro + + An alias for :attr:`premium`. + .. attribute:: premium_since + + A naive UTC datetime indicating how long the user has been premium since. + This could be ``None`` if not applicable. + .. attribute:: staff + + A boolean indicating if the user is Discord Staff. + .. attribute:: partner + + A boolean indicating if the user is a Discord Partner. + .. attribute:: hypesquad + + A boolean indicating if the user is in Discord HypeSquad. + .. attribute:: mutual_guilds + + A list of :class:`Guild` that the :class:`ClientUser` shares with this + user. + .. attribute:: connected_accounts + + A list of dict objects indicating the accounts the user has connected. + + An example entry can be seen below: :: + + {type: "twitch", id: "92473777", name: "discordapp"} + +.. _discord-api-enums: + +Enumerations +------------- + +The API provides some enumerations for certain types of strings to avoid the API +from being stringly typed in case the strings change in the future. + +All enumerations are subclasses of `enum`_. + +.. _enum: https://docs.python.org/3/library/enum.html + +.. class:: ChannelType + + Specifies the type of channel. + + .. attribute:: text + + A text channel. + .. attribute:: voice + + A voice channel. + .. attribute:: private + + A private text channel. Also called a direct message. + .. attribute:: group + + A private group text channel. + +.. class:: MessageType + + Specifies the type of :class:`Message`. This is used to denote if a message + is to be interpreted as a system message or a regular message. + + .. attribute:: default + + The default message type. This is the same as regular messages. + .. attribute:: recipient_add + + The system message when a recipient is added to a group private + message, i.e. a private channel of type :attr:`ChannelType.group`. + .. attribute:: recipient_remove + + The system message when a recipient is removed from a group private + message, i.e. a private channel of type :attr:`ChannelType.group`. + .. attribute:: call + + The system message denoting call state, e.g. missed call, started call, + etc. + .. attribute:: channel_name_change + + The system message denoting that a channel's name has been changed. + .. attribute:: channel_icon_change + + The system message denoting that a channel's icon has been changed. + .. attribute:: pins_add + + The system message denoting that a pinned message has been added to a channel. + +.. class:: VoiceRegion + + Specifies the region a voice server belongs to. + + .. attribute:: us_west + + The US West region. + .. attribute:: us_east + + The US East region. + .. attribute:: us_central + + The US Central region. + .. attribute:: eu_west + + The EU West region. + .. attribute:: eu_central + + The EU Central region. + .. attribute:: singapore + + The Singapore region. + .. attribute:: london + + The London region. + .. attribute:: sydney + + The Sydney region. + .. attribute:: amsterdam + + The Amsterdam region. + .. attribute:: frankfurt + + The Frankfurt region. + + .. attribute:: brazil + + The Brazil region. + .. attribute:: vip_us_east + + The US East region for VIP guilds. + .. attribute:: vip_us_west + + The US West region for VIP guilds. + .. attribute:: vip_amsterdam + + The Amsterdam region for VIP guilds. + +.. class:: VerificationLevel + + Specifies a :class:`Guild`\'s verification level, which is the criteria in + which a member must meet before being able to send messages to the guild. + + .. container:: operations + + .. describe:: x == y + + Checks if two verification levels are equal. + .. describe:: x != y + + Checks if two verification levels are not equal. + .. describe:: x > y + + Checks if a verification level is higher than another. + .. describe:: x < y + + Checks if a verification level is lower than another. + .. describe:: x >= y + + Checks if a verification level is higher or equal to another. + .. describe:: x <= y + + Checks if a verification level is lower or equal to another. + + .. attribute:: none + + No criteria set. + .. attribute:: low + + Member must have a verified email on their Discord account. + .. attribute:: medium + + Member must have a verified email and be registered on Discord for more + than five minutes. + .. attribute:: high + + Member must have a verified email, be registered on Discord for more + than five minutes, and be a member of the guild itself for more than + ten minutes. + .. attribute:: table_flip + + An alias for :attr:`high`. + .. attribute:: extreme + + Member must have a verified phone on their Discord account. + + .. attribute:: double_table_flip + + An alias for :attr:`extreme`. + +.. class:: ContentFilter + + Specifies a :class:`Guild`\'s explicit content filter, which is the machine + learning algorithms that Discord uses to detect if an image contains + pornography or otherwise explicit content. + + .. container:: operations + + .. describe:: x == y + + Checks if two content filter levels are equal. + .. describe:: x != y + + Checks if two content filter levels are not equal. + .. describe:: x > y + + Checks if a content filter level is higher than another. + .. describe:: x < y + + Checks if a content filter level is lower than another. + .. describe:: x >= y + + Checks if a content filter level is higher or equal to another. + .. describe:: x <= y + + Checks if a content filter level is lower or equal to another. + + .. attribute:: disabled + + The guild does not have the content filter enabled. + .. attribute:: no_role + + The guild has the content filter enabled for members without a role. + .. attribute:: all_members + + The guild has the content filter enabled for every member. + +.. class:: Status + + Specifies a :class:`Member` 's status. + + .. attribute:: online + + The member is online. + .. attribute:: offline + + The member is offline. + .. attribute:: idle + + The member is idle. + .. attribute:: dnd + + The member is "Do Not Disturb". + .. attribute:: do_not_disturb + + An alias for :attr:`dnd`. + .. attribute:: invisible + + The member is "invisible". In reality, this is only used in sending + a presence a la :meth:`Client.change_presence`. When you receive a + user's presence this will be :attr:`offline` instead. + +.. class:: RelationshipType + + Specifies the type of :class:`Relationship` + + .. attribute:: friend + + You are friends with this user. + .. attribute:: blocked + + You have blocked this user. + .. attribute:: incoming_request + + The user has sent you a friend request. + .. attribute:: outgoing_request + + You have sent a friend request to this user. + + +.. class:: AuditLogAction + + Represents the type of action being done for a :class:`AuditLogEntry`\, + which is retrievable via :meth:`Guild.audit_logs`. + + .. attribute:: guild_update + + The guild has updated. Things that trigger this include: + + - Changing the guild vanity URL + - Changing the guild invite splash + - Changing the guild AFK channel or timeout + - Changing the guild voice server region + - Changing the guild icon + - Changing the guild moderation settings + - Changing things related to the guild widget + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`Guild`. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.afk_channel` + - :attr:`~AuditLogDiff.system_channel` + - :attr:`~AuditLogDiff.afk_timeout` + - :attr:`~AuditLogDiff.default_message_notifications` + - :attr:`~AuditLogDiff.explicit_content_filter` + - :attr:`~AuditLogDiff.mfa_level` + - :attr:`~AuditLogDiff.name` + - :attr:`~AuditLogDiff.owner` + - :attr:`~AuditLogDiff.splash` + - :attr:`~AuditLogDiff.vanity_url_code` + + .. attribute:: channel_create + + A new channel was created. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + either a :class:`abc.GuildChannel` or :class:`Object` with an ID. + + A more filled out object in the :class:`Object` case can be found + by using :attr:`~AuditLogEntry.after`. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.name` + - :attr:`~AuditLogDiff.type` + - :attr:`~AuditLogDiff.overwrites` + + .. attribute:: channel_update + + A channel was updated. Things that trigger this include: + + - The channel name or topic was changed + - The channel bitrate was changed + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`abc.GuildChannel` or :class:`Object` with an ID. + + A more filled out object in the :class:`Object` case can be found + by using :attr:`~AuditLogEntry.after` or :attr:`~AuditLogEntry.before`. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.name` + - :attr:`~AuditLogDiff.type` + - :attr:`~AuditLogDiff.position` + - :attr:`~AuditLogDiff.overwrites` + - :attr:`~AuditLogDiff.topic` + - :attr:`~AuditLogDiff.bitrate` + + .. attribute:: channel_delete + + A channel was deleted. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + an :class:`Object` with an ID. + + A more filled out object can be found by using the + :attr:`~AuditLogEntry.before` object. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.name` + - :attr:`~AuditLogDiff.type` + - :attr:`~AuditLogDiff.overwrites` + + .. attribute:: overwrite_create + + A channel permission overwrite was created. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`abc.GuildChannel` or :class:`Object` with an ID. + + When this is the action, the type of :attr:`~AuditLogEntry.extra` is + either a :class:`Role` or :class:`Member`. If the object is not found + then it is a :class:`Object` with an ID being filled, a name, and a + ``type`` attribute set to either ``'role'`` or ``'member'`` to help + dictate what type of ID it is. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.deny` + - :attr:`~AuditLogDiff.allow` + - :attr:`~AuditLogDiff.id` + - :attr:`~AuditLogDiff.type` + + .. attribute:: overwrite_update + + A channel permission overwrite was changed, this is typically + when the permission values change. + + See :attr:`overwrite_create` for more information on how the + :attr:`~AuditLogEntry.target` and :attr:`~AuditLogEntry.extra` fields + are set. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.deny` + - :attr:`~AuditLogDiff.allow` + - :attr:`~AuditLogDiff.id` + - :attr:`~AuditLogDiff.type` + + .. attribute:: overwrite_delete + + A channel permission overwrite was deleted. + + See :attr:`overwrite_create` for more information on how the + :attr:`~AuditLogEntry.target` and :attr:`~AuditLogEntry.extra` fields + are set. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.deny` + - :attr:`~AuditLogDiff.allow` + - :attr:`~AuditLogDiff.id` + - :attr:`~AuditLogDiff.type` + + .. attribute:: kick + + A member was kicked. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`User` who got kicked. + + When this is the action, :attr:`~AuditLogEntry.changes` is empty. + + .. attribute:: member_prune + + A member prune was triggered. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + set to `None`. + + When this is the action, the type of :attr:`~AuditLogEntry.extra` is + set to an unspecified proxy object with two attributes: + + - ``delete_members_days``: An integer specifying how far the prune was. + - ``members_removed``: An integer specifying how many members were removed. + + When this is the action, :attr:`~AuditLogEntry.changes` is empty. + + .. attribute:: ban + + A member was banned. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`User` who got banned. + + When this is the action, :attr:`~AuditLogEntry.changes` is empty. + + .. attribute:: unban + + A member was unbanned. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`User` who got unbanned. + + When this is the action, :attr:`~AuditLogEntry.changes` is empty. + + .. attribute:: member_update + + A member has updated. This triggers in the following situations: + + - A nickname was changed + - They were server muted or deafened (or it was undo'd) + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`Member` or :class:`User` who got updated. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.nick` + - :attr:`~AuditLogDiff.mute` + - :attr:`~AuditLogDiff.deaf` + + .. attribute:: member_role_update + + A member's role has been updated. This triggers when a member + either gains a role or losses a role. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`Member` or :class:`User` who got the role. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.roles` + + .. attribute:: role_create + + A new role was created. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`Role` or a :class:`Object` with the ID. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.colour` + - :attr:`~AuditLogDiff.mentionable` + - :attr:`~AuditLogDiff.hoist` + - :attr:`~AuditLogDiff.name` + - :attr:`~AuditLogDiff.permissions` + + .. attribute:: role_update + + A role was updated. This triggers in the following situations: + + - The name has changed + - The permissions have changed + - The colour has changed + - Its hoist/mentionable state has changed + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`Role` or a :class:`Object` with the ID. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.colour` + - :attr:`~AuditLogDiff.mentionable` + - :attr:`~AuditLogDiff.hoist` + - :attr:`~AuditLogDiff.name` + - :attr:`~AuditLogDiff.permissions` + + .. attribute:: role_delete + + A role was deleted. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`Role` or a :class:`Object` with the ID. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.colour` + - :attr:`~AuditLogDiff.mentionable` + - :attr:`~AuditLogDiff.hoist` + - :attr:`~AuditLogDiff.name` + - :attr:`~AuditLogDiff.permissions` + + .. attribute:: invite_create + + An invite was created. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`Invite` that was created. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.max_age` + - :attr:`~AuditLogDiff.code` + - :attr:`~AuditLogDiff.temporary` + - :attr:`~AuditLogDiff.inviter` + - :attr:`~AuditLogDiff.channel` + - :attr:`~AuditLogDiff.uses` + - :attr:`~AuditLogDiff.max_uses` + + .. attribute:: invite_update + + An invite was updated. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`Invite` that was updated. + + .. attribute:: invite_delete + + An invite was deleted. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`Invite` that was deleted. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.max_age` + - :attr:`~AuditLogDiff.code` + - :attr:`~AuditLogDiff.temporary` + - :attr:`~AuditLogDiff.inviter` + - :attr:`~AuditLogDiff.channel` + - :attr:`~AuditLogDiff.uses` + - :attr:`~AuditLogDiff.max_uses` + + .. attribute:: webhook_create + + A webhook was created. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`Object` with the webhook ID. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.channel` + - :attr:`~AuditLogDiff.name` + - :attr:`~AuditLogDiff.type` (always set to ``1`` if so) + + .. attribute:: webhook_update + + A webhook was updated. This trigger in the following situations: + + - The webhook name changed + - The webhook channel changed + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`Object` with the webhook ID. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.channel` + - :attr:`~AuditLogDiff.name` + + .. attribute:: webhook_delete + + A webhook was deleted. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`Object` with the webhook ID. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.channel` + - :attr:`~AuditLogDiff.name` + - :attr:`~AuditLogDiff.type` (always set to ``1`` if so) + + .. attribute:: emoji_create + + An emoji was created. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`Emoji` or :class:`Object` with the emoji ID. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.name` + + .. attribute:: emoji_update + + An emoji was updated. This triggers when the name has changed. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`Emoji` or :class:`Object` with the emoji ID. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.name` + + .. attribute:: emoji_delete + + An emoji was deleted. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`Object` with the emoji ID. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.name` + + .. attribute:: message_delete + + A message was deleted by a moderator. Note that this + only triggers if the message was deleted by either bulk delete + or deletion by someone other than the author. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + the :class:`Member` or :class:`User` who had their message deleted. + + When this is the action, the type of :attr:`~AuditLogEntry.extra` is + set to an unspecified proxy object with two attributes: + + - ``count``: An integer specifying how many messages were deleted. + - ``channel``: A :class:`TextChannel` or :class:`Object` with the channel ID where the message got deleted. + + +.. class:: AuditLogActionCategory + + Represents the category that the :class:`AuditLogAction` belongs to. + + This can be retrieved via :attr:`AuditLogEntry.category`. + + .. attribute:: create + + The action is the creation of something. + + .. attribute:: delete + + The action is the deletion of something. + + .. attribute:: update + + The action is the update of something. + + + +Async Iterator +---------------- + +Some API functions return an "async iterator". An async iterator is something that is +capable of being used in an `async for `_ +statement. + +These async iterators can be used as follows in 3.5 or higher: :: + + async for elem in channel.history(): + # do stuff with elem here + +If you are using 3.4 however, you will have to use the more verbose way: :: + + iterator = channel.history() # or whatever returns an async iterator + while True: + try: + item = yield from iterator.next() + except discord.NoMoreItems: + break + + # do stuff with item here + +Certain utilities make working with async iterators easier, detailed below. + +.. class:: AsyncIterator + + Represents the "AsyncIterator" concept. Note that no such class exists, + it is purely abstract. + + .. container:: operations + + .. describe:: async for x in y + + Iterates over the contents of the async iterator. Note + that this is only available in Python 3.5 or higher. + + + .. comethod:: next() + + |coro| + + Advances the iterator by one, if possible. If no more items are found + then this raises :exc:`NoMoreItems`. + + .. comethod:: get(**attrs) + + |coro| + + Similar to :func:`utils.get` except run over the async iterator. + + Getting the last message by a user named 'Dave' or ``None``: :: + + msg = await channel.history().get(author__name='Dave') + + .. comethod:: find(predicate) + + |coro| + + Similar to :func:`utils.find` except run over the async iterator. + + Unlike :func:`utils.find`\, the predicate provided can be a + coroutine. + + Getting the last audit log with a reason or ``None``: :: + + def predicate(event): + return event.reason is not None + + event = await guild.audit_logs().find(predicate) + + :param predicate: The predicate to use. Can be a coroutine. + :return: The first element that returns ``True`` for the predicate or ``None``. + + .. comethod:: flatten() + + |coro| + + Flattens the async iterator into a ``list`` with all the elements. + + :return: A list of every element in the async iterator. + :rtype: list + + .. method:: map(func) + + This is similar to the built-in ``map`` function. Another + :class:`AsyncIterator` is returned that executes the function on + every element it is iterating over. This function can either be a + regular function or a coroutine. + + Creating a content iterator: :: + + def transform(message): + return message.content + + async for content in channel.history().map(transform): + message_length = len(content) + + :param func: The function to call on every element. Could be a coroutine. + :return: An async iterator. + + .. method:: filter(predicate) + + This is similar to the built-in ``filter`` function. Another + :class:`AsyncIterator` is returned that filters over the original + async iterator. This predicate can be a regular function or a coroutine. + + Getting messages by non-bot accounts: :: + + def predicate(message): + return not message.author.bot + + async for elem in channel.history().filter(predicate): + ... + + :param predicate: The predicate to call on every element. Could be a coroutine. + :return: An async iterator. + + +Audit Log Data +---------------- + +Working with :meth:`Guild.audit_logs` is a complicated process with a lot of machinery +involved. The library attempts to make it easy to use and friendly. In order to accomplish +this goal, it must make use of a couple of data classes that aid in this goal. + +.. autoclass:: AuditLogEntry + :members: + +.. class:: AuditLogChanges + + An audit log change set. + + .. attribute:: before + + The old value. The attribute has the type of :class:`AuditLogDiff`. + + Depending on the :class:`AuditLogActionCategory` retrieved by + :attr:`~AuditLogEntry.category`\, the data retrieved by this + attribute differs: + + +----------------------------------------+---------------------------------------------------+ + | Category | Description | + +----------------------------------------+---------------------------------------------------+ + | :attr:`~AuditLogActionCategory.create` | All attributes are set to ``None``. | + +----------------------------------------+---------------------------------------------------+ + | :attr:`~AuditLogActionCategory.delete` | All attributes are set the value before deletion. | + +----------------------------------------+---------------------------------------------------+ + | :attr:`~AuditLogActionCategory.update` | All attributes are set the value before updating. | + +----------------------------------------+---------------------------------------------------+ + | ``None`` | No attributes are set. | + +----------------------------------------+---------------------------------------------------+ + + .. attribute:: after + + The new value. The attribute has the type of :class:`AuditLogDiff`. + + Depending on the :class:`AuditLogActionCategory` retrieved by + :attr:`~AuditLogEntry.category`\, the data retrieved by this + attribute differs: + + +----------------------------------------+--------------------------------------------------+ + | Category | Description | + +----------------------------------------+--------------------------------------------------+ + | :attr:`~AuditLogActionCategory.create` | All attributes are set to the created value | + +----------------------------------------+--------------------------------------------------+ + | :attr:`~AuditLogActionCategory.delete` | All attributes are set to ``None`` | + +----------------------------------------+--------------------------------------------------+ + | :attr:`~AuditLogActionCategory.update` | All attributes are set the value after updating. | + +----------------------------------------+--------------------------------------------------+ + | ``None`` | No attributes are set. | + +----------------------------------------+--------------------------------------------------+ + +.. class:: AuditLogDiff + + Represents an audit log "change" object. A change object has dynamic + attributes that depend on the type of action being done. Certain actions + map to certain attributes being set. + + Note that accessing an attribute that does not match the specified action + will lead to an attribute error. + + To get a list of attributes that have been set, you can iterate over + them. To see a list of all possible attributes that could be set based + on the action being done, check the documentation for :class:`AuditLogAction`, + otherwise check the documentation below for all attributes that are possible. + + .. describe:: iter(diff) + + Return an iterator over (attribute, value) tuple of this diff. + + .. attribute:: name + + *str* – A name of something. + + .. attribute:: icon + + *str* – A guild's icon hash. See also :attr:`Guild.icon`. + + .. attribute:: splash + + *str* – The guild's invite splash hash. See also :attr:`Guild.splash`. + + .. attribute:: owner + + Union[:class:`Member`, :class:`User`] – The guild's owner. See also :attr:`Guild.owner` + + .. attribute:: region + + :class:`GuildRegion` – The guild's voice region. See also :attr:`Guild.region`. + + .. attribute:: afk_channel + + Union[:class:`VoiceChannel`, :class:`Object`] – The guild's AFK channel. + + If this could not be found, then it falls back to a :class:`Object` + with the ID being set. + + See :attr:`Guild.afk_channel`. + + .. attribute:: system_channel + + Union[:class:`TextChannel`, :class:`Object`] – The guild's system channel. + + If this could not be found, then it falls back to a :class:`Object` + with the ID being set. + + See :attr:`Guild.system_channel`. + + .. attribute:: afk_timeout + + *int* – The guild's AFK timeout. See :attr:`Guild.afk_timeout`. + + .. attribute:: mfa_level + + *int* - The guild's MFA level. See :attr:`Guild.mfa_level`. + + .. attribute:: widget_enabled + + *bool* – The guild's widget has been enabled or disabled. + + .. attribute:: widget_channel + + Union[:class:`TextChannel`, :class:`Object`] – The widget's channel. + + If this could not be found then it falls back to a :class:`Object` + with the ID being set. + + .. attribute:: verification_level + + :class:`VerificationLevel` – The guild's verification level. + + See also :attr:`Guild.verification_level`. + + .. attribute:: explicit_content_filter + + :class:`ContentFilter` – The guild's content filter. + + See also :attr:`Guild.explicit_content_filter`. + + .. attribute:: default_message_notifications + + *int* – The guild's default message notification setting. + + .. attribute:: vanity_url_code + + *str* – The guild's vanity URL. + + See also :meth:`Guild.vanity_invite` and :meth:`Guild.change_vanity_invite`. + + .. attribute:: position + + *int* – The position of a :class:`Role` or :class:`abc.GuildChannel`. + + .. attribute:: type + + *Union[int, str]* – The type of channel or channel permission overwrite. + + If the type is an ``int``, then it is a type of channel which can be either + ``0`` to indicate a text channel or ``1`` to indicate a voice channel. + + If the type is a ``str``, then it is a type of permission overwrite which + can be either ``'role'`` or ``'member'``. + + .. attribute:: topic + + *str* – The topic of a :class:`TextChannel`. + + See also :attr:`TextChannel.topic`. + + .. attribute:: bitrate + + *int* – The bitrate of a :class:`VoiceChannel`. + + See also :attr:`VoiceChannel.bitrate`. + + .. attribute:: overwrites + + List[Tuple[target, :class:`PermissionOverwrite`]] – A list of + permission overwrite tuples that represents a target and a + :class:`PermissionOverwrite` for said target. + + The first element is the object being targeted, which can either + be a :class:`Member` or :class:`User` or :class:`Role`. If this object + is not found then it is a :class:`Object` with an ID being filled and + a ``type`` attribute set to either ``'role'`` or ``'member'`` to help + decide what type of ID it is. + + .. attribute:: roles + + List[Union[:class:`Role`, :class:`Object`]] – A list of roles being added or removed + from a member. + + If a role is not found then it is a :class:`Object` with the ID and name being + filled in. + + .. attribute:: nick + + *Optional[str]* – The nickname of a member. + + See also :attr:`Member.nick` + + .. attribute:: deaf + + *bool* – Whether the member is being server deafened. + + See also :attr:`VoiceState.deaf`. + + .. attribute:: mute + + *bool* – Whether the member is being server muted. + + See also :attr:`VoiceState.mute`. + + .. attribute:: permissions + + :class:`Permissions` – The permissions of a role. + + See also :attr:`Role.permissions`. + + .. attribute:: colour + color + + :class:`Colour` – The colour of a role. + + See also :attr:`Role.colour` + + .. attribute:: hoist + + *bool* – Whether the role is being hoisted or not. + + See also :attr:`Role.hoist` + + .. attribute:: mentionable + + *bool* – Whether the role is mentionable or not. + + See also :attr:`Role.mentionable` + + .. attribute:: code + + *str* – The invite's code. + + See also :attr:`Invite.code` + + .. attribute:: channel + + Union[:class:`abc.GuildChannel`, :class:`Object`] – A guild channel. + + If the channel is not found then it is a :class:`Object` with the ID + being set. In some cases the channel name is also set. + + .. attribute:: inviter + + :class:`User` – The user who created the invite. + + See also :attr:`Invite.inviter`. + + .. attribute:: max_uses + + *int* – The invite's max uses. + + See also :attr:`Invite.max_uses`. + + .. attribute:: uses + + *int* – The invite's current uses. + + See also :attr:`Invite.uses`. + + .. attribute:: max_age + + *int* – The invite's max age in seconds. + + See also :attr:`Invite.max_age`. + + .. attribute:: temporary + + *bool* – If the invite is a temporary invite. + + See also :attr:`Invite.temporary`. + + .. attribute:: allow + deny + + :class:`Permissions` – The permissions being allowed or denied. + + .. attribute:: id + + *int* – The ID of the object being changed. + + .. attribute:: avatar + + *str* – The avatar hash of a member. + + See also :attr:`User.avatar`. + +.. this is currently missing the following keys: reason and application_id + I'm not sure how to about porting these + +Webhook Support +------------------ + +discord.py offers support for creating, editing, and executing webhooks through the :class:`Webhook` class. + +.. autoclass:: Webhook + :members: + +Adapters +~~~~~~~~~ + +Adapters allow you to change how the request should be handled. They all build on a single +interface, :meth:`WebhookAdapter.request`. + +.. autoclass:: WebhookAdapter + :members: + +.. autoclass:: AsyncWebhookAdapter + :members: + +.. autoclass:: RequestsWebhookAdapter + :members: + +.. _discord_api_abcs: + +Abstract Base Classes +----------------------- + +An abstract base class (also known as an ``abc``) is a class that models can inherit +to get their behaviour. The Python implementation of an `abc `_ is +slightly different in that you can register them at run-time. **Abstract base classes cannot be instantiated**. +They are mainly there for usage with ``isinstance`` and ``issubclass``\. + +This library has a module related to abstract base classes, some of which are actually from the ``abc`` standard +module, others which are not. + +.. autoclass:: discord.abc.Snowflake + :members: + +.. autoclass:: discord.abc.User + :members: + +.. autoclass:: discord.abc.PrivateChannel + :members: + +.. autoclass:: discord.abc.GuildChannel + :members: + +.. autoclass:: discord.abc.Messageable + :members: + :exclude-members: history, typing + + .. autocomethod:: discord.abc.Messageable.history + :async-for: + + .. autocomethod:: discord.abc.Messageable.typing + :async-with: + +.. autoclass:: discord.abc.Connectable + +.. _discord_api_models: + +Discord Models +--------------- + +Models are classes that are received from Discord and are not meant to be created by +the user of the library. + +.. danger:: + + The classes listed below are **not intended to be created by users** and are also + **read-only**. + + For example, this means that you should not make your own :class:`User` instances + nor should you modify the :class:`User` instance yourself. + + If you want to get one of these model classes instances they'd have to be through + the cache, and a common way of doing so is through the :func:`utils.find` function + or attributes of model classes that you receive from the events specified in the + :ref:`discord-api-events`. + +.. note:: + + Nearly all classes here have ``__slots__`` defined which means that it is + impossible to have dynamic attributes to the data classes. + + More information about ``__slots__`` can be found + `in the official python documentation `_. + + +ClientUser +~~~~~~~~~~~~ + +.. autoclass:: ClientUser() + :members: + :inherited-members: + +Relationship +~~~~~~~~~~~~~~ + +.. autoclass:: Relationship() + :members: + +User +~~~~~ + +.. autoclass:: User() + :members: + :inherited-members: + :exclude-members: history, typing + + .. autocomethod:: history + :async-for: + + .. autocomethod:: typing + :async-with: + +Attachment +~~~~~~~~~~~ + +.. autoclass:: Attachment() + :members: + +Message +~~~~~~~ + +.. autoclass:: Message() + :members: + +Reaction +~~~~~~~~~ + +.. autoclass:: Reaction() + :members: + :exclude-members: users + + .. autocomethod:: users + :async-for: + +CallMessage +~~~~~~~~~~~~ + +.. autoclass:: CallMessage() + :members: + +GroupCall +~~~~~~~~~~ + +.. autoclass:: GroupCall() + :members: + +Guild +~~~~~~ + +.. autoclass:: Guild() + :members: + :exclude-members: audit_logs + + .. autocomethod:: audit_logs + :async-for: + +Member +~~~~~~ + +.. autoclass:: Member() + :members: + :inherited-members: + :exclude-members: history, typing + + .. autocomethod:: history + :async-for: + + .. autocomethod:: typing + :async-with: + +VoiceState +~~~~~~~~~~~ + +.. autoclass:: VoiceState() + :members: + +Emoji +~~~~~ + +.. autoclass:: Emoji() + :members: + +PartialReactionEmoji +~~~~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: PartialReactionEmoji() + :members: + +Role +~~~~~ + +.. autoclass:: Role() + :members: + +TextChannel +~~~~~~~~~~~~ + +.. autoclass:: TextChannel() + :members: + :inherited-members: + :exclude-members: history, typing + + .. autocomethod:: history + :async-for: + + .. autocomethod:: typing + :async-with: + +VoiceChannel +~~~~~~~~~~~~~ + +.. autoclass:: VoiceChannel() + :members: + :inherited-members: + +CategoryChannel +~~~~~~~~~~~~~~~~~ + +.. autoclass:: CategoryChannel() + :members: + :inherited-members: + +DMChannel +~~~~~~~~~ + +.. autoclass:: DMChannel() + :members: + :inherited-members: + :exclude-members: history, typing + + .. autocomethod:: history + :async-for: + + .. autocomethod:: typing + :async-with: + +GroupChannel +~~~~~~~~~~~~ + +.. autoclass:: GroupChannel() + :members: + :inherited-members: + :exclude-members: history, typing + + .. autocomethod:: history + :async-for: + + .. autocomethod:: typing + :async-with: + + +Invite +~~~~~~~ + +.. autoclass:: Invite() + :members: + +.. _discord_api_data: + +Data Classes +-------------- + +Some classes are just there to be data containers, this lists them. + +Unlike :ref:`models ` you are allowed to create +these yourself, even if they can also be used to hold attributes. + +Nearly all classes here have ``__slots__`` defined which means that it is +impossible to have dynamic attributes to the data classes. + +The only exception to this rule is :class:`Object`, which is made with +dynamic attributes in mind. + +More information about ``__slots__`` can be found +`in the official python documentation `_. + + +Object +~~~~~~~ + +.. autoclass:: Object + :members: + +Embed +~~~~~~ + +.. autoclass:: Embed + :members: + +File +~~~~~ + +.. autoclass:: File + :members: + +Colour +~~~~~~ + +.. autoclass:: Colour + :members: + +Game +~~~~ + +.. autoclass:: Game + :members: + +Permissions +~~~~~~~~~~~~ + +.. autoclass:: Permissions + :members: + +PermissionOverwrite +~~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: PermissionOverwrite + :members: + +Exceptions +------------ + +The following exceptions are thrown by the library. + +.. autoexception:: DiscordException + +.. autoexception:: ClientException + +.. autoexception:: LoginFailure + +.. autoexception:: NoMoreItems + +.. autoexception:: HTTPException + :members: + +.. autoexception:: Forbidden + +.. autoexception:: NotFound + +.. autoexception:: InvalidArgument + +.. autoexception:: GatewayNotFound + +.. autoexception:: ConnectionClosed + +.. autoexception:: discord.opus.OpusError + +.. autoexception:: discord.opus.OpusNotLoaded diff --git a/discord.py-rewrite/docs/conf.py b/discord.py-rewrite/docs/conf.py new file mode 100644 index 0000000..f0af548 --- /dev/null +++ b/discord.py-rewrite/docs/conf.py @@ -0,0 +1,314 @@ +# -*- coding: utf-8 -*- +# +# discord.py documentation build configuration file, created by +# sphinx-quickstart on Fri Aug 21 05:43:30 2015. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys +import os +import re + +on_rtd = os.getenv('READTHEDOCS') == 'True' + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +sys.path.insert(0, os.path.abspath('..')) +sys.path.append(os.path.abspath('extensions')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.extlinks', + 'sphinxcontrib.asyncio', + 'details' +] + +if on_rtd: + extensions.append('sphinxcontrib.napoleon') +else: + extensions.append('sphinx.ext.napoleon') + +autodoc_member_order = 'bysource' + +extlinks = { + 'issue': ('https://github.com/Rapptz/discord.py/issues/%s', 'issue '), +} + +rst_prolog = """ +.. |coro| replace:: This function is a |corourl|_. +.. |maybecoro| replace:: This function *could be a* |corourl|_. +.. |corourl| replace:: *coroutine* +.. _corourl: https://docs.python.org/3/library/asyncio-task.html#coroutine +""" + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'discord.py' +copyright = u'2015-2017, Rapptz' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. + +version = '' +with open('../discord/__init__.py') as f: + version = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), re.MULTILINE).group(1) + +# The full version, including alpha/beta/rc tags. +release = version + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'friendly' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + + +# -- Options for HTML output ---------------------------------------------- + +html_experimental_html5_writer = True + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'basic' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# html_theme_options = { +# } + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Language to be used for generating the HTML full-text search index. +# Sphinx supports the following languages: +# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' +# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' +#html_search_language = 'en' + +# A dictionary with options for the search language support, empty by default. +# Now only 'ja' uses this config value +#html_search_options = {'type': 'default'} + +# The name of a javascript file (relative to the configuration directory) that +# implements a search results scorer. If empty, the default will be used. +#html_search_scorer = 'scorer.js' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'discord.pydoc' + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', + +# Latex figure (float) alignment +#'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + ('index', 'discord.py.tex', u'discord.py Documentation', + u'Rapptz', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'discord.py', u'discord.py Documentation', + [u'Rapptz'], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'discord.py', u'discord.py Documentation', + u'Rapptz', 'discord.py', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +#texinfo_no_detailmenu = False + +def setup(app): + app.add_javascript('custom.js') diff --git a/discord.py-rewrite/docs/discord.rst b/discord.py-rewrite/docs/discord.rst new file mode 100644 index 0000000..ac159ae --- /dev/null +++ b/discord.py-rewrite/docs/discord.rst @@ -0,0 +1,97 @@ +.. _discord-intro: + +Creating a Bot Account +======================== + +In order to work with the library and the Discord API in general, we must first create a Discord Bot account. + +Creating a Bot account is a pretty straightforward process. + +1. Make sure you're logged on to the `Discord website `_. +2. Navigate to the `application page `_ +3. Click on the "New App" button. + + .. image:: /images/discord_create_app_button.png + :alt: The new app button. + +4. Give the application a name and a description if wanted and click "Create App". + + - You can also put an avatar you want your bot to use, don't worry you can change this later. + - **Leave the Redirect URI(s) blank** unless are creating a service. + + .. image:: /images/discord_create_app_form.png + :alt: The new application form filled in. +5. Create a Bot User by clicking on the accompanying button and confirming it. + + .. image:: /images/discord_create_bot_user.png + :alt: The Create a Bot User button. +6. Make sure that **Public Bot** is ticked if you want others to invite your bot. + + - You should also make sure that **Require OAuth2 Code Grant** is unchecked unless you + are developing a service that needs it. If you're unsure, then **leave it unchecked**. + + .. image:: /images/discord_bot_user_options.png + :alt: How the Bot User options should look like for most people. + +7. Click to reveal the token. + + - **This is not the Client Secret** + - Look at the image above to see where the **Token** is. + + .. warning:: + + It should be worth noting that this token is essentially your bot's + password. You should **never** share this to someone else. In doing so, + someone can log in to your bot and do malicious things, such as leaving + servers, ban all members inside a server, or pinging everyone maliciously. + + The possibilities are endless, so **do not share this token.** + +And that's it. You now have a bot account and you can login with that token. + +.. _discord_invite_bot: + +Inviting Your Bot +------------------- + +So you've made a Bot User but it's not actually in any server. + +If you want to invite your bot you must create an invite URL for your bot. + +First, you must fetch the Client ID of the Bot. You can find this in the Bot's application page. + +.. image:: /images/discord_client_id.png + :alt: The Bot's Client ID. + +Copy paste that into the pre-formatted URL: + +.. code-block:: none + + https://discordapp.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&scope=bot&permissions=0 + +Replace ``YOUR_CLIENT_ID`` with the Client ID we got in the previous step. For example, +in the image above our client ID is 312777964700041216 so the resulting URL would be +https://discordapp.com/oauth2/authorize?client_id=312777964700041216&scope=bot&permissions=0 +(note that this bot has been deleted). + +Now you can click the link and invite your bot to any server you have "Manage Server" permissions on. + +Adding Permissions +~~~~~~~~~~~~~~~~~~~~ + +In the above URL, you might have noticed an interesting bit, the ``permissions=0`` fragment. + +Bot accounts can request specific permissions to be granted upon joining. When the bot joins +the guild, they will be granted a managed role that contains the permissions you requested. +If the permissions is 0, then no special role is created. + +This ``permissions`` value is calculated based on bit-wise arithmetic. Thankfully, people have +created a calculator that makes it easy to calculate the permissions necessary visually. + +- https://discordapi.com/permissions.html +- https://finitereality.github.io/permissions/ + +Feel free to use whichever is easier for you to grasp. + +If you want to generate this URL dynamically at run-time inside your bot and using the +:class:`discord.Permissions` interface, you can use :func:`discord.utils.oauth_url`. diff --git a/discord.py-rewrite/docs/ext/commands/api.rst b/discord.py-rewrite/docs/ext/commands/api.rst new file mode 100644 index 0000000..9fae235 --- /dev/null +++ b/discord.py-rewrite/docs/ext/commands/api.rst @@ -0,0 +1,225 @@ +.. currentmodule:: discord + +API Reference +=============== + +The following section outlines the API of discord.py's command extension module. + +.. _ext_commands_api_bot: + +Bot +---- + +.. autoclass:: discord.ext.commands.Bot + :members: + :inherited-members: + +.. autoclass:: discord.ext.commands.AutoShardedBot + :members: + +.. autofunction:: discord.ext.commands.when_mentioned + +.. autofunction:: discord.ext.commands.when_mentioned_or + +.. _ext_commands_api_events: + +Event Reference +----------------- + +These events function similar to :ref:`the regular events `, except they +are custom to the command extension module. + +.. function:: on_command_error(ctx, error) + + An error handler that is called when an error is raised + inside a command either through user input error, check + failure, or an error in your own code. + + A default one is provided (:meth:`.Bot.on_command_error`). + + :param ctx: The invocation context. + :type ctx: :class:`Context` + :param error: The error that was raised. + :type error: :class:`CommandError` derived + +.. function:: on_command(ctx) + + An event that is called when a command is found and is about to be invoked. + + This event is called regardless of whether the command itself succeeds via + error or completes. + + :param ctx: The invocation context. + :type ctx: :class:`Context` + +.. function:: on_command_completion(ctx) + + An event that is called when a command has completed its invocation. + + This event is called only if the command succeeded, i.e. all checks have + passed and the user input it correctly. + + :param ctx: The invocation context. + :type ctx: :class:`Context` + +.. _ext_commands_api_command: + +Command +-------- + +.. autofunction:: discord.ext.commands.command + +.. autofunction:: discord.ext.commands.group + +.. autoclass:: discord.ext.commands.Command + :members: + +.. autoclass:: discord.ext.commands.Group + :members: + :inherited-members: + +.. autoclass:: discord.ext.commands.GroupMixin + :members: + +.. _ext_commands_api_formatters: + +Formatters +----------- + +.. autoclass:: discord.ext.commands.Paginator + :members: + +.. autoclass:: discord.ext.commands.HelpFormatter + :members: + +.. _ext_commands_api_checks: + +Checks +------- + +.. autofunction:: discord.ext.commands.check + +.. autofunction:: discord.ext.commands.has_role + +.. autofunction:: discord.ext.commands.has_permissions + +.. autofunction:: discord.ext.commands.has_any_role + +.. autofunction:: discord.ext.commands.bot_has_role + +.. autofunction:: discord.ext.commands.bot_has_permissions + +.. autofunction:: discord.ext.commands.bot_has_any_role + +.. autofunction:: discord.ext.commands.cooldown + +.. autofunction:: discord.ext.commands.guild_only + +.. autofunction:: discord.ext.commands.is_owner + +.. autofunction:: discord.ext.commands.is_nsfw + +.. _ext_commands_api_context: + +Context +-------- + +.. autoclass:: discord.ext.commands.Context + :members: + :inherited-members: + :exclude-members: history, typing + + .. autocomethod:: discord.ext.commands.Context.history + :async-for: + + .. autocomethod:: discord.ext.commands.Context.typing + :async-with: + +.. _ext_commands_api_converters: + +Converters +------------ + +.. autoclass:: discord.ext.commands.Converter + :members: + +.. autoclass:: discord.ext.commands.MemberConverter + :members: + +.. autoclass:: discord.ext.commands.UserConverter + :members: + +.. autoclass:: discord.ext.commands.TextChannelConverter + :members: + +.. autoclass:: discord.ext.commands.VoiceChannelConverter + :members: + +.. autoclass:: discord.ext.commands.CategoryChannelConverter + :members: + +.. autoclass:: discord.ext.commands.InviteConverter + :members: + +.. autoclass:: discord.ext.commands.RoleConverter + :members: + +.. autoclass:: discord.ext.commands.GameConverter + :members: + +.. autoclass:: discord.ext.commands.ColourConverter + :members: + +.. autoclass:: discord.ext.commands.EmojiConverter + :members: + +.. autoclass:: discord.ext.commands.clean_content + :members: + +.. _ext_commands_api_errors: + +Errors +------- + +.. autoexception:: discord.ext.commands.CommandError + :members: + +.. autoexception:: discord.ext.commands.MissingRequiredArgument + :members: + +.. autoexception:: discord.ext.commands.BadArgument + :members: + +.. autoexception:: discord.ext.commands.NoPrivateMessage + :members: + +.. autoexception:: discord.ext.commands.CheckFailure + :members: + +.. autoexception:: discord.ext.commands.CommandNotFound + :members: + +.. autoexception:: discord.ext.commands.DisabledCommand + :members: + +.. autoexception:: discord.ext.commands.CommandInvokeError + :members: + +.. autoexception:: discord.ext.commands.TooManyArguments + :members: + +.. autoexception:: discord.ext.commands.UserInputError + :members: + +.. autoexception:: discord.ext.commands.CommandOnCooldown + :members: + +.. autoexception:: discord.ext.commands.NotOwner + :members: + +.. autoexception:: discord.ext.commands.MissingPermissions + :members: + +.. autoexception:: discord.ext.commands.BotMissingPermissions + :members: + diff --git a/discord.py-rewrite/docs/ext/commands/commands.rst b/discord.py-rewrite/docs/ext/commands/commands.rst new file mode 100644 index 0000000..dc8d7e7 --- /dev/null +++ b/discord.py-rewrite/docs/ext/commands/commands.rst @@ -0,0 +1,587 @@ +.. currentmodule:: discord + +.. _ext_commands_commands: + +Commands +========== + +One of the most appealing aspect of the command extension is how easy it is to define commands and +how you can arbitrarily nest groups and commands to have a rich sub-command system. + +Commands are defined by attaching it to a regular Python function. The command is then invoked by the user using a similar +signature to the Python function. + +For example, in the given command definition: + +.. code-block:: python3 + + @bot.command() + async def foo(ctx, arg): + await ctx.send(arg) + +With the following prefix (``$``), it would be invoked by the user via: + +.. code-block:: none + + $foo abc + +A command must always have at least one parameter, ``ctx``, which is the :class:`.Context` as the first one. + +There are two ways of registering a command. The first one is by using :meth:`.Bot.command` decorator, +as seen in the example above. The second is using the :func:`~ext.commands.command` decorator followed by +:meth:`.Bot.add_command` on the instance. + +Essentially, these two are equivalent: :: + + from discord.ext import commands + + bot = commands.Bot(command_prefix='$') + + @bot.command() + async def test(ctx): + pass + + # or: + + @commands.command() + async def test(ctx): + pass + + bot.add_command(test) + +Since the :meth:`.Bot.command` decorator is shorter and easier to comprehend, it will be the one used throughout the +documentation here. + +Any parameter that is accepted by the :class:`.Command` constructor can be passed into the decorator. For example, to change +the name to something other than the function would be as simple as doing this: + +.. code-block:: python3 + + @bot.command(name='list') + async def _list(ctx, arg): + pass + +Parameters +------------ + +Since we define commands by making Python functions, we also define the argument passing behaviour by the function +parameters. + +Certain parameter types do different things in the user side and most forms of parameter types are supported. + +Positional +++++++++++++ + +The most basic form of parameter passing is the positional parameter. This is where we pass a parameter as-is: + +.. code-block:: python3 + + @bot.command() + async def test(ctx, arg): + await ctx.send(arg) + + +On the bot using side, you can provide positional arguments by just passing a regular string: + +.. image:: /images/commands/positional1.png + +To make use of a word with spaces in between, you should quote it: + +.. image:: /images/commands/positional2.png + +As a note of warning, if you omit the quotes, you will only get the first word: + +.. image:: /images/commands/positional3.png + +Since positional arguments are just regular Python arguments, you can have as many as you want: + +.. code-block:: python3 + + @bot.command() + async def test(ctx, arg1, arg2): + await ctx.send('You passed {} and {}'.format(arg1, arg2)) + +Variable +++++++++++ + +Sometimes you want users to pass in an undetermined number of parameters. The library supports this +similar to how variable list parameters are done in Python: + +.. code-block:: python3 + + @bot.command() + async def test(ctx, *args): + await ctx.send('{} arguments: {}'.format(len(args), ', '.join(args))) + +This allows our user to accept either one or many arguments as they please. This works similar to positional arguments, +so multi-word parameters should be quoted. + +For example, on the bot side: + +.. image:: /images/commands/variable1.png + +If the user wants to input a multi-word argument, they have to quote it like earlier: + +.. image:: /images/commands/variable2.png + +Do note that similar to the Python function behaviour, a user can technically pass no arguments +at all: + +.. image:: /images/commands/variable3.png + +Since the ``args`` variable is a `tuple `_, +you can do anything you would usually do with one. + +Keyword-Only Arguments +++++++++++++++++++++++++ + +When you want to handle parsing of the argument yourself or do not feel like you want to wrap multi-word user input into +quotes, you can ask the library to give you the rest as a single argument. We do this by using a **keyword-only argument**, +seen below: + +.. code-block:: python3 + + @bot.command() + async def test(ctx, *, arg): + await ctx.send(arg) + +.. warning:: + + You can only have one keyword-only argument due to parsing ambiguities. + +On the bot side, we do not need to quote input with spaces: + +.. image:: /images/commands/keyword1.png + +Do keep in mind that wrapping it in quotes leaves it as-is: + +.. image:: /images/commands/keyword2.png + +By default, the keyword-only arguments are stripped of white space to make it easier to work with. This behaviour can be +toggled by the :attr:`.Command.rest_is_raw` argument in the decorator. + +.. _ext_commands_context: + +Invocation Context +------------------- + +As seen earlier, every command must take at least a single parameter, called the :class:`~ext.commands.Context`. + +This parameter gives you access to something called the "invocation context". Essentially all the information you need to +know how the command was executed. It contains a lot of useful information: + +- :attr:`.Context.guild` to fetch the :class:`Guild` of the command, if any. +- :attr:`.Context.message` to fetch the :class:`Message` of the command. +- :attr:`.Context.author` to fetch the :class:`Member` or :class:`User` that called the command. +- :meth:`.Context.send` to send a message to the channel the command was used in. + +The context implements the :class:`abc.Messageable` interface, so anything you can do on a :class:`abc.Messageable` you +can do on the :class:`~ext.commands.Context`. + +Converters +------------ + +Adding bot arguments with function parameters is only the first step in defining your bot's command interface. To actually +make use of the arguments, we usually want to convert the data into a target type. We call these +:ref:`ext_commands_api_converters`. + +Converters come in a few flavours: + +- A regular callable object that takes an argument as a sole parameter and returns a different type. + + - These range from your own function, to something like ``bool`` or ``int``. + +- A custom class that inherits from :class:`~ext.commands.Converter`. + +Basic Converters +++++++++++++++++++ + +At its core, a basic converter is a callable that takes in an argument and turns it into something else. + +For example, if we wanted to add two numbers together, we could request that they are turned into integers +for us by specifying the converter: + +.. code-block:: python3 + + @bot.command() + async def add(ctx, a: int, b: int): + await ctx.send(a + b) + +We specify converters by using something called a **function annotation**. This is a Python 3 exclusive feature that was +introduced in :pep:`3107`. + +This works with any callable, such as a function that would convert a string to all upper-case: + +.. code-block:: python3 + + def to_upper(argument): + return argument.upper() + + @bot.command() + async def up(ctx, *, content: to_upper): + await ctx.send(content) + +.. _ext_commands_adv_converters: + +Advanced Converters ++++++++++++++++++++++ + +Sometimes a basic converter doesn't have enough information that we need. For example, sometimes we want to get some +information from the :class:`Message` that called the command or we want to do some asynchronous processing. + +For this, the library provides the :class:`~ext.commands.Converter` interface. This allows you to have access to the +:class:`.Context` and have the callable be asynchronous. Defining a custom converter using this interface requires +overriding a single method, :meth:`.Converter.convert`. + +An example converter: + +.. code-block:: python3 + + import random + + class Slapper(commands.Converter): + async def convert(self, ctx, argument): + to_slap = random.choice(ctx.guild.members) + return '{0.author} slapped {1} because *{2}*'.format(ctx, to_slap, argument) + + @bot.command() + async def slap(ctx, *, reason: Slapper): + await ctx.send(reason) + +The converter provided can either be constructed or not. Essentially these two are equivalent: + +.. code-block:: python3 + + @bot.command() + async def slap(ctx, *, reason: Slapper): + await ctx.send(reason) + + # is the same as... + + @bot.command() + async def slap(ctx, *, reason: Slapper()): + await ctx.send(reason) + +Having the possibility of the converter be constructed allows you to set up some state in the converter's ``__init__`` for +fine tuning the converter. An example of this is actually in the library, :class:`~ext.commands.clean_content`. + +.. code-block:: python3 + + @bot.command() + async def clean(ctx, *, content: commands.clean_content): + await ctx.send(content) + + # or for fine-tuning + + @bot.command() + async def clean(ctx, *, content: commands.clean_content(use_nicknames=False)): + await ctx.send(content) + + +If a converter fails to convert an argument to its designated target type, the :exc:`.BadArgument` exception must be +raised. + +Discord Converters +++++++++++++++++++++ + +Working with :ref:`discord_api_models` is a fairly common thing when defining commands, as a result the library makes +working with them easy. + +For example, to receive a :class:`Member`, you can just pass it as a converter: + +.. code-block:: python3 + + @bot.command() + async def joined(ctx, *, member: discord.Member): + await ctx.send('{0} joined on {0.joined_at}'.format(member)) + +When this command is executed, it attempts to convert the string given into a :class:`Member` and then passes it as a +parameter for the function. This works by checking if the string is a mention, an ID, a nickname, a username + discriminator, +or just a regular username. The default set of converters have been written to be as easy to use as possible. + +A lot of discord models work out of the gate as a parameter: + +- :class:`Member` +- :class:`User` +- :class:`TextChannel` +- :class:`VoiceChannel` +- :class:`CategoryChannel` +- :class:`Role` +- :class:`Invite` +- :class:`Game` +- :class:`Emoji` +- :class:`Colour` + +Having any of these set as the converter will intelligently convert the argument to the appropriate target type you +specify. + +Under the hood, these are implemented by the :ref:`ext_commands_adv_converters` interface. A table of the equivalent +converter is given below: + ++-----------------------+-------------------------------------------------+ +| Discord Class | Converter | ++-----------------------+-------------------------------------------------+ +| :class:`Member` | :class:`~ext.commands.MemberConverter` | ++-----------------------+-------------------------------------------------+ +| :class:`User` | :class:`~ext.commands.UserConverter` | ++-----------------------+-------------------------------------------------+ +| :class:`TextChannel` | :class:`~ext.commands.TextChannelConverter` | ++-----------------------+-------------------------------------------------+ +| :class:`VoiceChannel` | :class:`~ext.commands.VoiceChannelConverter` | ++-----------------------+-------------------------------------------------+ +| :class:`VoiceChannel` | :class:`~ext.commands.CategoryChannelConverter` | ++-----------------------+-------------------------------------------------+ +| :class:`Role` | :class:`~ext.commands.RoleConverter` | ++-----------------------+-------------------------------------------------+ +| :class:`Invite` | :class:`~ext.commands.InviteConverter` | ++-----------------------+-------------------------------------------------+ +| :class:`Game` | :class:`~ext.commands.GameConverter` | ++-----------------------+-------------------------------------------------+ +| :class:`Emoji` | :class:`~ext.commands.EmojiConverter` | ++-----------------------+-------------------------------------------------+ +| :class:`Colour` | :class:`~ext.commands.ColourConverter` | ++-----------------------+-------------------------------------------------+ + +By providing the converter it allows us to use them as building blocks for another converter: + +.. code-block:: python3 + + class MemberRoles(commands.MemberConverter): + async def convert(self, ctx, argument): + member = await super().convert(ctx, argument) + return member.roles + + @bot.command() + async def roles(ctx, *, member: MemberRoles): + """Tells you a member's roles.""" + await ctx.send('I see the following roles: ' + ', '.join(member)) + +Inline Advanced Converters ++++++++++++++++++++++++++++++ + +If we don't want to inherit from :class:`~ext.commands.Converter`, we can still provide a converter that has the +advanced functionalities of an advanced converter and save us from specifying two types. + +For example, a common idiom would be to have a class and a converter for that class: + +.. code-block:: python3 + + class JoinDistance: + def __init__(self, joined, created): + self.joined = joined + self.created = created + + @property + def delta(self): + return self.joined - self.created + + class JoinDistanceConverter(commands.MemberConverter): + async def convert(self, ctx, argument): + member = await super().convert(ctx, argument) + return JoinDistance(member.joined_at, member.created_at) + + @bot.command() + async def delta(ctx, *, member: JoinDistanceConverter): + is_new = member.delta.days < 100 + if is_new: + await ctx.send("Hey you're pretty new!") + else: + await ctx.send("Hm you're not so new.") + +This can get tedious, so an inline advanced converter is possible through a ``classmethod`` inside the type: + +.. code-block:: python3 + + class JoinDistance: + def __init__(self, joined, created): + self.joined = joined + self.created = created + + @classmethod + async def convert(cls, ctx, argument): + member = await commands.MemberConverter().convert(ctx, argument) + return cls(member.joined_at, member.created_at) + + @property + def delta(self): + return self.joined - self.created + + @bot.command() + async def delta(ctx, *, member: JoinDistance): + is_new = member.delta.days < 100 + if is_new: + await ctx.send("Hey you're pretty new!") + else: + await ctx.send("Hm you're not so new.") + +.. _ext_commands_error_handler: + +Error Handling +---------------- + +When our commands fail to either parse we will, by default, receive a noisy error in ``stderr`` of our console that tells us +that an error has happened and has been silently ignored. + +In order to handle our errors, we must use something called an error handler. There is a global error handler, called +:func:`on_command_error` which works like any other event in the :ref:`discord-api-events`. This global error handler is +called for every error reached. + +Most of the time however, we want to handle an error local to the command itself. Luckily, commands come with local error +handlers that allow us to do just that. First we decorate an error handler function with :meth:`.Command.error`: + +.. code-block:: python3 + + @bot.command() + async def info(ctx, *, member: discord.Member): + """Tells you some info about the member.""" + fmt = '{0} joined on {0.joined_at} and has {1} roles.' + await ctx.send(fmt.format(member, len(member.roles))) + + @info.error + async def info_error(ctx, error): + if isinstance(error, commands.BadArgument): + await ctx.send('I could not find that member...') + +The first parameter of the error handler is the :class:`.Context` while the second one is an exception that is derived from +:exc:`~ext.commands.CommandError`. A list of errors is found in the :ref:`ext_commands_api_errors` page of the documentation. + +Checks +------- + +There are cases when we don't want a user to use our commands. They don't have permissions to do so or maybe we blocked +them from using our bot earlier. The commands extension comes with full support for these things in a concept called a +:ref:`ext_commands_api_checks`. + +A check is a basic predicate that can take in a :class:`.Context` as its sole parameter. Within it, you have the following +options: + +- Return ``True`` to signal that the person can run the command. +- Return ``False`` to signal that the person cannot run the command. +- Raise a :exc:`~ext.commands.CommandError` derived exception to signal the person cannot run the command. + + - This allows you to have custom error messages for you to handle in the + :ref:`error handlers `. + +To register a check for a command, we would have two ways of doing so. The first is using the :meth:`~ext.commands.check` +decorator. For example: + +.. code-block:: python3 + + async def is_owner(ctx): + return ctx.author.id == 316026178463072268 + + @bot.command(name='eval') + @commands.check(is_owner) + async def _eval(ctx, *, code): + """A bad example of an eval command""" + await ctx.send(eval(code)) + +This would only evaluate the command if the function ``is_owner`` returns ``True``. Sometimes we re-use a check often and +want to split it into its own decorator. To do that we can just add another level of depth: + +.. code-block:: python3 + + def is_owner(): + async def predicate(ctx): + return ctx.author.id == 316026178463072268 + return commands.check(predicate) + + @bot.command(name='eval') + @is_owner() + async def _eval(ctx, *, code): + """A bad example of an eval command""" + await ctx.send(eval(code)) + + +Since an owner check is so common, the library provides it for you (:func:`~ext.commands.is_owner`): + +.. code-block:: python3 + + @bot.command(name='eval') + @commands.is_owner() + async def _eval(ctx, *, code): + """A bad example of an eval command""" + await ctx.send(eval(code)) + +When multiple checks are specified, **all** of them must be ``True``: + +.. code-block:: python3 + + def is_in_guild(guild_id): + async def predicate(ctx): + return ctx.guild and ctx.guild.id == guild_id + return commands.check(is_in_guild) + + @bot.command() + @is_in_guild(41771983423143937) + async def secretguilddata(ctx): + """super secret stuff""" + await ctx.send('secret stuff') + +If any of those checks fail in the example above, then the command will not be run. + +When an error happens, the error is propagated to the :ref:`error handlers `. If you do not +raise a custom :exc:`~ext.commands.CommandError` derived exception, then it will get wrapped up into a +:exc:`~ext.commands.CheckFailure` exception as so: + +.. code-block:: python3 + + @bot.command() + @is_in_guild(41771983423143937) + async def secretguilddata(ctx): + """super secret stuff""" + await ctx.send('secret stuff') + + @secretguilddata.error + async def secretguilddata_error(ctx, error): + if isinstance(error, commands.CheckFailure): + await ctx.send('nothing to see here comrade.') + +If you want a more robust error system, you can derive from the exception and raise it instead of returning ``False``: + +.. code-block:: python3 + + class NoPrivateMessages(commands.CheckFailure): + pass + + def guild_only(): + async def predicate(ctx): + if ctx.guild is None: + raise NoPrivateMessages('Hey no DMs!') + return True + return commands.check(predicate) + + @guild_only() + async def test(ctx): + await ctx.send('Hey this is not a DM! Nice.') + + @test.error + async def test_error(ctx, error): + if isinstance(error, NoPrivateMessages): + await ctx.send(error) + +.. note:: + + Since having a ``guild_only`` decorator is pretty common, it comes built-in via :func:`~ext.commands.guild_only`. + +Global Checks +++++++++++++++ + +Sometimes we want to apply a check to **every** command, not just certain commands. The library supports this as well +using the global check concept. + +Global checks work similarly to regular checks except they are registered with the :func:`.Bot.check` decorator. + +For example, to block all DMs we could do the following: + +.. code-block:: python3 + + @bot.check + async def globally_block_dms(ctx): + return ctx.guild is not None + +.. warning:: + + Be careful on how you write your global checks, as it could also lock you out of your own bot. + +.. need a note on global check once here I think diff --git a/discord.py-rewrite/docs/ext/commands/index.rst b/discord.py-rewrite/docs/ext/commands/index.rst new file mode 100644 index 0000000..559597c --- /dev/null +++ b/discord.py-rewrite/docs/ext/commands/index.rst @@ -0,0 +1,14 @@ +``discord.ext.commands`` -- Bot commands framework +==================================================== + +``discord.py`` offers a lower level aspect on interacting with Discord. Often times, the library is used for the creation of +bots. However this task can be daunting and confusing to get correctly the first time. Many times there comes a repetition in +creating a bot command framework that is extensible, flexible, and powerful. For this reason, ``discord.py`` comes with an +extension library that handles this for you. + + +.. toctree:: + :maxdepth: 2 + + commands + api diff --git a/discord.py-rewrite/docs/extensions/details.py b/discord.py-rewrite/docs/extensions/details.py new file mode 100644 index 0000000..96f39d5 --- /dev/null +++ b/discord.py-rewrite/docs/extensions/details.py @@ -0,0 +1,55 @@ +from docutils.parsers.rst import Directive +from docutils.parsers.rst import states, directives +from docutils.parsers.rst.roles import set_classes +from docutils import nodes + +class details(nodes.General, nodes.Element): + pass + +class summary(nodes.General, nodes.Element): + pass + +def visit_details_node(self, node): + self.body.append(self.starttag(node, 'details', CLASS=node.attributes.get('class', ''))) + +def visit_summary_node(self, node): + self.body.append(self.starttag(node, 'summary', CLASS=node.attributes.get('summary-class', ''))) + self.body.append(node.rawsource) + +def depart_details_node(self, node): + self.body.append('\n') + +def depart_summary_node(self, node): + self.body.append('') + +class DetailsDirective(Directive): + final_argument_whitespace = True + optional_arguments = 1 + + option_spec = { + 'class': directives.class_option, + 'summary-class': directives.class_option, + } + + has_content = True + + def run(self): + set_classes(self.options) + self.assert_has_content() + + text = '\n'.join(self.content) + node = details(text, **self.options) + + if self.arguments: + summary_node = summary(self.arguments[0], **self.options) + summary_node.source, summary_node.line = self.state_machine.get_source_and_line(self.lineno) + node += summary_node + + self.state.nested_parse(self.content, self.content_offset, node) + return [node] + +def setup(app): + app.add_node(details, html=(visit_details_node, depart_details_node)) + app.add_node(summary, html=(visit_summary_node, depart_summary_node)) + app.add_directive('details', DetailsDirective) + diff --git a/discord.py-rewrite/docs/faq.rst b/discord.py-rewrite/docs/faq.rst new file mode 100644 index 0000000..bc01eab --- /dev/null +++ b/discord.py-rewrite/docs/faq.rst @@ -0,0 +1,333 @@ +.. currentmodule:: discord +.. _faq: + +Frequently Asked Questions +=========================== + +This is a list of Frequently Asked Questions regarding using ``discord.py`` and its extension modules. Feel free to suggest a +new question or submit one via pull requests. + +.. contents:: Questions + :local: + +Coroutines +------------ + +Questions regarding coroutines and asyncio belong here. + +I get a SyntaxError around the word ``async``\! What should I do? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This ``SyntaxError`` happens because you're using a Python version lower than 3.5. Python 3.4 uses ``@asyncio.coroutine`` and +``yield from`` instead of ``async def`` and ``await``. + +Thus you must do the following instead: :: + + async def foo(): + await bar() + + # into + + @asyncio.coroutine + def foo(): + yield from bar() + +Don't forget to ``import asyncio`` on the top of your files. + +**It is heavily recommended that you update to Python 3.5 or higher as it simplifies asyncio massively.** + +What is a coroutine? +~~~~~~~~~~~~~~~~~~~~~~ + +A coroutine is a function that must be invoked with ``await`` or ``yield from``. When Python encounters an ``await`` it stops +the function's execution at that point and works on other things until it comes back to that point and finishes off its work. +This allows for your program to be doing multiple things at the same time without using threads or complicated +multiprocessing. + +**If you forget to await a coroutine then the coroutine will not run. Never forget to await a coroutine.** + +Where can I use ``await``\? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can only use ``await`` inside ``async def`` functions and nowhere else. + +What does "blocking" mean? +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In asynchronous programming a blocking call is essentially all the parts of the function that are not ``await``. Do not +despair however, because not all forms of blocking are bad! Using blocking calls is inevitable, but you must work to make +sure that you don't excessively block functions. Remember, if you block for too long then your bot will freeze since it has +not stopped the function's execution at that point to do other things. + +A common source of blocking for too long is something like ``time.sleep(n)``. Don't do that. Use ``asyncio.sleep(n)`` +instead. Similar to this example: :: + + # bad + time.sleep(10) + + # good + await asyncio.sleep(10) + +Another common source of blocking for too long is using HTTP requests with the famous module ``requests``. While ``requests`` +is an amazing module for non-asynchronous programming, it is not a good choice for ``asyncio`` because certain requests can +block the event loop too long. Instead, use the ``aiohttp`` library which is installed on the side with this library. + +Consider the following example: :: + + # bad + r = requests.get('http://random.cat/meow') + if r.status_code == 200: + js = r.json() + await channel.send(js['file']) + + # good + async with aiohttp.ClientSession() as session: + async with session.get('http://random.cat/meow') as r: + if r.status == 200: + js = await r.json() + await channel.send(js['file']) + +General +--------- + +General questions regarding library usage belong here. + +How do I set the "Playing" status? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +There is a method for this under :class:`Client` called :meth:`Client.change_presence`. The relevant aspect of this is its +``game`` keyword argument which takes in a :class:`Game` object. Putting both of these pieces of info together, you get the +following: :: + + await client.change_presence(game=discord.Game(name='my game')) + +How do I send a message to a specific channel? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You must fetch the channel directly and then call the appropriate method. Example: :: + + channel = client.get_channel(12324234183172) + await channel.send('hello') + +How do I upload an image? +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To upload something to Discord you have to use the :class:`File` object. + +A :class:`File` accepts two parameters, the file-like object (or file path) and the filename +to pass to Discord when uploading. + +If you want to upload an image it's as simple as: :: + + await channel.send(file=discord.File('my_file.png')) + +If you have a file-like object you can do as follows: :: + + with open('my_file.png', 'rb') as fp: + await channel.send(file=discord.File(fp, 'new_filename.png')) + +To upload multiple files, you can use the ``files`` keyword argument instead of ``file``\: :: + + my_files = [ + discord.File('result.zip'), + discord.File('teaser_graph.png'), + ] + await channel.send(files=my_files) + +If you want to upload something from a URL, you will have to use an HTTP request using ``aiohttp`` +and then pass an ``io.BytesIO`` instance to :class:`File` like so: + +.. code-block:: python3 + + import io + import aiohttp + + async with aiohttp.ClientSession() as session: + async with session.get(my_url) as resp: + if resp.status != 200: + return await channel.send('Could not download file...') + data = io.BytesIO(await resp.read()) + await channel.send(file=discord.File(data, 'cool_image.png')) + + +How can I add a reaction to a message? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You use the :meth:`Message.add_reaction` method. + +If you want to use unicode emoji, you must pass a valid unicode code point in a string. In your code, you can write this in a few different ways: + +- ``'👍'`` +- ``'\U0001F44D'`` +- ``'\N{THUMBS UP SIGN}'`` + +Quick example: :: + + await message.add_reaction('\N{THUMBS UP SIGN}') + +In case you want to use emoji that come from a message, you already get their code points in the content without needing +to do anything special. You **cannot** send ``':thumbsup:'`` style shorthands. + +For custom emoji, you should pass an instance of :class:`Emoji`. You can also pass a ``'name:id'`` string, but if you +can use said emoji, you should be able to use :meth:`Client.get_emoji` to get an emoji via ID or use :func:`utils.find`/ +:func:`utils.get` on :attr:`Client.emojis` or :attr:`Guild.emojis` collections. + +Quick example: :: + + # if you have the ID already + emoji = client.get_emoji(310177266011340803) + await message.add_reaction(emoji) + + # no ID, do a lookup + emoji = discord.utils.get(guild.emojis, name='LUL') + if emoji: + await message.add_reaction(emoji) + +How do I pass a coroutine to the player's "after" function? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The library's music player launches on a separate thread, ergo it does not execute inside a coroutine. +This does not mean that it is not possible to call a coroutine in the ``after`` parameter. To do so you must pass a callable +that wraps up a couple of aspects. + +The first gotcha that you must be aware of is that calling a coroutine is not a thread-safe operation. Since we are +technically in another thread, we must take caution in calling thread-safe operations so things do not bug out. Luckily for +us, ``asyncio`` comes with a ``asyncio.run_coroutine_threadsafe`` +`function `_ that allows us to call +a coroutine from another thread. + +.. warning:: + + This function is only part of 3.5.1+ and 3.4.4+. If you are not using these Python versions then use + ``discord.compat.run_coroutine_threadsafe``. + +However, this function returns a ``concurrent.Future`` and to actually call it we have to fetch its result. Putting all of +this together we can do the following: :: + + def my_after(error): + coro = some_channel.send('Song is done!') + fut = asyncio.run_coroutine_threadsafe(coro, client.loop) + try: + fut.result() + except: + # an error happened sending the message + pass + + voice.play(discord.FFmpegPCMAudio(url), after=my_after) + +How do I run something in the background? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +`Check the background_task.py example. `_ + +How do I get a specific model? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +There are multiple ways of doing this. If you have a specific model's ID then you can use +one of the following functions: + +- :meth:`Client.get_channel` +- :meth:`Client.get_guild` +- :meth:`Client.get_user` +- :meth:`Client.get_emoji` +- :meth:`Guild.get_member` +- :meth:`Guild.get_channel` + +The following use an HTTP request: + +- :meth:`abc.Messageable.get_message` +- :meth:`Client.get_user_info` + + +If the functions above do not help you, then use of :func:`utils.find` or :func:`utils.get` would serve some use in finding +specific models. + +Quick example: :: + + # find a guild by name + guild = discord.utils.get(client.guilds, name='My Server') + + # make sure to check if it's found + if guild is not None: + # find a channel by name + channel = discord.utils.get(guild.text_channels, name='cool-channel') + +Commands Extension +------------------- + +Questions regarding ``discord.ext.commands`` belong here. + +Is there any documentation for this? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Not at the moment. Writing documentation for stuff takes time. A lot of people get by reading the docstrings in the source +code. Others get by via asking questions in the `Discord server `_. Others look at the +source code of `other existing bots `_. + +There is a `basic example `_ showcasing some +functionality. + +**Documentation is being worked on, it will just take some time to polish it**. + +Why does ``on_message`` make my commands stop working? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Overriding the default provided ``on_message`` forbids any extra commands from running. To fix this, add a +``bot.process_commands(message)`` line at the end of your ``on_message``. For example: :: + + @bot.event + async def on_message(message): + # do some extra stuff here + + await bot.process_commands(message) + +Why do my arguments require quotes? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In a simple command defined as: :: + + @bot.command() + async def echo(ctx, message: str): + await ctx.send(message) + +Calling it via ``?echo a b c`` will only fetch the first argument and disregard the rest. To fix this you should either call +it via ``?echo "a b c"`` or change the signature to have "consume rest" behaviour. Example: :: + + @bot.command() + async def echo(ctx, *, message: str): + await ctx.send(message) + +This will allow you to use ``?echo a b c`` without needing the quotes. + +How do I get the original ``message``\? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The :class:`~ext.commands.Context` contains an attribute, :attr:`~.Context.message` to get the original +message. + +Example: :: + + @bot.command() + async def joined_at(ctx, member: discord.Member = None): + member = member or ctx.author + await ctx.send('{0} joined at {0.joined_at}'.format(member)) + +How do I make a subcommand? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use the ``group`` decorator. This will transform the callback into a ``Group`` which will allow you to add commands into +the group operating as "subcommands". These groups can be arbitrarily nested as well. + +Example: :: + + @bot.group() + async def git(ctx): + if ctx.invoked_subcommand is None: + await ctx.send('Invalid git command passed...') + + @git.command() + async def push(ctx, remote: str, branch: str): + await ctx.send('Pushing to {} {}'.format(remote, branch)) + +This could then be used as ``?git push origin master``. + diff --git a/discord.py-rewrite/docs/index.rst b/discord.py-rewrite/docs/index.rst new file mode 100644 index 0000000..ba8c221 --- /dev/null +++ b/discord.py-rewrite/docs/index.rst @@ -0,0 +1,59 @@ +.. discord.py documentation master file, created by + sphinx-quickstart on Fri Aug 21 05:43:30 2015. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to discord.py +=========================== + +.. image:: /images/snake.png + +discord.py is a modern, easy to use, feature-rich, and async ready API wrapper +for Discord. + +**Features:** + +- Modern Pythonic API using ``async``\/``await`` syntax +- Sane rate limit handling that prevents 429s +- Implements the entire Discord API +- Command extension to aid with bot creation +- Easy to use with an object oriented design +- Optimised for both speed and memory + +Documentation Contents +----------------------- + +.. toctree:: + :maxdepth: 2 + + intro + quickstart + migrating + logging + api + +Extensions +----------- + +.. toctree:: + :maxdepth: 3 + + ext/commands/index.rst + + +Additional Information +----------------------- + +.. toctree:: + :maxdepth: 2 + + discord + faq + whats_new + +If you still can't find what you're looking for, try in one of the following pages: + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/discord.py-rewrite/docs/intro.rst b/discord.py-rewrite/docs/intro.rst new file mode 100644 index 0000000..6a8a4d7 --- /dev/null +++ b/discord.py-rewrite/docs/intro.rst @@ -0,0 +1,112 @@ +.. currentmodule:: discord + +.. _intro: + +Introduction +============== + +This is the documentation for discord.py, a library for Python to aid +in creating applications that utilise the Discord API. + +Prerequisites +--------------- + +discord.py works with Python 3.4.2 or higher. Support for earlier versions of Python +is not provided. Python 2.7 or lower is not supported. Python 3.3 is not supported +due to one of the dependencies (``aiohttp``) not supporting Python 3.3. + + +.. _installing: + +Installing +----------- + +You can get the library directly from PyPI: :: + + python3 -m pip install -U discord.py + +If you are using Windows, then the following should be used instead: :: + + py -3 -m pip install -U discord.py + + +To get voice support, you should use ``discord.py[voice]`` instead of ``discord.py``, e.g. :: + + python3 -m pip install -U discord.py[voice] + +On Linux environments, installing voice requires getting the following dependencies: + +- libffi +- libnacl +- python3-dev + +For a debian-based system, the following command will help get those dependencies: + +.. code-block:: shell + + $ apt install libffi-dev libnacl-dev python3-dev + +Remember to check your permissions! + +Virtual Environments +~~~~~~~~~~~~~~~~~~~~~ + +Sometimes we don't want to pollute our system installs with a library or we want to maintain +different versions of a library than the currently system installed one. Or we don't have permissions to +install a library along side with the system installed ones. For this purpose, the standard library as +of 3.3 comes with a concept called "Virtual Environment" to help maintain these separate versions. + +A more in-depth tutorial is found on `the official documentation. `_ + +However, for the quick and dirty: + +1. Go to your project's working directory: + + .. code-block:: shell + + $ cd your-bot-source + $ python3 -m venv bot-env + +2. Activate the virtual environment: + + .. code-block:: shell + + $ source bot-env/bin/activate + + On Windows you activate it with: + + .. code-block:: shell + + $ bot-env\Scripts\activate.bat + +3. Use pip like usual: + + .. code-block:: shell + + $ pip install -U discord.py + +Congratulations. You now have a virtual environment all set up without messing with your system installation. + +Basic Concepts +--------------- + +discord.py revolves around the concept of :ref:`events `. +An event is something you listen to and then respond to. For example, when a message +happens, you will receive an event about it and you can then respond to it. + +A quick example to showcase how events work: + +.. code-block:: python3 + + import discord + + class MyClient(discord.Client): + async def on_ready(self): + print('Logged on as {0}!'.format(self.user)) + + async def on_message(self, message): + print('Message from {0.author}: {0.content}'.format(message)) + + client = MyClient() + client.run('my token goes here') + diff --git a/discord.py-rewrite/docs/logging.rst b/discord.py-rewrite/docs/logging.rst new file mode 100644 index 0000000..0395575 --- /dev/null +++ b/discord.py-rewrite/docs/logging.rst @@ -0,0 +1,46 @@ +.. versionadded:: 0.6.0 +.. _logging_setup: + +Setting Up Logging +=================== + +*discord.py* logs errors and debug information via the `logging`_ python +module. It is strongly recommended that the logging module is +configured, as no errors or warnings will be output if it is not set up. +Configuration of the ``logging`` module can be as simple as:: + + import logging + + logging.basicConfig(level=logging.INFO) + +Placed at the start of the application. This will output the logs from +discord as well as other libraries that uses the ``logging`` module +directly to the console. + +The optional ``level`` argument specifies what level of events to log +out and can any of ``CRITICAL``, ``ERROR``, ``WARNING``, ``INFO``, and +``DEBUG`` and if not specified defaults to ``WARNING``. + +More advance setups are possible with the ``logging`` module. To for +example write the logs to a file called ``discord.log`` instead of +outputting them to to the console the following snippet can be used:: + + import discord + import logging + + logger = logging.getLogger('discord') + logger.setLevel(logging.DEBUG) + handler = logging.FileHandler(filename='discord.log', encoding='utf-8', mode='w') + handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s')) + logger.addHandler(handler) + +This is recommended, especially at verbose levels such as ``INFO``, +and ``DEBUG`` as there are a lot of events logged and it would clog the +stdout of your program. + + + +For more information, check the documentation and tutorial of the +`logging`_ module. + +.. _logging: https://docs.python.org/2/library/logging.html diff --git a/discord.py-rewrite/docs/make.bat b/discord.py-rewrite/docs/make.bat new file mode 100644 index 0000000..bb9b635 --- /dev/null +++ b/discord.py-rewrite/docs/make.bat @@ -0,0 +1,263 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + echo. coverage to run coverage check of the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +REM Check if sphinx-build is available and fallback to Python version if any +%SPHINXBUILD% 2> nul +if errorlevel 9009 goto sphinx_python +goto sphinx_ok + +:sphinx_python + +set SPHINXBUILD=python -m sphinx.__init__ +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +:sphinx_ok + + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\discord.py.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\discord.py.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "coverage" ( + %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage + if errorlevel 1 exit /b 1 + echo. + echo.Testing of coverage in the sources finished, look at the ^ +results in %BUILDDIR%/coverage/python.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +:end diff --git a/discord.py-rewrite/docs/migrating.rst b/discord.py-rewrite/docs/migrating.rst new file mode 100644 index 0000000..57d4f3a --- /dev/null +++ b/discord.py-rewrite/docs/migrating.rst @@ -0,0 +1,1071 @@ +.. currentmodule:: discord + +.. _migrating_1_0: + +Migrating to v1.0 +====================== + +v1.0 is one of the biggest breaking changes in the library due to a complete +redesign. + +The amount of changes are so massive and long that for all intents and purposes, it is a completely +new library. + +Part of the redesign involves making things more easy to use and natural. Things are done on the +:ref:`models ` instead of requiring a :class:`Client` instance to do any work. + +Major Model Changes +--------------------- + +Below are major model changes that have happened in v1.0 + +Snowflakes are int +~~~~~~~~~~~~~~~~~~~~ + +Before v1.0, all snowflakes (the ``id`` attribute) were strings. This has been changed to ``int``. + +Quick example: :: + + # before + ch = client.get_channel('84319995256905728') + if message.author.id == '80528701850124288': + ... + + # after + ch = client.get_channel(84319995256905728) + if message.author.id == 80528701850124288: + ... + +This change allows for fewer errors when using the Copy ID feature in the official client since you no longer have +to wrap it in quotes and allows for optimisation opportunities by allowing ETF to be used instead of JSON internally. + +Server is now Guild +~~~~~~~~~~~~~~~~~~~~~ + +The official API documentation calls the "Server" concept a "Guild" instead. In order to be more consistent with the +API documentation when necessary, the model has been renamed to :class:`Guild` and all instances referring to it has +been changed as well. + +A list of changes is as follows: + ++-------------------------------+----------------------------------+ +| Before | After | ++-------------------------------+----------------------------------+ +| ``Message.server`` | :attr:`Message.guild` | ++-------------------------------+----------------------------------+ +| ``Channel.server`` | :attr:`.GuildChannel.guild` | ++-------------------------------+----------------------------------+ +| ``Client.servers`` | :attr:`Client.guilds` | ++-------------------------------+----------------------------------+ +| ``Client.get_server`` | :meth:`Client.get_guild` | ++-------------------------------+----------------------------------+ +| ``Emoji.server`` | :attr:`Emoji.guild` | ++-------------------------------+----------------------------------+ +| ``Role.server`` | :attr:`Role.guild` | ++-------------------------------+----------------------------------+ +| ``Invite.server`` | :attr:`Invite.guild` | ++-------------------------------+----------------------------------+ +| ``Member.server`` | :attr:`Member.guild` | ++-------------------------------+----------------------------------+ +| ``Permissions.manage_server`` | :attr:`Permissions.manage_guild` | ++-------------------------------+----------------------------------+ +| ``VoiceClient.server`` | :attr:`VoiceClient.guild` | ++-------------------------------+----------------------------------+ +| ``Client.create_server`` | :meth:`Client.create_guild` | ++-------------------------------+----------------------------------+ + +.. _migrating_1_0_model_state: + +Models are Stateful +~~~~~~~~~~~~~~~~~~~~~ + +As mentioned earlier, a lot of functionality was moved out of :class:`Client` and +put into their respective :ref:`model `. + +A list of these changes is enumerated below. + ++---------------------------------------+------------------------------------------------------------------------------+ +| Before | After | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.add_reaction`` | :meth:`Message.add_reaction` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.add_roles`` | :meth:`Member.add_roles` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.ban`` | :meth:`Member.ban` or :meth:`Guild.ban` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.change_nickname`` | :meth:`Member.edit` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.clear_reactions`` | :meth:`Message.clear_reactions` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.create_channel`` | :meth:`Guild.create_text_channel` and :meth:`Guild.create_voice_channel` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.create_custom_emoji`` | :meth:`Guild.create_custom_emoji` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.create_invite`` | :meth:`abc.GuildChannel.create_invite` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.create_role`` | :meth:`Guild.create_role` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.delete_channel`` | :meth:`abc.GuildChannel.delete` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.delete_channel_permissions`` | :meth:`abc.GuildChannel.set_permissions` with ``overwrites`` set to ``None`` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.delete_custom_emoji`` | :meth:`Emoji.delete` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.delete_invite`` | :meth:`Invite.delete` or :meth:`Client.delete_invite` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.delete_message`` | :meth:`Message.delete` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.delete_messages`` | :meth:`TextChannel.delete_messages` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.delete_role`` | :meth:`Role.delete` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.delete_server`` | :meth:`Guild.delete` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.edit_channel`` | :meth:`TextChannel.edit` or :meth:`VoiceChannel.edit` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.edit_channel_permissions`` | :meth:`abc.GuildChannel.set_permissions` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.edit_custom_emoji`` | :meth:`Emoji.edit` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.edit_message`` | :meth:`Message.edit` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.edit_profile`` | :meth:`ClientUser.edit` (you get this from :attr:`Client.user`) | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.edit_role`` | :meth:`Role.edit` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.edit_server`` | :meth:`Guild.edit` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.estimate_pruned_members`` | :meth:`Guild.estimate_pruned_members` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.get_all_emojis`` | :attr:`Client.emojis` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.get_bans`` | :meth:`Guild.bans` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.get_message`` | :meth:`abc.Messageable.get_message` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.get_reaction_users`` | :meth:`Reaction.users` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.invites_from`` | :meth:`abc.GuildChannel.invites` or :meth:`Guild.invites` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.join_voice_channel`` | :meth:`VoiceChannel.connect` (see :ref:`migrating_1_0_voice`) | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.kick`` | :meth:`Guild.kick` or :meth:`Member.kick` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.leave_server`` | :meth:`Guild.leave` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.logs_from`` | :meth:`abc.Messageable.history` (see :ref:`migrating_1_0_async_iter`) | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.move_channel`` | :meth:`TextChannel.edit` or :meth:`VoiceChannel.edit` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.move_member`` | :meth:`Member.edit` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.move_role`` | :meth:`Role.edit` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.pin_message`` | :meth:`Message.pin` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.pins_from`` | :meth:`abc.Messageable.pins` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.prune_members`` | :meth:`Guild.prune_members` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.purge_from`` | :meth:`TextChannel.purge` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.remove_reaction`` | :meth:`Message.remove_reaction` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.remove_roles`` | :meth:`Member.remove_roles` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.replace_roles`` | :meth:`Member.edit` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.send_file`` | :meth:`abc.Messageable.send` (see :ref:`migrating_1_0_sending_messages`) | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.send_message`` | :meth:`abc.Messageable.send` (see :ref:`migrating_1_0_sending_messages`) | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.send_typing`` | :meth:`abc.Messageable.trigger_typing` (use :meth:`abc.Messageable.typing`) | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.server_voice_state`` | :meth:`Member.edit` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.start_private_message`` | :meth:`User.create_dm` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.unban`` | :meth:`Guild.unban` or :meth:`Member.unban` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.unpin_message`` | :meth:`Message.unpin` | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.wait_for_message`` | :meth:`Client.wait_for` (see :ref:`migrating_1_0_wait_for`) | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.wait_for_reaction`` | :meth:`Client.wait_for` (see :ref:`migrating_1_0_wait_for`) | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.wait_until_login`` | Removed | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.messages`` | Removed | ++---------------------------------------+------------------------------------------------------------------------------+ +| ``Client.wait_until_ready`` | No change | ++---------------------------------------+------------------------------------------------------------------------------+ + +Property Changes +~~~~~~~~~~~~~~~~~~ + +In order to be a bit more consistent, certain things that were properties were changed to methods instead. + +The following are now methods instead of properties (requires parentheses): + +- :meth:`Role.is_default` +- :meth:`Client.is_ready` +- :meth:`Client.is_closed` + +Dict Value Change +~~~~~~~~~~~~~~~~~~~~~ + +Prior to v1.0 some aggregating properties that retrieved models would return "dict view" objects. + +As a consequence, when the dict would change size while you would iterate over it, a RuntimeError would +be raised and crash the task. To alleviate this, the "dict view" objects were changed into lists. + +The following views were changed to a list: + +- :attr:`Client.guilds` +- :attr:`Client.users` (new in v1.0) +- :attr:`Client.emojis` (new in v1.0) +- :attr:`Guild.channels` +- :attr:`Guild.text_channels` (new in v1.0) +- :attr:`Guild.voice_channels` (new in v1.0) +- :attr:`Guild.emojis` +- :attr:`Guild.members` + +Voice State Changes +~~~~~~~~~~~~~~~~~~~~~ + +Earlier, in v0.11.0 a :class:`VoiceState` class was added to refer to voice states along with a +:attr:`Member.voice` attribute to refer to it. + +However, it was transparent to the user. In an effort to make the library save more memory, the +voice state change is now more visible. + +The only way to access voice attributes is via the :attr:`Member.voice` attribute. Note that if +the member does not have a voice state this attribute can be ``None``. + +Quick example: :: + + # before + member.deaf + member.voice.voice_channel + + # after + if member.voice: # can be None + member.voice.deaf + member.voice.channel + + +User and Member Type Split +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In v1.0 to save memory, :class:`User` and :class:`Member` are no longer inherited. Instead, they are "flattened" +by having equivalent properties that map out to the functional underlying :class:`User`. Thus, there is no functional +change in how they are used. However this breaks ``isinstance`` checks and thus is something to keep in mind. + +These memory savings were accomplished by having a global :class:`User` cache, and as a positive consequence you +can now easily fetch a :class:`User` by their ID by using the new :meth:`Client.get_user`. You can also get a list +of all :class:`User` your client can see with :attr:`Client.users`. + +.. _migrating_1_0_channel_split: + +Channel Type Split +~~~~~~~~~~~~~~~~~~~~~ + +Prior to v1.0, channels were two different types, ``Channel`` and ``PrivateChannel`` with a ``is_private`` +property to help differentiate between them. + +In order to save memory the channels have been split into 4 different types: + +- :class:`TextChannel` for guild text channels. +- :class:`VoiceChannel` for guild voice channels. +- :class:`DMChannel` for DM channels with members. +- :class:`GroupChannel` for Group DM channels with members. + +With this split came the removal of the ``is_private`` attribute. You should now use ``isinstance``. + +The types are split into two different :ref:`discord_api_abcs`: + +- :class:`abc.GuildChannel` for guild channels. +- :class:`abc.PrivateChannel` for private channels (DMs and group DMs). + +So to check if something is a guild channel you would do: :: + + isinstance(channel, discord.abc.GuildChannel) + +And to check if it's a private channel you would do: :: + + isinstance(channel, discord.abc.PrivateChannel) + +Of course, if you're looking for only a specific type you can pass that too, e.g. :: + + isinstance(channel, discord.TextChannel) + +With this type split also came event changes, which are enumerated in :ref:`migrating_1_0_event_changes`. + + +Miscellaneous Model Changes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +There were lots of other things added or removed in the models in general. + +They will be enumerated here. + +**Removed** + +- :meth:`Client.login` no longer accepts email and password logins. + + - Use a token and ``bot=False``. + +- ``Client.get_all_emojis`` + + - Use :attr:`Client.emojis` instead. + +- ``Client.wait_for_message`` and ``Client.wait_for_reaction`` are gone. + + - Use :meth:`Client.wait_for` instead. + +- ``Channel.voice_members`` + + - Use :attr:`VoiceChannel.members` instead. + +- ``Channel.is_private`` + + - Use ``isinstance`` instead with one of the :ref:`discord_api_abcs` instead. + - e.g. ``isinstance(channel, discord.abc.GuildChannel)`` will check if it isn't a private channel. + +- ``Client.accept_invite`` + + - There is no replacement for this one. This functionality is deprecated API wise. + +- ``Guild.default_channel`` / ``Server.default_channel`` and ``Channel.is_default`` + + - The concept of a default channel was removed from Discord. + See `#329 `_. + +- ``Message.edited_timestamp`` + + - Use :attr:`Message.edited_at` instead. + +- ``Message.timestamp`` + + - Use :attr:`Message.created_at` instead. + +- ``Colour.to_tuple()`` + + - Use :meth:`Colour.to_rgb` instead. + +- ``Permissions.view_audit_logs`` + + - Use :attr:`Permissions.view_audit_log` instead. + +**Changed** + +- :attr:`Member.avatar_url` and :attr:`User.avatar_url` now return the default avatar if a custom one is not set. +- :attr:`Message.embeds` is now a list of :class:`Embed` instead of ``dict`` objects. +- :attr:`Message.attachments` is now a list of :class:`Attachment` instead of ``dict`` object. + +**Added** + +- :class:`Attachment` to represent a discord attachment. +- :class:`CategoryChannel` to represent a channel category. +- :attr:`VoiceChannel.members` for fetching members connected to a voice channel. +- :attr:`TextChannel.members` for fetching members that can see the channel. +- :attr:`Role.members` for fetching members that have the role. +- :attr:`Guild.text_channels` for fetching text channels only. +- :attr:`Guild.voice_channels` for fetching voice channels only. +- :attr:`Guild.categories` for fetching channel categories only. +- :attr:`TextChannel.category` and :attr:`VoiceChannel.category` to get the category a channel belongs to. +- :meth:`Guild.by_category` to get channels grouped by their category. +- :attr:`Guild.chunked` to check member chunking status. +- :attr:`Guild.explicit_content_filter` to fetch the content filter. +- :attr:`Guild.shard_id` to get a guild's Shard ID if you're sharding. +- :attr:`Client.users` to get all visible :class:`User` instances. +- :meth:`Client.get_user` to get a :class:`User` by ID. +- :meth:`User.avatar_url_as` to get an avatar in a specific size or format. +- :meth:`Guild.vanity_invite` to fetch the guild's vanity invite. +- :meth:`Guild.audit_logs` to fetch the guild's audit logs. +- :attr:`Message.webhook_id` to fetch the message's webhook ID. +- :meth:`TextChannel.is_nsfw` to check if a text channel is NSFW. +- :meth:`Colour.from_rgb` to construct a :class:`Colour` from RGB tuple. + +.. _migrating_1_0_sending_messages: + +Sending Messages +------------------ + +One of the changes that were done was the merger of the previous ``Client.send_message`` and ``Client.send_file`` +functionality into a single method, :meth:`~abc.Messageable.send`. + +Basically: :: + + # before + await client.send_message(channel, 'Hello') + + # after + await channel.send('Hello') + +This supports everything that the old ``send_message`` supported such as embeds: :: + + e = discord.Embed(title='foo') + await channel.send('Hello', embed=e) + +There is a caveat with sending files however, as this functionality was expanded to support multiple +file attachments, you must now use a :class:`File` pseudo-namedtuple to upload a single file. :: + + # before + await client.send_file(channel, 'cool.png', filename='testing.png', content='Hello') + + # after + await channel.send('Hello', file=discord.File('cool.png', 'testing.png')) + +This change was to facilitate multiple file uploads: :: + + my_files = [ + discord.File('cool.png', 'testing.png'), + discord.File(some_fp, 'cool_filename.png'), + ] + + await channel.send('Your images:', files=my_files) + +.. _migrating_1_0_async_iter: + +Asynchronous Iterators +------------------------ + +Prior to v1.0, certain functions like ``Client.logs_from`` would return a different type if done in Python 3.4 or 3.5+. + +In v1.0, this change has been reverted and will now return a singular type meeting an abstract concept called +:class:`AsyncIterator`. + +This allows you to iterate over it like normal in Python 3.5+: :: + + async for message in channel.history(): + print(message) + +Or turn it into a list for either Python 3.4 or 3.5+: :: + + messages = await channel.history().flatten() # use yield from for 3.4! + for message in messages: + print(message) + +A handy aspect of returning :class:`AsyncIterator` is that it allows you to chain functions together such as +:meth:`AsyncIterator.map` or :meth:`AsyncIterator.filter`: :: + + async for m_id in channel.history().filter(lambda m: m.author == client.user).map(lambda m: m.id): + print(m_id) + +The functions passed to :meth:`AsyncIterator.map` or :meth:`AsyncIterator.filter` can be either coroutines or regular +functions. + +You can also get single elements a la :func:`discord.utils.find` or :func:`discord.utils.get` via +:meth:`AsyncIterator.get` or :meth:`AsyncIterator.find`: :: + + my_last_message = await channel.history().get(author=client.user) + +The following return :class:`AsyncIterator`: + +- :meth:`abc.Messageable.history` +- :meth:`Guild.audit_logs` +- :meth:`Reaction.users` + +.. _migrating_1_0_event_changes: + +Event Changes +-------------- + +A lot of events have gone through some changes. + +Many events with ``server`` in the name were changed to use ``guild`` instead. + +Before: + +- ``on_server_join`` +- ``on_server_remove`` +- ``on_server_update`` +- ``on_server_role_create`` +- ``on_server_role_delete`` +- ``on_server_role_update`` +- ``on_server_emojis_update`` +- ``on_server_available`` +- ``on_server_unavailable`` + +After: + +- :func:`on_guild_join` +- :func:`on_guild_remove` +- :func:`on_guild_update` +- :func:`on_guild_role_create` +- :func:`on_guild_role_delete` +- :func:`on_guild_role_update` +- :func:`on_guild_emojis_update` +- :func:`on_guild_available` +- :func:`on_guild_unavailable` + + +The :func:`on_voice_state_update` event has received an argument change. + +Before: :: + + async def on_voice_state_update(before, after) + +After: :: + + async def on_voice_state_update(member, before, after) + +Instead of two :class:`Member` objects, the new event takes one :class:`Member` object and two :class:`VoiceState` objects. + +The :func:`on_guild_emojis_update` event has received an argument change. + +Before: :: + + async def on_guild_emojis_update(before, after) + +After: :: + + async def on_guild_emojis_update(guild, before, after) + +The first argument is now the :class:`Guild` that the emojis were updated from. + +The :func:`on_member_ban` event has received an argument change as well: + +Before: :: + + async def on_member_ban(member) + +After: :: + + async def on_member_ban(guild, user) + +As part of the change, the event can either receive a :class:`User` or :class:`Member`. To help in the cases that have +:class:`User`, the :class:`Guild` is provided as the first parameter. + +The ``on_channel_`` events have received a type level split (see :ref:`migrating_1_0_channel_split`). + +Before: + +- ``on_channel_delete`` +- ``on_channel_create`` +- ``on_channel_update`` + +After: + +- :func:`on_guild_channel_delete` +- :func:`on_guild_channel_create` +- :func:`on_guild_channel_update` +- :func:`on_private_channel_delete` +- :func:`on_private_channel_create` +- :func:`on_private_channel_update` + +The ``on_guild_channel_`` events correspond to :class:`abc.GuildChannel` being updated (i.e. :class:`TextChannel` +and :class:`VoiceChannel`) and the ``on_private_channel_`` events correspond to :class:`abc.PrivateChannel` being +updated (i.e. :class:`DMChannel` and :class:`GroupChannel`). + +.. _migrating_1_0_voice: + +Voice Changes +--------------- + +Voice sending has gone through a complete redesign. + +In particular: + +- Connection is done through :meth:`VoiceChannel.connect` instead of ``Client.join_voice_channel``. +- You no longer create players and operate on them (you no longer store them). +- You instead request :class:`VoiceClient` to play an :class:`AudioSource` via :meth:`VoiceClient.play`. +- There are different built-in :class:`AudioSource`\s. + + - :class:`FFmpegPCMAudio` is the equivalent of ``create_ffmpeg_player`` + +- create_ffmpeg_player/create_stream_player/create_ytdl_player have all been removed. + + - The goal is to create :class:`AudioSource` instead. + +- Using :meth:`VoiceClient.play` will not return an ``AudioPlayer``. + + - Instead, it's "flattened" like :class:`User` -> :class:`Member` is. + +- The ``after`` parameter now takes a single parameter (the error). + +Basically: + +Before: :: + + vc = await client.join_voice_channel(channel) + player = vc.create_ffmpeg_player('testing.mp3', after=lambda: print('done')) + player.start() + + player.is_playing() + player.pause() + player.resume() + player.stop() + # ... + +After: :: + + vc = await channel.connect() + vc.play(discord.FFmpegPCMAudio('testing.mp3'), after=lambda e: print('done', e)) + vc.is_playing() + vc.pause() + vc.resume() + vc.stop() + # ... + +With the changed :class:`AudioSource` design, you can now change the source that the :class:`VoiceClient` is +playing at runtime via :attr:`VoiceClient.source`. + +For example, you can add a :class:`PCMVolumeTransformer` to allow changing the volume: :: + + vc.source = discord.PCMVolumeTransformer(vc.source) + vc.source.volume = 0.6 + +An added benefit of the redesign is that it will be much more resilient towards reconnections: + +- The voice websocket will now automatically re-connect and re-do the handshake when disconnected. +- The initial connect handshake will now retry up to 5 times so you no longer get as many ``asyncio.TimeoutError``. +- Audio will now stop and resume when a disconnect is found. + + - This includes changing voice regions etc. + + +.. _migrating_1_0_wait_for: + +Waiting For Events +-------------------- + +Prior to v1.0, the machinery for waiting for an event outside of the event itself was done through two different +functions, ``Client.wait_for_message`` and ``Client.wait_for_reaction``. One problem with one such approach is that it did +not allow you to wait for events outside of the ones provided by the library. + +In v1.0 the concept of waiting for another event has been generalised to work with any event as :meth:`Client.wait_for`. + +For example, to wait for a message: :: + + # before + msg = await client.wait_for_message(author=message.author, channel=message.channel) + + # after + def pred(m): + return m.author == message.author and m.channel == message.channel + + msg = await client.wait_for('message', check=pred) + +To facilitate multiple returns, :meth:`Client.wait_for` returns either a single argument, no arguments, or a tuple of +arguments. + +For example, to wait for a reaction: :: + + reaction, user = await client.wait_for('reaction_add', check=lambda r, u: u.id == 176995180300206080) + + # use user and reaction + +Since this function now can return multiple arguments, the ``timeout`` parameter will now raise a ``asyncio.TimeoutError`` +when reached instead of setting the return to ``None``. For example: + +.. code-block:: python3 + + def pred(m): + return m.author == message.author and m.channel == message.channel + + try: + + msg = await client.wait_for('message', check=pred, timeout=60.0) + except asyncio.TimeoutError: + await channel.send('You took too long...') + else: + await channel.send('You said {0.content}, {0.author}.'.format(msg)) + +Upgraded Dependencies +----------------------- + +Following v1.0 of the library, we've updated our requirements to ``aiohttp`` v2.0 or higher. + +Since this is a backwards incompatible change, it is recommended that you see the +`changes `_ and the +`migrating `_ pages for details on the breaking changes in +``aiohttp``. + +Of the most significant for common users is the removal of helper functions such as: + +- ``aiohttp.get`` +- ``aiohttp.post`` +- ``aiohttp.delete`` +- ``aiohttp.patch`` +- ``aiohttp.head`` +- ``aiohttp.put`` +- ``aiohttp.request`` + +It is recommended that you create a session instead: :: + + async with aiohttp.ClientSession() as sess: + async with sess.get('url') as resp: + # work with resp + +Since it is better to not create a session for every request, you should store it in a variable and then call +``session.close`` on it when it needs to be disposed. + +Sharding +---------- + +The library has received significant changes on how it handles sharding and now has sharding as a first-class citizen. + +If using a Bot account and you want to shard your bot in a single process then you can use the :class:`AutoShardedClient`. + +This class allows you to use sharding without having to launch multiple processes or deal with complicated IPC. + +It should be noted that **the sharded client does not support user accounts**. This is due to the changes in connection +logic and state handling. + +Usage is as simple as doing: :: + + client = discord.AutoShardedClient() + +instead of using :class:`Client`. + +This will launch as many shards as your bot needs using the ``/gateway/bot`` endpoint, which allocates about 1000 guilds +per shard. + +If you want more control over the sharding you can specify ``shard_count`` and ``shard_ids``. :: + + # launch 10 shards regardless + client = discord.AutoShardedClient(shard_count=10) + + # launch specific shard IDs in this process + client = discord.AutoShardedClient(shard_count=10, shard_ids=(1, 2, 5, 6)) + +For users of the command extension, there is also :class:`~ext.commands.AutoShardedBot` which behaves similarly. + +Connection Improvements +------------------------- + +In v1.0, the auto reconnection logic has been powered up significantly. + +:meth:`Client.connect` has gained a new keyword argument, ``reconnect`` that defaults to ``True`` which controls +the reconnect logic. When enabled, the client will automatically reconnect in all instances of your internet going +offline or Discord going offline with exponential back-off. + +:meth:`Client.run` and :meth:`Client.start` gains this keyword argument as well, but for most cases you will not +need to specify it unless turning it off. + +.. _migrating_1_0_commands: + +Command Extension Changes +-------------------------- + +Due to the :ref:`migrating_1_0_model_state` changes, some of the design of the extension module had to +undergo some design changes as well. + +Context Changes +~~~~~~~~~~~~~~~~~ + +In v1.0, the :class:`.Context` has received a lot of changes with how it's retrieved and used. + +The biggest change is that ``pass_context=True`` no longer exists, :class:`.Context` is always passed. Ergo: + +.. code-block:: python3 + + # before + @bot.command() + async def foo(): + await bot.say('Hello') + + # after + @bot.command() + async def foo(ctx): + await ctx.send('Hello') + +The reason for this is because :class:`~ext.commands.Context` now meets the requirements of :class:`abc.Messageable`. This +makes it have similar functionality to :class:`TextChannel` or :class:`DMChannel`. Using :meth:`~.Context.send` +will either DM the user in a DM context or send a message in the channel it was in, similar to the old ``bot.say`` +functionality. The old helpers have been removed in favour of the new :class:`abc.Messageable` interface. See +:ref:`migrating_1_0_removed_helpers` for more information. + +Since the :class:`~ext.commands.Context` is now by default passed, several shortcuts have been added: + +**New Shortcuts** + +- :attr:`~ext.commands.Context.author` is a shortcut for ``ctx.message.author``. +- :attr:`~ext.commands.Context.guild` is a shortcut for ``ctx.message.guild``. +- :attr:`~ext.commands.Context.channel` is a shortcut for ``ctx.message.channel``. +- :attr:`~ext.commands.Context.me` is a shortcut for ``ctx.message.guild.me`` or ``ctx.bot.user``. +- :attr:`~ext.commands.Context.voice_client` is a shortcut for ``ctx.message.guild.voice_client``. + +**New Functionality** + +- :meth:`~.Context.reinvoke` to invoke a command again. + + - This is useful for bypassing cooldowns. + +Subclassing Context +++++++++++++++++++++ + +In v1.0, there is now the ability to subclass :class:`~ext.commands.Context` and use it instead of the default +provided one. + +For example, if you want to add some functionality to the context: + +.. code-block:: python3 + + class MyContext(commands.Context): + @property + def secret(self): + return 'my secret here' + +Then you can use :meth:`~ext.commands.Bot.get_context` inside :func:`on_message` with combination with +:meth:`~ext.commands.Bot.invoke` to use your custom context: + +.. code-block:: python3 + + class MyBot(commands.Bot): + async def on_message(self, message): + ctx = await self.get_context(message, cls=MyContext) + await self.invoke(ctx) + +Now inside your commands you will have access to your custom context: + +.. code-block:: python3 + + @bot.command() + async def secret(ctx): + await ctx.send(ctx.secret) + +.. _migrating_1_0_removed_helpers: + +Removed Helpers ++++++++++++++++++ + +With the new :class:`.Context` changes, a lot of message sending helpers have been removed. + +For a full list of changes, see below: + ++-----------------+------------------------------------------------------------+ +| Before | After | ++-----------------+------------------------------------------------------------+ +| ``Bot.say`` | :meth:`.Context.send` | ++-----------------+------------------------------------------------------------+ +| ``Bot.upload`` | :meth:`.Context.send` | ++-----------------+------------------------------------------------------------+ +| ``Bot.whisper`` | ``ctx.author.send`` | ++-----------------+------------------------------------------------------------+ +| ``Bot.type`` | :meth:`.Context.typing` or :meth:`.Context.trigger_typing` | ++-----------------+------------------------------------------------------------+ +| ``Bot.reply`` | No replacement. | ++-----------------+------------------------------------------------------------+ + +Command Changes +~~~~~~~~~~~~~~~~~ + +As mentioned earlier, the first command change is that ``pass_context=True`` no longer +exists, so there is no need to pass this as a parameter. + +Another change is the removal of ``no_pm=True``. Instead, use the new :func:`~ext.commands.guild_only` built-in +check. + +The ``commands`` attribute of :class:`~ext.commands.Bot` and :class:`~ext.commands.Group` have been changed from a +dictionary to a set that does not have aliases. To retrieve the previous dictionary behaviour, use ``all_commands`` instead. + +Command instances have gained new attributes and properties: + +1. :attr:`~ext.commands.Command.signature` to get the signature of the command. +2. :attr:`~.Command.usage`, an attribute to override the default signature. +3. :attr:`~.Command.root_parent` to get the root parent group of a subcommand. + +For :class:`~ext.commands.Group` and :class:`~ext.commands.Bot` the following changed: + +- Changed :attr:`~.GroupMixin.commands` to be a ``set`` without aliases. + + - Use :attr:`~.GroupMixin.all_commands` to get the old ``dict`` with all commands. + +Check Changes +~~~~~~~~~~~~~~~ + +Prior to v1.0, :func:`~ext.commands.check`\s could only be synchronous. As of v1.0 checks can now be coroutines. + +Along with this change, a couple new checks were added. + +- :func:`~ext.commands.guild_only` replaces the old ``no_pm=True`` functionality. +- :func:`~ext.commands.is_owner` uses the :meth:`Client.application_info` endpoint by default to fetch owner ID. + + - This is actually powered by a different function, :meth:`~ext.commands.Bot.is_owner`. + - You can set the owner ID yourself by setting :attr:`.Bot.owner_id`. + +- :func:`~ext.commands.is_nsfw` checks if the channel the command is in is a NSFW channel. + + - This is powered by the new :meth:`TextChannel.is_nsfw` method. + +Event Changes +~~~~~~~~~~~~~~~ + +All command extension events have changed. + +Before: :: + + on_command(command, ctx) + on_command_completion(command, ctx) + on_command_error(error, ctx) + +After: :: + + on_command(ctx) + on_command_completion(ctx) + on_command_error(ctx, error) + +The extraneous ``command`` parameter in :func:`.on_command` and :func:`.on_command_completion` +have been removed. The :class:`~ext.commands.Command` instance was not kept up-to date so it was incorrect. In order to get +the up to date :class:`~ext.commands.Command` instance, use the :attr:`.Context.command` +attribute. + +The error handlers, either :meth:`.Command.error` or :func:`.on_command_error`, +have been re-ordered to use the :class:`~ext.commands.Context` as its first parameter to be consistent with other events +and commands. + +Cog Changes +~~~~~~~~~~~~~ + +Cog special methods have changed slightly. + +The previous ``__check`` special method has been renamed to ``__global_check`` to make it more clear that it's a global +check. + +To complement the new ``__global_check`` there is now a new ``__local_check`` to facilitate a check that will run on +every command in the cog. There is also a ``__global_check_once``, which is similar to a global check instead it is only +called once per :meth:`.Bot.invoke` call rather than every :meth:`.Command.invoke` call. Practically, the difference is +only for black-listing users or channels without constantly opening a database connection. + +Cogs have also gained a ``__before_invoke`` and ``__after_invoke`` cog local before and after invocation hook, which +can be seen in :ref:`migrating_1_0_before_after_hook`. + +The final addition is cog-local error handler, ``__error``, that is run on every command in the cog. + +An example cog with every special method registered is as follows: :: + + class Cog: + def __unload(self): + print('cleanup goes here') + + def __global_check(self, ctx): + print('cog global check') + return True + + def __global_check_once(self, ctx): + print('cog global check once') + return True + + async def __local_check(self, ctx): + print('cog local check') + return await ctx.bot.is_owner(ctx.author) + + async def __error(self, ctx, error): + print('Error in {0.command.qualified_name}: {1}'.format(ctx, error)) + + async def __before_invoke(self, ctx): + print('cog local before: {0.command.qualified_name}'.format(ctx)) + + async def __after_invoke(self, ctx): + print('cog local after: {0.command.qualified_name}'.format(ctx)) + + +.. _migrating_1_0_before_after_hook: + +Before and After Invocation Hooks +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Commands have gained new before and after invocation hooks that allow you to do an action before and after a command is +run. + +They take a single parameter, :class:`~ext.commands.Context` and they must be a coroutine. + +They are on a global, per-cog, or per-command basis. + +Basically: :: + + + # global hooks: + + @bot.before_invoke + async def before_any_command(ctx): + # do something before a command is called + pass + + @bot.after_invoke + async def after_any_command(ctx): + # do something after a command is called + pass + +The after invocation is hook always called, **regardless of an error in the command**. This makes it ideal for some error +handling or clean up of certain resources such a database connection. + +The per-command registration is as follows: :: + + @bot.command() + async def foo(ctx): + await ctx.send('foo') + + @foo.before_invoke + async def before_foo_command(ctx): + # do something before the foo command is called + pass + + @foo.after_invoke + async def after_foo_command(ctx): + # do something after the foo command is called + pass + +The special cog method for these is ``__before_invoke`` and ``__after_invoke``, e.g.: :: + + class Cog: + async def __before_invoke(self, ctx): + ctx.secret_cog_data = 'foo' + + async def __after_invoke(self, ctx): + print('{0.command} is done...'.format(ctx)) + + @commands.command() + async def foo(self, ctx): + await ctx.send(ctx.secret_cog_data) + +To check if a command failed in the after invocation hook, you can use +:attr:`.Context.command_failed`. + +The invocation order is as follows: + +1. Command local before invocation hook +2. Cog local before invocation hook +3. Global before invocation hook +4. The actual command +5. Command local after invocation hook +6. Cog local after invocation hook +7. Global after invocation hook + +Converter Changes +~~~~~~~~~~~~~~~~~~~ + +Prior to v1.0, a converter was a type hint that could be a callable that could be invoked +with a singular argument denoting the argument passed by the user as a string. + +This system was eventually expanded to support a :class:`~ext.commands.Converter` system to +allow plugging in the :class:`~ext.commands.Context` and do more complicated conversions such +as the built-in "discord" converters. + +In v1.0 this converter system was revamped to allow instances of :class:`~ext.commands.Converter` derived +classes to be passed. For consistency, the :meth:`~ext.commands.Converter.convert` method was changed to +always be a coroutine and will now take the two arguments as parameters. + +Essentially, before: :: + + class MyConverter(commands.Converter): + def convert(self): + return self.ctx.message.server.me + +After: :: + + class MyConverter(commands.Converter): + async def convert(self, ctx, argument): + return ctx.me + +The command framework also got a couple new converters: + +- :class:`~ext.commands.clean_content` this is akin to :attr:`Message.clean_content` which scrubs mentions. +- :class:`~ext.commands.UserConverter` will now appropriately convert :class:`User` only. +- ``ChannelConverter`` is now split into two different converters. + + - :class:`~ext.commands.TextChannelConverter` for :class:`TextChannel`. + - :class:`~ext.commands.VoiceChannelConverter` for :class:`VoiceChannel`. diff --git a/discord.py-rewrite/docs/migrating_to_async.rst b/discord.py-rewrite/docs/migrating_to_async.rst new file mode 100644 index 0000000..109ad40 --- /dev/null +++ b/discord.py-rewrite/docs/migrating_to_async.rst @@ -0,0 +1,322 @@ +:orphan: + +.. currentmodule:: discord + +.. _migrating-to-async: + +Migrating to v0.10.0 +====================== + +v0.10.0 is one of the biggest breaking changes in the library due to massive +fundamental changes in how the library operates. + +The biggest major change is that the library has dropped support to all versions prior to +Python 3.4.2. This was made to support ``asyncio``, in which more detail can be seen +:issue:`in the corresponding issue <50>`. To reiterate this, the implication is that +**python version 2.7 and 3.3 are no longer supported**. + +Below are all the other major changes from v0.9.0 to v0.10.0. + +Event Registration +-------------------- + +All events before were registered using :meth:`Client.event`. While this is still +possible, the events must be decorated with ``@asyncio.coroutine``. + +Before: + +.. code-block:: python3 + + @client.event + def on_message(message): + pass + +After: + +.. code-block:: python3 + + @client.event + @asyncio.coroutine + def on_message(message): + pass + +Or in Python 3.5+: + +.. code-block:: python3 + + @client.event + async def on_message(message): + pass + +Because there is a lot of typing, a utility decorator (:meth:`Client.async_event`) is provided +for easier registration. For example: + +.. code-block:: python3 + + @client.async_event + def on_message(message): + pass + + +Be aware however, that this is still a coroutine and your other functions that are coroutines must +be decorated with ``@asyncio.coroutine`` or be ``async def``. + +Event Changes +-------------- + +Some events in v0.9.0 were considered pretty useless due to having no separate states. The main +events that were changed were the ``_update`` events since previously they had no context on what +was changed. + +Before: + +.. code-block:: python3 + + def on_channel_update(channel): pass + def on_member_update(member): pass + def on_status(member): pass + def on_server_role_update(role): pass + def on_voice_state_update(member): pass + def on_socket_raw_send(payload, is_binary): pass + + +After: + +.. code-block:: python3 + + def on_channel_update(before, after): pass + def on_member_update(before, after): pass + def on_server_role_update(before, after): pass + def on_voice_state_update(before, after): pass + def on_socket_raw_send(payload): pass + +Note that ``on_status`` was removed. If you want its functionality, use :func:`on_member_update`. +See :ref:`discord-api-events` for more information. Other removed events include ``on_socket_closed``, ``on_socket_receive``, and ``on_socket_opened``. + + +Coroutines +----------- + +The biggest change that the library went through is that almost every function in :class:`Client` +was changed to be a `coroutine `_. Functions +that are marked as a coroutine in the documentation must be awaited from or yielded from in order +for the computation to be done. For example... + +Before: + +.. code-block:: python3 + + client.send_message(message.channel, 'Hello') + +After: + +.. code-block:: python3 + + yield from client.send_message(message.channel, 'Hello') + + # or in python 3.5+ + await client.send_message(message.channel, 'Hello') + +In order for you to ``yield from`` or ``await`` a coroutine then your function must be decorated +with ``@asyncio.coroutine`` or ``async def``. + +Iterables +---------- + +For performance reasons, many of the internal data structures were changed into a dictionary to support faster +lookup. As a consequence, this meant that some lists that were exposed via the API have changed into iterables +and not sequences. In short, this means that certain attributes now only support iteration and not any of the +sequence functions. + +The affected attributes are as follows: + +- :attr:`Client.servers` +- :attr:`Client.private_channels` +- :attr:`Server.channels` +- :attr:`Server.members` + +Some examples of previously valid behaviour that is now invalid + +.. code-block:: python3 + + if client.servers[0].name == "test": + # do something + +Since they are no longer ``list``\s, they no longer support indexing or any operation other than iterating. +In order to get the old behaviour you should explicitly cast it to a list. + +.. code-block:: python3 + + servers = list(client.servers) + # work with servers + +.. warning:: + + Due to internal changes of the structure, the order you receive the data in + is not in a guaranteed order. + +Enumerations +------------ + +Due to dropping support for versions lower than Python 3.4.2, the library can now use +`enumerations `_ in places where it makes sense. + +The common places where this was changed was in the server region, member status, and channel type. + +Before: + +.. code-block:: python3 + + server.region == 'us-west' + member.status == 'online' + channel.type == 'text' + +After: + +.. code-block:: python3 + + server.region == discord.ServerRegion.us_west + member.status = discord.Status.online + channel.type == discord.ChannelType.text + +The main reason for this change was to reduce the use of finicky strings in the API as this +could give users a false sense of power. More information can be found in the :ref:`discord-api-enums` page. + +Properties +----------- + +A lot of function calls that returned constant values were changed into Python properties for ease of use +in format strings. + +The following functions were changed into properties: + ++----------------------------------------+--------------------------------------+ +| Before | After | ++----------------------------------------+--------------------------------------+ +| ``User.avatar_url()`` | :attr:`User.avatar_url` | ++----------------------------------------+--------------------------------------+ +| ``User.mention()`` | :attr:`User.mention` | ++----------------------------------------+--------------------------------------+ +| ``Channel.mention()`` | :attr:`Channel.mention` | ++----------------------------------------+--------------------------------------+ +| ``Channel.is_default_channel()`` | :attr:`Channel.is_default` | ++----------------------------------------+--------------------------------------+ +| ``Role.is_everyone()`` | :attr:`Role.is_everyone` | ++----------------------------------------+--------------------------------------+ +| ``Server.get_default_role()`` | :attr:`Server.default_role` | ++----------------------------------------+--------------------------------------+ +| ``Server.icon_url()`` | :attr:`Server.icon_url` | ++----------------------------------------+--------------------------------------+ +| ``Server.get_default_channel()`` | :attr:`Server.default_channel` | ++----------------------------------------+--------------------------------------+ +| ``Message.get_raw_mentions()`` | :attr:`Message.raw_mentions` | ++----------------------------------------+--------------------------------------+ +| ``Message.get_raw_channel_mentions()`` | :attr:`Message.raw_channel_mentions` | ++----------------------------------------+--------------------------------------+ + +Member Management +------------------- + +Functions that involved banning and kicking were changed. + ++--------------------------------+--------------------------+ +| Before | After | ++--------------------------------+--------------------------+ +| ``Client.ban(server, user)`` | ``Client.ban(member)`` | ++--------------------------------+--------------------------+ +| ``Client.kick(server, user)`` | ``Client.kick(member)`` | ++--------------------------------+--------------------------+ + +.. migrating-renames: + +Renamed Functions +------------------- + +Functions have been renamed. + ++------------------------------------+-------------------------------------------+ +| Before | After | ++------------------------------------+-------------------------------------------+ +| ``Client.set_channel_permissions`` | :meth:`Client.edit_channel_permissions` | ++------------------------------------+-------------------------------------------+ + +All the :class:`Permissions` related attributes have been renamed and the `can_` prefix has been +dropped. So for example, ``can_manage_messages`` has become ``manage_messages``. + +Forced Keyword Arguments +------------------------- + +Since 3.0+ of Python, we can now force questions to take in forced keyword arguments. A keyword argument is when you +explicitly specify the name of the variable and assign to it, for example: ``foo(name='test')``. Due to this support, +some functions in the library were changed to force things to take said keyword arguments. This is to reduce errors of +knowing the argument order and the issues that could arise from them. + +The following parameters are now exclusively keyword arguments: + +- :meth:`Client.send_message` + - ``tts`` +- :meth:`Client.logs_from` + - ``before`` + - ``after`` +- :meth:`Client.edit_channel_permissions` + - ``allow`` + - ``deny`` + +In the documentation you can tell if a function parameter is a forced keyword argument if it is after ``\*,`` +in the function signature. + +.. _migrating-running: + +Running the Client +-------------------- + +In earlier versions of discord.py, ``client.run()`` was a blocking call to the main thread +that called it. In v0.10.0 it is still a blocking call but it handles the event loop for you. +However, in order to do that you must pass in your credentials to :meth:`Client.run`. + +Basically, before: + +.. code-block:: python3 + + client.login('token') + client.run() + +After: + +.. code-block:: python3 + + client.run('token') + +.. warning:: + + Like in the older ``Client.run`` function, the newer one must be the one of + the last functions to call. This is because the function is **blocking**. Registering + events or doing anything after :meth:`Client.run` will not execute until the function + returns. + +This is a utility function that abstracts the event loop for you. There's no need for +the run call to be blocking and out of your control. Indeed, if you want control of the +event loop then doing so is quite straightforward: + +.. code-block:: python3 + + import discord + import asyncio + + client = discord.Client() + + @asyncio.coroutine + def main_task(): + yield from client.login('token') + yield from client.connect() + + loop = asyncio.get_event_loop() + try: + loop.run_until_complete(main_task()) + except: + loop.run_until_complete(client.logout()) + finally: + loop.close() + + + diff --git a/discord.py-rewrite/docs/quickstart.rst b/discord.py-rewrite/docs/quickstart.rst new file mode 100644 index 0000000..c1ffb85 --- /dev/null +++ b/discord.py-rewrite/docs/quickstart.rst @@ -0,0 +1,76 @@ +.. _quickstart: + +.. currentmodule:: discord + +Quickstart +============ + +This page gives a brief introduction to the library. It assumes you have the library installed, +if you don't check the :ref:`installing` portion. + +A Minimal Bot +--------------- + +Let's make a bot that replies to a specific message and walk you through it. + +It looks something like this: + +.. code-block:: python3 + + import discord + + client = discord.Client() + + @client.event + async def on_ready(): + print('We have logged in as {0.user}'.format(client)) + + @client.event + async def on_message(message): + if message.author == client.user: + return + + if message.content.startswith('$hello'): + await message.channel.send('Hello!') + + client.run('your token here') + +Let's name this file ``example_bot.py``. Make sure not to name it ``discord.py`` as that'll conflict +with the library. + +There's a lot going on here, so let's walk you through it step by step. + +1. The first line just imports the library, if this raises a `ModuleNotFoundError` or `ImportError` + then head on over to :ref:`installing` section to properly install. +2. Next, we create an instance of a :class:`Client`. This client is our connection to Discord. +3. We then use the :meth:`Client.event` decorator to register an event. This library has many events. + Since this library is asynchronous, we do things in a "callback" style manner. + + A callback is essentially a function that is called when something happens. In our case, + the :func:`on_ready` event is called when the bot has finished logging in and setting things + up and the :func:`on_message` event is called when the bot has received a message. +4. Since the :func:`on_message` event triggers for *every* message received, we have to make + sure that we ignore messages from ourselves. We do this by checking if the :attr:`Message.author` + is the same as the :attr:`Client.user`. +5. Afterwards, we check if the :class:`Message.content` starts with ``'$hello'``. If it is, + then we reply in the channel it was used in with ``'Hello!'``. +6. Finally, we run the bot with our login token. If you need help getting your token or creating a bot, + look in the :ref:`discord-intro` section. + + +Now that we've made a bot, we have to *run* the bot. Luckily, this is simple since this is just a +Python script, we can run it directly. + +On Windows: + +.. code-block:: shell + + $ py -3 example_bot.py + +On other systems: + +.. code-block:: shell + + $ python3 example_bot.py + +Now you can try playing around with your basic bot. diff --git a/discord.py-rewrite/docs/whats_new.rst b/discord.py-rewrite/docs/whats_new.rst new file mode 100644 index 0000000..069138a --- /dev/null +++ b/discord.py-rewrite/docs/whats_new.rst @@ -0,0 +1,364 @@ +.. currentmodule:: discord + +.. _whats_new: + +Changelog +============ + +This page keeps a detailed human friendly rendering of what's new and changed +in specific versions. + +.. _vp0p16p6: + +v0.16.6 +-------- + +Bug Fixes +~~~~~~~~~~ + +- Fix issue with :meth:`Client.create_server` that made it stop working. +- Fix main thread being blocked upon calling ``StreamPlayer.stop``. +- Handle HEARTBEAT_ACK and resume gracefully when it occurs. +- Fix race condition when pre-emptively rate limiting that caused releasing an already released lock. +- Fix invalid state errors when immediately cancelling a coroutine. + +.. _vp0p16p1: + +v0.16.1 +-------- + +This release is just a bug fix release with some better rate limit implementation. + +Bug Fixes +~~~~~~~~~~~ + +- Servers are now properly chunked for user bots. +- The CDN URL is now used instead of the API URL for assets. +- Rate limit implementation now tries to use header information if possible. +- Event loop is now properly propagated (:issue:`420`) +- Allow falsey values in :meth:`Client.send_message` and :meth:`Client.send_file`. + +.. _vp0p16p0: + +v0.16.0 +--------- + +New Features +~~~~~~~~~~~~~~ + +- Add :attr:`Channel.overwrites` to get all the permission overwrites of a channel. +- Add :attr:`Server.features` to get information about partnered servers. + +Bug Fixes +~~~~~~~~~~ + +- Timeout when waiting for offline members while triggering :func:`on_ready`. + + - The fact that we did not timeout caused a gigantic memory leak in the library that caused + thousands of duplicate :class:`Member` instances causing big memory spikes. + +- Discard null sequences in the gateway. + + - The fact these were not discarded meant that :func:`on_ready` kept being called instead of + :func:`on_resumed`. Since this has been corrected, in most cases :func:`on_ready` will be + called once or twice with :func:`on_resumed` being called much more often. + +.. _vp0p15p1: + +v0.15.1 +--------- + +- Fix crash on duplicate or out of order reactions. + +.. _vp0p15p0: + +v0.15.0 +-------- + +New Features +~~~~~~~~~~~~~~ + +- Rich Embeds for messages are now supported. + + - To do so, create your own :class:`Embed` and pass the instance to the ``embed`` keyword argument to :meth:`Client.send_message` or :meth:`Client.edit_message`. +- Add :meth:`Client.clear_reactions` to remove all reactions from a message. +- Add support for MESSAGE_REACTION_REMOVE_ALL event, under :func:`on_reaction_clear`. +- Add :meth:`Permissions.update` and :meth:`PermissionOverwrite.update` for bulk permission updates. + + - This allows you to use e.g. ``p.update(read_messages=True, send_messages=False)`` in a single line. +- Add :meth:`PermissionOverwrite.is_empty` to check if the overwrite is empty (i.e. has no overwrites set explicitly as true or false). + +For the command extension, the following changed: + +- ``Context`` is no longer slotted to facilitate setting dynamic attributes. + +.. _vp0p14p3: + +v0.14.3 +--------- + +Bug Fixes +~~~~~~~~~~~ + +- Fix crash when dealing with MESSAGE_REACTION_REMOVE +- Fix incorrect buckets for reactions. + +.. _v0p14p2: + +v0.14.2 +--------- + +New Features +~~~~~~~~~~~~~~ + +- :meth:`Client.wait_for_reaction` now returns a namedtuple with ``reaction`` and ``user`` attributes. + - This is for better support in the case that ``None`` is returned since tuple unpacking can lead to issues. + +Bug Fixes +~~~~~~~~~~ + +- Fix bug that disallowed ``None`` to be passed for ``emoji`` parameter in :meth:`Client.wait_for_reaction`. + +.. _v0p14p1: + +v0.14.1 +--------- + +Bug fixes +~~~~~~~~~~ + +- Fix bug with `Reaction` not being visible at import. + - This was also breaking the documentation. + +.. _v0p14p0: + +v0.14.0 +-------- + +This update adds new API features and a couple of bug fixes. + +New Features +~~~~~~~~~~~~~ + +- Add support for Manage Webhooks permission under :attr:`Permissions.manage_webhooks` +- Add support for ``around`` argument in 3.5+ :meth:`Client.logs_from`. +- Add support for reactions. + - :meth:`Client.add_reaction` to add a reactions + - :meth:`Client.remove_reaction` to remove a reaction. + - :meth:`Client.get_reaction_users` to get the users that reacted to a message. + - :attr:`Permissions.add_reactions` permission bit support. + - Two new events, :func:`on_reaction_add` and :func:`on_reaction_remove`. + - :attr:`Message.reactions` to get reactions from a message. + - :meth:`Client.wait_for_reaction` to wait for a reaction from a user. + +Bug Fixes +~~~~~~~~~~ + +- Fix bug with Paginator still allowing lines that are too long. +- Fix the :attr:`Permissions.manage_emojis` bit being incorrect. + +.. _v0p13p0: + +v0.13.0 +--------- + +This is a backwards compatible update with new features. + +New Features +~~~~~~~~~~~~~ + +- Add the ability to manage emojis. + + - :meth:`Client.create_custom_emoji` to create new emoji. + - :meth:`Client.edit_custom_emoji` to edit an old emoji. + - :meth:`Client.delete_custom_emoji` to delete a custom emoji. +- Add new :attr:`Permissions.manage_emojis` toggle. + + - This applies for :class:`PermissionOverwrite` as well. +- Add new statuses for :class:`Status`. + + - :attr:`Status.dnd` (aliased with :attr:`Status.do_not_disturb`\) for Do Not Disturb. + - :attr:`Status.invisible` for setting your status to invisible (please see the docs for a caveat). +- Deprecate :meth:`Client.change_status` + + - Use :meth:`Client.change_presence` instead for better more up to date functionality. + - This method is subject for removal in a future API version. +- Add :meth:`Client.change_presence` for changing your status with the new Discord API change. + + - This is the only method that allows changing your status to invisible or do not disturb. + +Bug Fixes +~~~~~~~~~~ + +- Paginator pages do not exceed their max_size anymore (:issue:`340`) +- Do Not Disturb users no longer show up offline due to the new :class:`Status` changes. + +.. _v0p12p0: + +v0.12.0 +--------- + +This is a bug fix update that also comes with new features. + +New Features +~~~~~~~~~~~~~ + +- Add custom emoji support. + + - Adds a new class to represent a custom Emoji named :class:`Emoji` + - Adds a utility generator function, :meth:`Client.get_all_emojis`. + - Adds a list of emojis on a server, :attr:`Server.emojis`. + - Adds a new event, :func:`on_server_emojis_update`. +- Add new server regions to :class:`ServerRegion` + + - :attr:`ServerRegion.eu_central` and :attr:`ServerRegion.eu_west`. +- Add support for new pinned system message under :attr:`MessageType.pins_add`. +- Add order comparisons for :class:`Role` to allow it to be compared with regards to hierarchy. + + - This means that you can now do ``role_a > role_b`` etc to check if ``role_b`` is lower in the hierarchy. + +- Add :attr:`Server.role_hierarchy` to get the server's role hierarchy. +- Add :attr:`Member.server_permissions` to get a member's server permissions without their channel specific overwrites. +- Add :meth:`Client.get_user_info` to retrieve a user's info from their ID. +- Add a new ``Player`` property, ``Player.error`` to fetch the error that stopped the player. + + - To help with this change, a player's ``after`` function can now take a single parameter denoting the current player. +- Add support for server verification levels. + + - Adds a new enum called :class:`VerificationLevel`. + - This enum can be used in :meth:`Client.edit_server` under the ``verification_level`` keyword argument. + - Adds a new attribute in the server, :attr:`Server.verification_level`. +- Add :attr:`Server.voice_client` shortcut property for :meth:`Client.voice_client_in`. + + - This is technically old (was added in v0.10.0) but was undocumented until v0.12.0. + +For the command extension, the following are new: + +- Add custom emoji converter. +- All default converters that can take IDs can now convert via ID. +- Add coroutine support for ``Bot.command_prefix``. +- Add a method to reset command cooldown. + +Bug Fixes +~~~~~~~~~~ + +- Fix bug that caused the library to not work with the latest ``websockets`` library. +- Fix bug that leaked keep alive threads (:issue:`309`) +- Fix bug that disallowed :class:`ServerRegion` from being used in :meth:`Client.edit_server`. +- Fix bug in :meth:`Channel.permissions_for` that caused permission resolution to happen out of order. +- Fix bug in :attr:`Member.top_role` that did not account for same-position roles. + +.. _v0p11p0: + +v0.11.0 +-------- + +This is a minor bug fix update that comes with a gateway update (v5 -> v6). + +Breaking Changes +~~~~~~~~~~~~~~~~~ + +- ``Permissions.change_nicknames`` has been renamed to :attr:`Permissions.change_nickname` to match the UI. + +New Features +~~~~~~~~~~~~~ + +- Add the ability to prune members via :meth:`Client.prune_members`. +- Switch the websocket gateway version to v6 from v5. This allows the library to work with group DMs and 1-on-1 calls. +- Add :attr:`AppInfo.owner` attribute. +- Add :class:`CallMessage` for group voice call messages. +- Add :class:`GroupCall` for group voice call information. +- Add :attr:`Message.system_content` to get the system message. +- Add the remaining VIP servers and the Brazil servers into :class:`ServerRegion` enum. +- Add ``stderr`` argument to :meth:`VoiceClient.create_ffmpeg_player` to redirect stderr. +- The library now handles implicit permission resolution in :meth:`Channel.permissions_for`. +- Add :attr:`Server.mfa_level` to query a server's 2FA requirement. +- Add :attr:`Permissions.external_emojis` permission. +- Add :attr:`Member.voice` attribute that refers to a :class:`VoiceState`. + + - For backwards compatibility, the member object will have properties mirroring the old behaviour. + +For the command extension, the following are new: + +- Command cooldown system with the ``cooldown`` decorator. +- ``UserInputError`` exception for the hierarchy for user input related errors. + +Bug Fixes +~~~~~~~~~~ + +- :attr:`Client.email` is now saved when using a token for user accounts. +- Fix issue when removing roles out of order. +- Fix bug where discriminators would not update. +- Handle cases where ``HEARTBEAT`` opcode is received. This caused bots to disconnect seemingly randomly. + +For the command extension, the following bug fixes apply: + +- ``Bot.check`` decorator is actually a decorator not requiring parentheses. +- ``Bot.remove_command`` and ``Group.remove_command`` no longer throw if the command doesn't exist. +- Command names are no longer forced to be ``lower()``. +- Fix a bug where Member and User converters failed to work in private message contexts. +- ``HelpFormatter`` now ignores hidden commands when deciding the maximum width. + +.. _v0p10p0: + +v0.10.0 +------- + +For breaking changes, see :ref:`migrating-to-async`. The breaking changes listed there will not be enumerated below. Since this version is rather a big departure from v0.9.2, this change log will be non-exhaustive. + +New Features +~~~~~~~~~~~~~ + +- The library is now fully ``asyncio`` compatible, allowing you to write non-blocking code a lot more easily. +- The library now fully handles 429s and unconditionally retries on 502s. +- A new command extension module was added but is currently undocumented. Figuring it out is left as an exercise to the reader. +- Two new exception types, :exc:`Forbidden` and :exc:`NotFound` to denote permission errors or 404 errors. +- Added :meth:`Client.delete_invite` to revoke invites. +- Added support for sending voice. Check :class:`VoiceClient` for more details. +- Added :meth:`Client.wait_for_message` coroutine to aid with follow up commands. +- Added :data:`version_info` named tuple to check version info of the library. +- Login credentials are now cached to have a faster login experience. You can disable this by passing in ``cache_auth=False`` + when constructing a :class:`Client`. +- New utility function, :func:`discord.utils.get` to simplify retrieval of items based on attributes. +- All data classes now support ``!=``, ``==``, ``hash(obj)`` and ``str(obj)``. +- Added :meth:`Client.get_bans` to get banned members from a server. +- Added :meth:`Client.invites_from` to get currently active invites in a server. +- Added :attr:`Server.me` attribute to get the :class:`Member` version of :attr:`Client.user`. +- Most data classes now support a ``hash(obj)`` function to allow you to use them in ``set`` or ``dict`` classes or subclasses. +- Add :meth:`Message.clean_content` to get a text version of the content with the user and channel mentioned changed into their names. +- Added a way to remove the messages of the user that just got banned in :meth:`Client.ban`. +- Added :meth:`Client.wait_until_ready` to facilitate easy creation of tasks that require the client cache to be ready. +- Added :meth:`Client.wait_until_login` to facilitate easy creation of tasks that require the client to be logged in. +- Add :class:`discord.Game` to represent any game with custom text to send to :meth:`Client.change_status`. +- Add :attr:`Message.nonce` attribute. +- Add :meth:`Member.permissions_in` as another way of doing :meth:`Channel.permissions_for`. +- Add :meth:`Client.move_member` to move a member to another voice channel. +- You can now create a server via :meth:`Client.create_server`. +- Added :meth:`Client.edit_server` to edit existing servers. +- Added :meth:`Client.server_voice_state` to server mute or server deafen a member. +- If you are being rate limited, the library will now handle it for you. +- Add :func:`on_member_ban` and :func:`on_member_unban` events that trigger when a member is banned/unbanned. + +Performance Improvements +~~~~~~~~~~~~~~~~~~~~~~~~~ + +- All data classes now use ``__slots__`` which greatly reduce the memory usage of things kept in cache. +- Due to the usage of ``asyncio``, the CPU usage of the library has gone down significantly. +- A lot of the internal cache lists were changed into dictionaries to change the ``O(n)`` lookup into ``O(1)``. +- Compressed READY is now on by default. This means if you're on a lot of servers (or maybe even a few) you would + receive performance improvements by having to download and process less data. +- While minor, change regex from ``\d+`` to ``[0-9]+`` to avoid unnecessary unicode character lookups. + +Bug Fixes +~~~~~~~~~~ + +- Fix bug where guilds being updated did not edit the items in cache. +- Fix bug where ``member.roles`` were empty upon joining instead of having the ``@everyone`` role. +- Fix bug where :meth:`Role.is_everyone` was not being set properly when the role was being edited. +- :meth:`Client.logs_from` now handles cases where limit > 100 to sidestep the discord API limitation. +- Fix bug where a role being deleted would trigger a ``ValueError``. +- Fix bug where :meth:`Permissions.kick_members` and :meth:`Permissions.ban_members` were flipped. +- Mentions are now triggered normally. This was changed due to the way discord handles it internally. +- Fix issue when a :class:`Message` would attempt to upgrade a :attr:`Message.server` when the channel is + a :class:`Object`. +- Unavailable servers were not being added into cache, this has been corrected. diff --git a/discord.py-rewrite/examples/background_task.py b/discord.py-rewrite/examples/background_task.py new file mode 100644 index 0000000..a72862f --- /dev/null +++ b/discord.py-rewrite/examples/background_task.py @@ -0,0 +1,28 @@ +import discord +import asyncio + +class MyClient(discord.Client): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # create the background task and run it in the background + self.bg_task = self.loop.create_task(self.my_background_task()) + + async def on_ready(self): + print('Logged in as') + print(self.user.name) + print(self.user.id) + print('------') + + async def my_background_task(self): + await self.wait_until_ready() + counter = 0 + channel = self.get_channel(1234567) # channel ID goes here + while not self.is_closed(): + counter += 1 + await channel.send(counter) + await asyncio.sleep(60) # task runs every 60 seconds + + +client = MyClient() +client.run('token') diff --git a/discord.py-rewrite/examples/basic_bot.py b/discord.py-rewrite/examples/basic_bot.py new file mode 100644 index 0000000..88909f4 --- /dev/null +++ b/discord.py-rewrite/examples/basic_bot.py @@ -0,0 +1,65 @@ +import discord +from discord.ext import commands +import random + +description = '''An example bot to showcase the discord.ext.commands extension +module. + +There are a number of utility commands being showcased here.''' +bot = commands.Bot(command_prefix='?', description=description) + +@bot.event +async def on_ready(): + print('Logged in as') + print(bot.user.name) + print(bot.user.id) + print('------') + +@bot.command() +async def add(ctx, left: int, right: int): + """Adds two numbers together.""" + await ctx.send(left + right) + +@bot.command() +async def roll(ctx, dice: str): + """Rolls a dice in NdN format.""" + try: + rolls, limit = map(int, dice.split('d')) + except Exception: + await ctx.send('Format has to be in NdN!') + return + + result = ', '.join(str(random.randint(1, limit)) for r in range(rolls)) + await ctx.send(result) + +@bot.command(description='For when you wanna settle the score some other way') +async def choose(ctx, *choices: str): + """Chooses between multiple choices.""" + await ctx.send(random.choice(choices)) + +@bot.command() +async def repeat(ctx, times: int, content='repeating...'): + """Repeats a message multiple times.""" + for i in range(times): + await ctx.send(content) + +@bot.command() +async def joined(ctx, member: discord.Member): + """Says when a member joined.""" + await ctx.send('{0.name} joined in {0.joined_at}'.format(member)) + +@bot.group() +async def cool(ctx): + """Says if a user is cool. + + In reality this just checks if a subcommand is being invoked. + """ + if ctx.invoked_subcommand is None: + await ctx.send('No, {0.subcommand_passed} is not cool'.format(ctx)) + +@cool.command(name='bot') +async def _bot(ctx): + """Is the bot cool?""" + await ctx.send('Yes, the bot is cool.') + +bot.run('token') diff --git a/discord.py-rewrite/examples/basic_voice.py b/discord.py-rewrite/examples/basic_voice.py new file mode 100644 index 0000000..a689cc9 --- /dev/null +++ b/discord.py-rewrite/examples/basic_voice.py @@ -0,0 +1,132 @@ +import asyncio + +import discord +import youtube_dl + +from discord.ext import commands + +# Suppress noise about console usage from errors +youtube_dl.utils.bug_reports_message = lambda: '' + + +ytdl_format_options = { + 'format': 'bestaudio/best', + 'outtmpl': '%(extractor)s-%(id)s-%(title)s.%(ext)s', + 'restrictfilenames': True, + 'noplaylist': True, + 'nocheckcertificate': True, + 'ignoreerrors': False, + 'logtostderr': False, + 'quiet': True, + 'no_warnings': True, + 'default_search': 'auto', + 'source_address': '0.0.0.0' # ipv6 addresses cause issues sometimes +} + +ffmpeg_options = { + 'before_options': '-nostdin', + 'options': '-vn' +} + +ytdl = youtube_dl.YoutubeDL(ytdl_format_options) + + +class YTDLSource(discord.PCMVolumeTransformer): + def __init__(self, source, *, data, volume=0.5): + super().__init__(source, volume) + + self.data = data + + self.title = data.get('title') + self.url = data.get('url') + + @classmethod + async def from_url(cls, url, *, loop=None): + loop = loop or asyncio.get_event_loop() + data = await loop.run_in_executor(None, ytdl.extract_info, url) + + if 'entries' in data: + # take first item from a playlist + data = data['entries'][0] + + filename = ytdl.prepare_filename(data) + return cls(discord.FFmpegPCMAudio(filename, **ffmpeg_options), data=data) + + +class Music: + def __init__(self, bot): + self.bot = bot + + @commands.command() + async def join(self, ctx, *, channel: discord.VoiceChannel): + """Joins a voice channel""" + + if ctx.voice_client is not None: + return await ctx.voice_client.move_to(channel) + + await channel.connect() + + @commands.command() + async def play(self, ctx, *, query): + """Plays a file from the local filesystem""" + + if ctx.voice_client is None: + if ctx.author.voice.channel: + await ctx.author.voice.channel.connect() + else: + return await ctx.send("Not connected to a voice channel.") + + if ctx.voice_client.is_playing(): + ctx.voice_client.stop() + + source = discord.PCMVolumeTransformer(discord.FFmpegPCMAudio(query)) + ctx.voice_client.play(source, after=lambda e: print('Player error: %s' % e) if e else None) + + await ctx.send('Now playing: {}'.format(query)) + + @commands.command() + async def yt(self, ctx, *, url): + """Streams from a url (almost anything youtube_dl supports)""" + + if ctx.voice_client is None: + if ctx.author.voice.channel: + await ctx.author.voice.channel.connect() + else: + return await ctx.send("Not connected to a voice channel.") + + if ctx.voice_client.is_playing(): + ctx.voice_client.stop() + + player = await YTDLSource.from_url(url, loop=self.bot.loop) + ctx.voice_client.play(player, after=lambda e: print('Player error: %s' % e) if e else None) + + await ctx.send('Now playing: {}'.format(player.title)) + + @commands.command() + async def volume(self, ctx, volume: int): + """Changes the player's volume""" + + if ctx.voice_client is None: + return await ctx.send("Not connected to a voice channel.") + + ctx.voice_client.source.volume = volume + await ctx.send("Changed volume to {}%".format(volume)) + + + @commands.command() + async def stop(self, ctx): + """Stops and disconnects the bot from voice""" + + await ctx.voice_client.disconnect() + + +bot = commands.Bot(command_prefix=commands.when_mentioned_or("!"), + description='Music bot example') + +@bot.event +async def on_ready(): + print('Logged in as {0.id}/{0}'.format(bot.user)) + print('------') + +bot.add_cog(Music(bot)) +bot.run('token') diff --git a/discord.py-rewrite/examples/deleted.py b/discord.py-rewrite/examples/deleted.py new file mode 100644 index 0000000..6204c6f --- /dev/null +++ b/discord.py-rewrite/examples/deleted.py @@ -0,0 +1,21 @@ +import discord + +class MyClient(discord.Client): + async def on_ready(self): + print('Connected!') + print('Username: {0.name}\nID: {0.id}'.format(self.user)) + + async def on_message(self, message): + if message.content.startswith('!deleteme'): + msg = await message.channel.send('I will delete myself now...') + await msg.delete() + + # this also works + await message.channel.send('Goodbye in 3 seconds...', delete_after=3.0) + + async def on_message_delete(self, message): + fmt = '{0.author} has deleted the message: {0.content}' + await message.channel.send(fmt.format(message)) + +client = MyClient() +client.run('token') diff --git a/discord.py-rewrite/examples/edits.py b/discord.py-rewrite/examples/edits.py new file mode 100644 index 0000000..2c1db4d --- /dev/null +++ b/discord.py-rewrite/examples/edits.py @@ -0,0 +1,20 @@ +import discord +import asyncio + +class MyClient(discord.Client): + async def on_ready(self): + print('Connected!') + print('Username: {0.name}\nID: {0.id}'.format(self.user)) + + async def on_message(self, message): + if message.content.startswith('!editme'): + msg = await message.channel.send('10') + await asyncio.sleep(3.0) + await msg.edit(content='40') + + async def on_message_edit(self, before, after): + fmt = '**{0.author}** edited their message:\n{0.content} -> {1.content}' + await before.channel.send(fmt.format(before, after)) + +client = MyClient() +client.run('token') diff --git a/discord.py-rewrite/examples/guessing_game.py b/discord.py-rewrite/examples/guessing_game.py new file mode 100644 index 0000000..a8f09c6 --- /dev/null +++ b/discord.py-rewrite/examples/guessing_game.py @@ -0,0 +1,36 @@ +import discord +import random +import asyncio + +class MyClient(discord.Client): + async def on_ready(self): + print('Logged in as') + print(self.user.name) + print(self.user.id) + print('------') + + async def on_message(self, message): + # we do not want the bot to reply to itself + if message.author.id == self.user.id: + return + + if message.content.startswith('$guess'): + await message.channel.send('Guess a number between 1 and 10.') + + def is_correct(m): + return m.author == message.author and m.content.isdigit() + + answer = random.randint(1, 10) + + try: + guess = await self.wait_for('message', check=is_correct, timeout=5.0) + except asyncio.TimeoutError: + return await message.channel.send('Sorry, you took too long it was {}.'.format(answer)) + + if int(guess.content) == answer: + await message.channel.send('You are right!') + else: + await message.channel.send('Oops. It is actually {}.'.format(answer)) + +client = MyClient() +client.run('token') diff --git a/discord.py-rewrite/examples/new_member.py b/discord.py-rewrite/examples/new_member.py new file mode 100644 index 0000000..67e700a --- /dev/null +++ b/discord.py-rewrite/examples/new_member.py @@ -0,0 +1,15 @@ +import discord + +class MyClient(discord.Client): + async def on_ready(self): + print('Logged in as') + print(self.user.name) + print(self.user.id) + print('------') + + async def on_member_join(self, member): + guild = member.guild + await guild.default_channel.send('Welcome {0.mention} to {1.name}!'.format(member, guild)) + +client = MyClient() +client.run('token') diff --git a/discord.py-rewrite/examples/playlist.py b/discord.py-rewrite/examples/playlist.py new file mode 100644 index 0000000..7607b0c --- /dev/null +++ b/discord.py-rewrite/examples/playlist.py @@ -0,0 +1,246 @@ +import asyncio +import discord +from discord.ext import commands + +if not discord.opus.is_loaded(): + # the 'opus' library here is opus.dll on windows + # or libopus.so on linux in the current directory + # you should replace this with the location the + # opus library is located in and with the proper filename. + # note that on windows this DLL is automatically provided for you + discord.opus.load_opus('opus') + +class VoiceEntry: + def __init__(self, message, player): + self.requester = message.author + self.channel = message.channel + self.player = player + + def __str__(self): + fmt = '*{0.title}* uploaded by {0.uploader} and requested by {1.display_name}' + duration = self.player.duration + if duration: + fmt = fmt + ' [length: {0[0]}m {0[1]}s]'.format(divmod(duration, 60)) + return fmt.format(self.player, self.requester) + +class VoiceState: + def __init__(self, bot): + self.current = None + self.voice = None + self.bot = bot + self.play_next_song = asyncio.Event() + self.songs = asyncio.Queue() + self.skip_votes = set() # a set of user_ids that voted + self.audio_player = self.bot.loop.create_task(self.audio_player_task()) + + def is_playing(self): + if self.voice is None or self.current is None: + return False + + player = self.current.player + return not player.is_done() + + @property + def player(self): + return self.current.player + + def skip(self): + self.skip_votes.clear() + if self.is_playing(): + self.player.stop() + + def toggle_next(self): + self.bot.loop.call_soon_threadsafe(self.play_next_song.set) + + async def audio_player_task(self): + while True: + self.play_next_song.clear() + self.current = await self.songs.get() + await self.bot.send_message(self.current.channel, 'Now playing ' + str(self.current)) + self.current.player.start() + await self.play_next_song.wait() + +class Music: + """Voice related commands. + + Works in multiple servers at once. + """ + def __init__(self, bot): + self.bot = bot + self.voice_states = {} + + def get_voice_state(self, server): + state = self.voice_states.get(server.id) + if state is None: + state = VoiceState(self.bot) + self.voice_states[server.id] = state + + return state + + async def create_voice_client(self, channel): + voice = await self.bot.join_voice_channel(channel) + state = self.get_voice_state(channel.server) + state.voice = voice + + def __unload(self): + for state in self.voice_states.values(): + try: + state.audio_player.cancel() + if state.voice: + self.bot.loop.create_task(state.voice.disconnect()) + except: + pass + + @commands.command(pass_context=True, no_pm=True) + async def join(self, ctx, *, channel : discord.Channel): + """Joins a voice channel.""" + try: + await self.create_voice_client(channel) + except discord.ClientException: + await self.bot.say('Already in a voice channel...') + except discord.InvalidArgument: + await self.bot.say('This is not a voice channel...') + else: + await self.bot.say('Ready to play audio in ' + channel.name) + + @commands.command(pass_context=True, no_pm=True) + async def summon(self, ctx): + """Summons the bot to join your voice channel.""" + summoned_channel = ctx.message.author.voice_channel + if summoned_channel is None: + await self.bot.say('You are not in a voice channel.') + return False + + state = self.get_voice_state(ctx.message.server) + if state.voice is None: + state.voice = await self.bot.join_voice_channel(summoned_channel) + else: + await state.voice.move_to(summoned_channel) + + return True + + @commands.command(pass_context=True, no_pm=True) + async def play(self, ctx, *, song : str): + """Plays a song. + + If there is a song currently in the queue, then it is + queued until the next song is done playing. + + This command automatically searches as well from YouTube. + The list of supported sites can be found here: + https://rg3.github.io/youtube-dl/supportedsites.html + """ + state = self.get_voice_state(ctx.message.server) + opts = { + 'default_search': 'auto', + 'quiet': True, + } + + if state.voice is None: + success = await ctx.invoke(self.summon) + if not success: + return + + try: + player = await state.voice.create_ytdl_player(song, ytdl_options=opts, after=state.toggle_next) + except Exception as e: + fmt = 'An error occurred while processing this request: ```py\n{}: {}\n```' + await self.bot.send_message(ctx.message.channel, fmt.format(type(e).__name__, e)) + else: + player.volume = 0.6 + entry = VoiceEntry(ctx.message, player) + await self.bot.say('Enqueued ' + str(entry)) + await state.songs.put(entry) + + @commands.command(pass_context=True, no_pm=True) + async def volume(self, ctx, value : int): + """Sets the volume of the currently playing song.""" + + state = self.get_voice_state(ctx.message.server) + if state.is_playing(): + player = state.player + player.volume = value / 100 + await self.bot.say('Set the volume to {:.0%}'.format(player.volume)) + + @commands.command(pass_context=True, no_pm=True) + async def pause(self, ctx): + """Pauses the currently played song.""" + state = self.get_voice_state(ctx.message.server) + if state.is_playing(): + player = state.player + player.pause() + + @commands.command(pass_context=True, no_pm=True) + async def resume(self, ctx): + """Resumes the currently played song.""" + state = self.get_voice_state(ctx.message.server) + if state.is_playing(): + player = state.player + player.resume() + + @commands.command(pass_context=True, no_pm=True) + async def stop(self, ctx): + """Stops playing audio and leaves the voice channel. + + This also clears the queue. + """ + server = ctx.message.server + state = self.get_voice_state(server) + + if state.is_playing(): + player = state.player + player.stop() + + try: + state.audio_player.cancel() + del self.voice_states[server.id] + await state.voice.disconnect() + except: + pass + + @commands.command(pass_context=True, no_pm=True) + async def skip(self, ctx): + """Vote to skip a song. The song requester can automatically skip. + + 3 skip votes are needed for the song to be skipped. + """ + + state = self.get_voice_state(ctx.message.server) + if not state.is_playing(): + await self.bot.say('Not playing any music right now...') + return + + voter = ctx.message.author + if voter == state.current.requester: + await self.bot.say('Requester requested skipping song...') + state.skip() + elif voter.id not in state.skip_votes: + state.skip_votes.add(voter.id) + total_votes = len(state.skip_votes) + if total_votes >= 3: + await self.bot.say('Skip vote passed, skipping song...') + state.skip() + else: + await self.bot.say('Skip vote added, currently at [{}/3]'.format(total_votes)) + else: + await self.bot.say('You have already voted to skip this song.') + + @commands.command(pass_context=True, no_pm=True) + async def playing(self, ctx): + """Shows info about the currently played song.""" + + state = self.get_voice_state(ctx.message.server) + if state.current is None: + await self.bot.say('Not playing anything.') + else: + skip_count = len(state.skip_votes) + await self.bot.say('Now playing {} [skips: {}/3]'.format(state.current, skip_count)) + +bot = commands.Bot(command_prefix=commands.when_mentioned_or('$'), description='A playlist example for discord.py') +bot.add_cog(Music(bot)) + +@bot.event +async def on_ready(): + print('Logged in as:\n{0} (ID: {0.id})'.format(bot.user)) + +bot.run('token') diff --git a/discord.py-rewrite/examples/reply.py b/discord.py-rewrite/examples/reply.py new file mode 100644 index 0000000..c8f903f --- /dev/null +++ b/discord.py-rewrite/examples/reply.py @@ -0,0 +1,19 @@ +import discord + +class MyClient(discord.Client): + async def on_ready(self): + print('Logged in as') + print(self.user.name) + print(self.user.id) + print('------') + + async def on_message(self, message): + # we do not want the bot to reply to itself + if message.author.id == self.user.id: + return + + if message.content.startswith('!hello'): + await message.channel.send('Hello {0.author.mention}'.format(message)) + +client = MyClient() +client.run('token') diff --git a/discord.py-rewrite/requirements.txt b/discord.py-rewrite/requirements.txt new file mode 100644 index 0000000..5a7e43e --- /dev/null +++ b/discord.py-rewrite/requirements.txt @@ -0,0 +1,2 @@ +aiohttp>=2.0.0,<2.3.0 +websockets>=3.1,<4.0 diff --git a/discord.py-rewrite/setup.py b/discord.py-rewrite/setup.py new file mode 100644 index 0000000..b249214 --- /dev/null +++ b/discord.py-rewrite/setup.py @@ -0,0 +1,68 @@ +from setuptools import setup, find_packages +import re, os + +on_rtd = os.getenv('READTHEDOCS') == 'True' + +requirements = [] +with open('requirements.txt') as f: + requirements = f.read().splitlines() + +if on_rtd: + requirements.append('sphinxcontrib-napoleon') + requirements.append('sphinxcontrib-asyncio') + requirements.append('sphinx==1.6.3') + +version = '' +with open('discord/__init__.py') as f: + version = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), re.MULTILINE).group(1) + +if not version: + raise RuntimeError('version is not set') + +if version.endswith(('a', 'b', 'rc')): + # append version identifier based on commit count + try: + import subprocess + p = subprocess.Popen(['git', 'rev-list', '--count', 'HEAD'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + out, err = p.communicate() + if out: + version = version + out.decode('utf-8').strip() + except Exception: + pass + +readme = '' +with open('README.rst') as f: + readme = f.read() + +extras_require = { + 'voice': ['PyNaCl==1.1.2'], + 'docs': ['sphinxcontrib-asyncio'] +} + +setup(name='discord.py', + author='Rapptz', + url='https://github.com/Rapptz/discord.py', + version=version, + packages=['discord', 'discord.ext.commands'], + license='MIT', + description='A python wrapper for the Discord API', + long_description=readme, + include_package_data=True, + install_requires=requirements, + extras_require=extras_require, + classifiers=[ + 'Development Status :: 4 - Beta', + 'License :: OSI Approved :: MIT License', + 'Intended Audience :: Developers', + 'Natural Language :: English', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Topic :: Internet', + 'Topic :: Software Development :: Libraries', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Topic :: Utilities', + ] +) diff --git a/phfhu.py b/phfhu.py new file mode 100644 index 0000000..ba2b63f --- /dev/null +++ b/phfhu.py @@ -0,0 +1,43 @@ +import asyncio +import discord +from discord.ext import commands +import os +import praw +from random import randint + +class Porno(object): + """Porno commando's. Jij weet wat er aan is.""" + + def __init__(self, bot): + self.bot = bot + self.voice_states = {} + self.fileIndex = {} + self.r = praw.Reddit( + user_agent='StroopwafelBot', + client_secret='Z4K17T8TC26cNAgDSRxCkE9jYR4', + client_id='aWeyKpzdHMtmJQ' + ) + self.r.read_only = True + def __check(self, ctx): #Todo: Is dit het porno kanaal? + return True + + def randomJpgFromFolder(self, dir): + if dir not in self.fileIndex.keys(): + self.fileIndex[dir] = [] #empty list + for dirname, dirnames, filenames in os.walk('./'+ dir): + for filename in filenames: + if filename.endswith('.jpg'): + self.fileIndex[dir].append(os.path.join(dirname, filename)) + #now we should have an index for the selected folder, but it might still be empty (i.e. empty folder given) + if self.fileIndex[dir]: + return self.fileIndex[dir][randint(0,len(self.fileIndex[dir])-1)] #get a random element from the newly created index + else: + return 'default file here' + + @commands.command(pass_context=True) + @asyncio.coroutine + def nsfw(self, ctx): + """ random post uit een nsfw subreddit, for reasons.. """ + randnsfw = self.r.subreddit('randnsfw') + post = randnsfw.random() + yield from self.bot.say(post.url) \ No newline at end of file diff --git a/pi/General.py b/pi/General.py new file mode 100644 index 0000000..de397ad --- /dev/null +++ b/pi/General.py @@ -0,0 +1,27 @@ +import asyncio +import discord +from discord.ext import commands +from sympy import latex, symbols, preview, Symbol #for LaTeX images + +class General(object): + """Overal toegestaan.""" + + def __init__(self, bot): + self.bot = bot + self.voice_states = {} + def __check(self, ctx): #Todo: Is dit het porno kanaal? + return True + @commands.command(pass_context=True) + @asyncio.coroutine + def tex(self, ctx, mathstring): + """Geeft een mooi LaTeX plaatje terug. + Encapsuleer uw string in mooie \" + Bijvoorbeeld: + !tex "a^b b^c \rightarrow d" + """ + preamble = "\\documentclass[10pt]{article}\\pagestyle{empty}\\usepackage[margin=1in]{geometry}\\usepackage[T1]{fontenc}\\usepackage{CJKutf8}\\usepackage[english]{babel}\\usepackage{amsmath, amsfonts}\\begin{document}" + options = ["-T", "tight", "-z", "9", "--truecolor", "-D", "512"] + preview(r'$$'+mathstring+'$$', viewer='file', filename='test.png', dvioptions=options, preamble=preamble) + file = open('test.png', 'rb') + yield from self.bot.upload(file, filename ='test.png', content='Nerd!') + diff --git a/pi/Music.py b/pi/Music.py new file mode 100644 index 0000000..b965e62 --- /dev/null +++ b/pi/Music.py @@ -0,0 +1,240 @@ +import asyncio +import discord +from discord.ext import commands + +class VoiceEntry: + def __init__(self, message, player): + self.requester = message.author + self.channel = message.channel + self.player = player + + def __str__(self): + fmt = '*{0.title}* verzocht door {1.display_name}' + duration = self.player.duration + if duration: + fmt = fmt + ' [{0[0]}m {0[1]}s]'.format(divmod(duration, 60)) + return fmt.format(self.player, self.requester) + +class VoiceState: + def __init__(self, bot): + self.current = None + self.voice = None + self.bot = bot + self.play_next_song = asyncio.Event() + self.songs = asyncio.Queue() + self.skip_votes = set() # a set of user_ids that voted + self.audio_player = self.bot.loop.create_task(self.audio_player_task()) + + def is_playing(self): + if self.voice is None or self.current is None: + return False + + player = self.current.player + return not player.is_done() + + @property + def player(self): + return self.current.player + + def skip(self): + self.skip_votes.clear() + if self.is_playing(): + self.player.stop() + + def toggle_next(self): + self.bot.loop.call_soon_threadsafe(self.play_next_song.set) + + @asyncio.coroutine + def audio_player_task(self): + while True: + self.play_next_song.clear() + self.current = yield from self.songs.get() + yield from self.bot.send_message(self.current.channel, 'U luistert naar: ' + str(self.current)) + self.current.player.start() + yield from self.play_next_song.wait() + + + +if not discord.opus.is_loaded(): + # the 'opus' library here is opus.dll on windows + # or libopus.so on linux in the current directory + # you should replace this with the location the + # opus library is located in and with the proper filename. + # note that on windows this DLL is automatically provided for you + discord.opus.load_opus('opus') + +class Music: + """Play that funky music white boy. + """ + def __init__(self, bot): + self.bot = bot + self.voice_states = {} + + def __check(self, ctx): + return True + + def get_voice_state(self, server): + state = self.voice_states.get(server.id) + if state is None: + state = VoiceState(self.bot) + self.voice_states[server.id] = state + + return state + @asyncio.coroutine + def create_voice_client(self, channel): + voice = yield from self.bot.join_voice_channel(channel) + state = self.get_voice_state(channel.server) + state.voice = voice + + def __unload(self): + for state in self.voice_states.values(): + try: + state.audio_player.cancel() + if state.voice: + self.bot.loop.create_task(state.voice.disconnect()) + except: + pass + + @commands.command(pass_context=True, no_pm=True) + @asyncio.coroutine + def join(self, ctx, *, channel : discord.Channel): + """Verplaats stroopie naar een bepaald kanaal.""" + try: + yield from self.create_voice_client(channel) + except discord.ClientException: + yield from self.bot.say('Already in a voice channel...') + except discord.InvalidArgument: + yield from self.bot.say('This is not a voice channel...') + else: + yield from self.bot.say('Ready to play audio in ' + channel.name) + + @commands.command(pass_context=True, no_pm=True) + @asyncio.coroutine + def summon(self, ctx): + """Roep stroopie naar jouw kanaal toe..""" + summoned_channel = ctx.message.author.voice_channel + if summoned_channel is None: + yield from self.bot.say('You are not in a voice channel.') + return False + + state = self.get_voice_state(ctx.message.server) + if state.voice is None: + state.voice = yield from self.bot.join_voice_channel(summoned_channel) + else: + yield from state.voice.move_to(summoned_channel) + + return True + + @commands.command(pass_context=True, no_pm=True) + @asyncio.coroutine + def play(self, ctx, *, song : str): + """Zet een liedje in de queue + """ + state = self.get_voice_state(ctx.message.server) + opts = { + 'default_search': 'auto', + 'quiet': True, + 'no-check-certificate': True, + } + + if state.voice is None: + success = yield from ctx.invoke(self.summon) + if not success: + return + + try: + player = yield from state.voice.create_ytdl_player(song, ytdl_options=opts, after=state.toggle_next, use_avconv=True) + except Exception as e: + fmt = 'Error: ```py\n{}: {}\n```' + yield from self.bot.send_message(ctx.message.channel, fmt.format(type(e).__name__, e)) + else: + player.volume = 0.6 + entry = VoiceEntry(ctx.message, player) + yield from self.bot.say('In de queue: ' + str(entry)) + yield from state.songs.put(entry) + + @commands.command(pass_context=True, no_pm=True) + @asyncio.coroutine + def volume(self, ctx, value : int): + """zet het volume. 1-100""" + + state = self.get_voice_state(ctx.message.server) + if state.is_playing(): + player = state.player + player.volume = value / 100 + yield from self.bot.say('Zet het volume op {:.0%}'.format(player.volume)) + + @commands.command(pass_context=True, no_pm=True) + @asyncio.coroutine + def pause(self, ctx): + """Pauzeer het huidige liedje""" + state = self.get_voice_state(ctx.message.server) + if state.is_playing(): + player = state.player + player.pause() + + @commands.command(pass_context=True, no_pm=True) + @asyncio.coroutine + def resume(self, ctx): + """onpauzeer.""" + state = self.get_voice_state(ctx.message.server) + if state.is_playing(): + player = state.player + player.resume() + + @commands.command(pass_context=True, no_pm=True) + @asyncio.coroutine + def stop(self, ctx): + """Kappen nou. Stoppen met spelen, gaat uit het kanaal en leegt de queue. + """ + server = ctx.message.server + state = self.get_voice_state(server) + + if state.is_playing(): + player = state.player + player.stop() + + try: + state.audio_player.cancel() + del self.voice_states[server.id] + yield from state.voice.disconnect() + except: + pass + + @commands.command(pass_context=True, no_pm=True) + @asyncio.coroutine + def skip(self, ctx): + """skipperdepip + """ + + state = self.get_voice_state(ctx.message.server) + if not state.is_playing(): + yield from self.bot.say('Not playing any music right now...') + return + + voter = ctx.message.author + if voter == state.current.requester: + yield from self.bot.say('Requester requested skipping song...') + state.skip() + elif voter.id not in state.skip_votes: + state.skip_votes.add(voter.id) + total_votes = len(state.skip_votes) + if total_votes >= 3: + yield from self.bot.say('Skip vote passed, skipping song...') + state.skip() + else: + yield from self.bot.say('Skip vote added, currently at [{}/3]'.format(total_votes)) + else: + yield from self.bot.say('You have already voted to skip this song.') + + @commands.command(pass_context=True, no_pm=True) + @asyncio.coroutine + def playing(self, ctx): + """Info over het huidige liedje.""" + + state = self.get_voice_state(ctx.message.server) + if state.current is None: + yield from self.bot.say('Not playing anything.') + else: + skip_count = len(state.skip_votes) + yield from self.bot.say('Now playing {} [skips: {}/3]'.format(state.current, skip_count)) diff --git a/pi/Porno.py b/pi/Porno.py new file mode 100644 index 0000000..e32807e --- /dev/null +++ b/pi/Porno.py @@ -0,0 +1,36 @@ +import asyncio +import discord +from discord.ext import commands +import os +from random import randint + +class Porno(object): + """Porno commando's. Jij weet wat er aan is.""" + + def __init__(self, bot): + self.bot = bot + self.voice_states = {} + self.fileIndex = {} + def __check(self, ctx): #Todo: Is dit het porno kanaal? + return True + + def randomJpgFromFolder(self, dir): + if dir not in self.fileIndex.keys(): + self.fileIndex[dir] = [] #empty list + for dirname, dirnames, filenames in os.walk('./'+ dir): + for filename in filenames: + if filename.endswith('.jpg'): + self.fileIndex[dir].append(os.path.join(dirname, filename)) + #now we should have an index for the selected folder, but it might still be empty (i.e. empty folder given) + if self.fileIndex[dir]: + return self.fileIndex[dir][randint(0,len(self.fileIndex[dir])-1)] #get a random element from the newly created index + else: + return 'default file here' + + @commands.command(pass_context=True) + @asyncio.coroutine + def ancilla(self, ctx): + """ :-) """ + filepath = self.randomJpgFromFolder('ancilla'); + file = open(filepath, 'rb') + yield from self.bot.upload(file, filename = filepath, content='Alsjeblieft!') \ No newline at end of file diff --git a/pi/Shitpost.py b/pi/Shitpost.py new file mode 100644 index 0000000..bf2e4d3 --- /dev/null +++ b/pi/Shitpost.py @@ -0,0 +1,72 @@ +import asyncio +import discord +from discord.ext import commands +from urllib import request +import json, requests +from datetime import datetime +import sys +import os + +class Shitpost(object): + """Schijtpaal commando's. Alleen bruikbaar in de daarvoor toegewezen schijtpaal kanalen.""" + + def __init__(self, bot): + self.bot = bot + self.voice_states = {} + def __check(self, ctx): #Todo: Is dit het shitpost kanaal? + return True + + @commands.command(pass_context=True) + @asyncio.coroutine + def eriku(self, ctx, boven: str, onder: str): + """Maakt een erik meem. + Bijvoorbeeld: + @Stroopwafel eriku \"je moeder\" \"is een hoer\" + """ + params = dict( + template_id = '93296473', #de eriku + username = 'Stroopwafel', + password = 'ruyter118', + text0 = boven, + text1 = onder + ) + self.bot.say("deed ik het ding?") + resp = requests.get(url='https://api.imgflip.com/caption_image', params=params) + #todo check for success + photourl = resp.json()['data']['url'] + em = discord.Embed(title='De Eriku heeft gesproken. Prijs hem!', type='photo') + em.set_image(url=photourl) + yield from self.bot.say(embed=em) + + @commands.command(pass_context=True, hidden=True) + @asyncio.coroutine + def nihao(self,ctx): + yield from self.bot.say("Kankerlauw") + + @commands.command(pass_context=True) + @asyncio.coroutine + def tijd(self, ctx, *args): + """Vertelt je hoe laat het is.. + Misschien geeft het ook wel een leuke meem terug. + Wie weet. + Probeer het eens op verschillende tijdstippen + """ + + time=datetime.now().strftime('%H%M') + for argument in args: + time = argument + filepath = '' + #filejpg = './tijd/' + time + '.jpg' + #filepng = './tijd/' + time + '.png' + filejpg = os.path.join('tijd', time) + '.jpg' + filepng = os.path.join('tijd', time) + '.png' + if os.path.isfile(filejpg): + filepath = filejpg + elif os.path.isfile(filepng): + filepath = filepng + + if filepath is not '': + file = open(filepath, 'rb') + yield from self.bot.upload(file, filename = filepath) + else: + yield from self.bot.say('Het is nu ' + datetime.now().strftime('%H:%M')) \ No newline at end of file diff --git a/pi/Stroopwafel.py b/pi/Stroopwafel.py new file mode 100644 index 0000000..260d764 --- /dev/null +++ b/pi/Stroopwafel.py @@ -0,0 +1,20 @@ +import asyncio +import discord +import Music +import Porno +import Shitpost +import General +from discord.ext import commands + +bot = commands.Bot(command_prefix=commands.when_mentioned_or('!'), description='Stroopwafel. Shitpost bot extraordinaire.', pm_help=True) +bot.add_cog(Music.Music(bot)) +bot.add_cog(Shitpost.Shitpost(bot)) +bot.add_cog(Porno.Porno(bot)) +bot.add_cog(General.General(bot)) + +@bot.event +@asyncio.coroutine +def on_ready(): + print('Logged in as:\n{0} (ID: {0.id})'.format(bot.user)) + +bot.run('MjczODc1ODM1NzIxNzQ0Mzg1.C2p7EA._tTMtYEsaUuTv_RtOifGhNX9QZ4') \ No newline at end of file diff --git a/porn/neko/download.py b/porn/neko/download.py new file mode 100644 index 0000000..a1304d2 --- /dev/null +++ b/porn/neko/download.py @@ -0,0 +1,58 @@ + +import os +import json +import argparse +import praw +from time import sleep +from urllib.request import (Request, urlopen, urlretrieve) + + +def main(): + r = praw.Reddit( + user_agent='StroopwafelBot', + client_secret='Z4K17T8TC26cNAgDSRxCkE9jYR4', + client_id='aWeyKpzdHMtmJQ' + ) + r.read_only = True + + sub = r.subreddit('NekoIRL') + print(sub.random().url) +if __name__ == "__main__": + main() + +#def main(): +# +# parser = argparse.ArgumentParser() +# parser.add_argument('--sub', '-s') +# args = parser.parse_args() +# sub = args.sub +# imgs_folder = 'imgs_' + sub +# imgs_url = 'https://www.reddit.com/r/' + sub + '/top.json' +# #imgs_url = 'https://www.reddit.com/r/' + sub + '/about/stylesheet.json' +# req = Request(imgs_url) +# req.add_header('User-agent', 'Stylesheet images downloader Py3 v1') +# imgs_json = json.loads(urlopen(req).read()) +# imgs = [i for i in imgs_json['data']['children']] +# +# def fetch_images(total, count): +# if not os.path.exists(imgs_folder): +# os.makedirs(imgs_folder) +# os.chdir(imgs_folder) +# +# for i in imgs: +# url = i['url'] +# ext = '.' + url.split('.')[-1] +# name = i['name'] + ext +# urlretrieve(url, name) +# print('Downloading ' + str(count) + ' of ' + str(total) + ' - ' + name) +# count += 1 +# sleep(1) +# +# if imgs: +# total = len(imgs) +# fetch_images(total, count=1) +# else: +# print("No images found") +# +#if __name__ == "__main__": +# main() \ No newline at end of file diff --git a/porn/neko/imgs_NekoIRL/side-top.jpg b/porn/neko/imgs_NekoIRL/side-top.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b3a3b940e9f25d62d4471a4e8ccd46cae9678a43 GIT binary patch literal 28963 zcmb4qXH-*Nuy%k@0@6Z}PUs{EN^jC@LJv)f2-16zE-1Z8C!v=B3B5z;p!AMNZ=#^^ zQUpbc2!hx9=dSPXH+!w~XU>_Kz0O{H_B_w*zpH;g185MMa7_RK0RTYoZvg&o0Mr1) zL_i>ri1^=wn3$M^^d1@MKcS|e0Ntad27_s-X=&*g+3wTPvoO%oGI24nu(NY=a?;)B z=H=$#W#i!F_+KLg|2`!pA*CWCqvD{arRVtnmcQ=+v}6Pg1P_P^xB-N;1Vpq1e}@5V z004mWf1~|BAs{3I{tFU7_D`!y10WzG{1-b3F(EOKkboHQPfH5`5`(!!N$6CJxb1vN z>BS;5N_kY92R<{!>C} z0fdBvL;wQd|Gh&%NK3>81dFN=({US#+0pa(szx&WT?gDF`e%stpLZp|g@-$)#F8VU z14rx=xg0YI>a=@V^_y5j@^xYFfZ!?7TW<>`x8Q9k+6h3ZJFgu8BYpJEmbpeE&;JqE za8>(}>=_}CAS4=LA$8C-i!IN|;ewpVhiEA2=*hkK3QTRri_Byul24I9E|p+u?Z*-p zi1h2V7D5yBIFH5mts(-<2~7Kx=bFZtIYzjdVDY>X-ss{GkWf2|r9 zK`KQIA9wt0=xleIA!Z!ncg(q8Eq^`Fn<-Ar$Ai^nAy0Xf7>~rSs2|g*rprdr@lsNI zooAXfeHDVzmJ%6XloMxA0II0Q+|si0n}Zt^NQ(9eBCF3EW-vc$mxYq*#S4iBNh&l^ zyihI3{JpGI!zMITJsM(MNsFTAQC5@;QV?v4t>v2#P!Cb25Q;KwyCCe51KSeQC{02q zg%=OUwU6Gqy;>@F;oy=BsJ;==tsTz*PkBH7DiuY*uHW`MSr}nxJf*12Uai&0)NY{+ z=a&GnmRJV4{3j`UL1&gKQ>iX1-OR9ktcqX-Rz7zr%oz~WHzZr8TH?;Y@tbyn#)x+7VttE;(XLIDK9iGI~qeIPhWT}(n@^U{Gw z0yx1~VtzM$*3DA#tHepA2k#X74*B;Eu*vlh*TsEUz&m=Xh~F$JJ@=jO7VZ&z6=z}8 z(xPeT#0$d0q3mJ(sGZM`&8f!C4Pf5fujt^D8>K_YgBd_-agDq}%)#aee~U@s zhYp*l!snlO-0_|2?KT-zKfYgZlj6+0ML8gzS8g+v0Dk&-@p?r~lGfE*w-0_X<)@5T zLAth*X6)i_r$8>Mvab4(!EDaNeSu?==td*T_-D^g>RtdGQhsKfbGz-v^dA;zzasqN zyIjuN=6Hc1L{_c9%`=MeJPs3XV8V0u4Uc0;^xx z!mjbhye!V;uK?YgM^Ee3Dj9YoI!wS(_MsNN-qe;q-^9FkJJ1-LE8;U(FACs~2X`V( z$PL_(wGIUT!Ijv`57trgNO!|%IaiaC_^{+nwy(NSz|m?=x>XuP5W-t&OST*ySZ*VS z7@!gq7=LxXyz41Qj5ue>+e8}Jkm#sO9&a3fdaxN!>oFe$QG1g&_r3l615>vl4$V23 zuA&PjSn$Gx7>kJX4m@*Nvwl8f*^AxN_j{py1ei9nPxw-(7R57ofxD=&&TjDh$OqHH z$T9ttP@J6_1(my*?luGK7lmmd(ZJd6kZYvy(;S@Hf$u69v^KCw^%sCR`%*mLBw;PL zyR?w}kr(X{4i_{!*G3V{lZL0_OKwyzGlo~Q2H#VSrr^pl_&nu|r&(MW9couzm~9A@ z-4?$ylXY}OS$EHLC9?gO8O>pvZ!^X*crvIaoqZ(!n?v>PfWOx~;zufNl4VZuSc52_ zId2Rx>aji%rEl`PYPL^;L2#wD)@g9Px$9}`QPlk6o}mfaAXYtERF3k?E3{M52PH41 z){WrMf~5gEgs{pIaGFn`(s--pk0Rc#ld`^h(?YmCZqy$3D4jQJSp{d5LgM*Ct}V81 zU%(sTC!TCdE(33w>xoc0UZ6luMTjS>j=r6rm(6c@Y}VN zN{1Ug5(uW($P{*oCqK{c(;FGNW^o9HOeSG?1FL%^96X3iL82JV4~9&NP;dX8LUOnv zz{P>3$n!HT@6lEcJ`ijapw^_qBA8ah29~ZM*{0>Ee>wl~*;g<|K#ZQ14}pPK zq}jA3&*NM_@+%>tdv+w-{BC;2p8iIp)vJFx4d0=$Bhx0Wb(eI`fLvX5@t-G7)n0bR z;9y2d2TFb5PxpOAfYmtQJ}mnect@H%;)|1kvc&aLRy?w{j6H};lwW?XSpU7+&znE?+C`EE)OYdjzu8pVzF=zG;aIq&3&_jkPgC3 z%)OP|tH9-x?a$V#PfQX(dBqoXKVI=K6{Kt*b{RuP@{v5Jc z)Ao}l#5^128q8ERt>R7}^-ViyFIgmHtN{D|lzc$5vA;GW0E_n} z>5+K)G_P~{&x2oAZ>D@p0z-hYcietXFxvKOZ}El?T5h*4$dK7m;9tP$o!xm|(<#6B z>#JaKzLs*f&jg~Ipehkj`{d&XS^l(Xa@a{!tznA&ldCqRhiK;w=ED^GBp-J+-`0%z zkpVcE712sHEcogo#`hJ)>o~>R4q1b?J{`)of*{plH&j(h=s_-XmG@tOm1r{bgLmS# zw1_1yApA4ow+mh}-J_VaD%PgW2mUvm+}zQfjwFmKAbpy8O}pHfCKO6}Y4T#D&qspl<1;H!ZN%0<0wAB;0cXyE>Fmq< zGZZ!<0T^EY->IMERQ?DG!7>q}*N3jD2ZzeIv79@Dr$7F~CzHNmSM~X(a<5Ujf)lQq zso$T!)IPg6zm51+fa23G9CA?_35HI+tQ9EeZVegT@|Yw8(z1qC^ifBA`NcHK9=k(@ zIOga;6cbpO%typxgFt3Yp)8FWX1S!k3z}o0_%~@Go_n7hU=*&p{-Qfdu!85Ol|P0% zPYq`W5J(gq?*aGY_inf!cd+C7slZ7W88QNXP18!8wRcEw)Xf9#j|g>ATa-Y!HoIoO ziOZEyl22`QhmRHC=j#}39W#i>SK)B0C!W>YV^oT?-sq8s7EsecG;lELFF?vgMPY!2 z=&bf>(tKMxV>MiR(#%odFTf5*$^Ygc>Em;%wuz9@O=M`?hr(N8Gq29>@rN+;(0Iuo zI6=6Qq1#koHkemfw~lG`yW#!s{pB2Yt(pEhAS2Qx)A>;5FI7rk6ABvmx((C40@g$n z;nx6S_4=EF%A_J+BY7fjET6flQ%2JLFQh zwlJ#01SzNIYW-;4s*OCIfHk_Q#);JReHS5UQOnzM?%TRuJF+m5Mp9JQK81_3YOnJ` z?azD~A{;2bd!a=Q}Vy$0i<-9$t)b*ruG3)*%^I1mIcACI% zx<-@Tq>ArDwrP zD?fm1r{rF+x|_iy+G_5xJJGoDm1ixnv?c2^iEKuOe+lz7EM{0F{{zU###_Zev=&t# z>qs6!g&i?`T_tt-NO;bMuMG`FaB$2~&!%N`&Qp$T#)av=sEPosV6IJtn1(%@qKkAp z{Ps<1bb+besrctRwTm6O)rL}%XkWeg@AS1!5jxJphfBYWms`*D1h(IDemM|*am;bB z;lJi{$sx?xOr?}tKk{DLeo*m(&N1Oxa{~W=G@~P}n5gW0pYDT=j-k0F?JcY6}>k^0gz&$xRB>FCGh1eWKAE(SaWjN|2;2KryN78YK{K3m^T zyg@HlMzB|ESQapk)H4zZukxv2`QW~VJjnI9oxV}5w{b}Q?V?A2g zk-b zi|;x{h&5c$+qy|fAG_=a#$|r}!_PC1QgcDYG67V0k9Q4!sMNTiK2<~}pRNa2QaPg~ig?sMbv%>fYem5Iop4=187bO5 z=+t)D9JWq|#(}=ls4mUOu&D~BQZDiraBk77`Ew-fD-_Z0XL!+?8RBeDS9Lpw`AwKAY+H;gN{vR}dt< z=r15zXQQ3XknDq%z8=I z#o7cnP@}?vP17%*#CXs@Mbvp|npT83Dqk+xl)s-Z^>x~6?s{nDtm3I@EGF^EV7;2; zkGj)netT$8baS9puO?k0l$YyWtyP3=HMty3SbG|537yn1(3Eo=y?jKIgH#4yN`7s0 z-qhTk!F7#TgtW8U=mafB(=GBn)!ahwi!hHgFvQ)omR#t@40AG;^oikIrVHg-LbsEt z_I_>UTUuEcPRtN_Eh$9Dlzk+eSd>X_{%lQqNPS?@H&RBq=J#=KD%uduNffD+jSEg~F% zD^+1_ntU^Dh8m*3)sXv7`W442J*}KGPFBD_-q&{LKA#IvL~DC|&=btwVrr+%{D~&t z2hG>vexP=*BL1|Jv0Su^S-&Xe^u_0OCYn~K28F}jDZ zOh(2^NY&f2mNn#_NrF!Kjag{w6&Kg$;qqE}xK)PFYhzwW|AT{PyTfqd?l@mab=LVTYC`aj%1c)FzD$_F9Ytx8Z$^5C~ z*#Tv_w+=cuGW@lM>^J73;1=&~g&IuRA0Ru@2FBM64fp8;cJD5TLgR}bqogNP!w^E; zz+Y#$mS9#EUeB!u9T_bq2E)M2aQ*Y+4w|^L&WNo3%Fsr9;Hi(OE5#&W_8z};NK%%= z1DW)nU2nMIdC=ef6<4j^%0I`x>Yq|sQ@JG~0%CaKlDfmH#=$=`Go$D*8}&8~ACW-4USscInxr-&l zesoxPG6xPMYAkxzi63z4;B+9|>(c$h5O*4TRwuDzZS8D?jM03%^%gNa<@sS>en0Xu za(HxCY2m?pkxjRKl?ani>W9l!hrW)E>Q`}?OB|=U>_~UH7+j!_V=42nl>o#-DVPzJ zBwi`2ui9JwA{bX;I%6FXN>rC$&^E-zcsQ9Ci8<2mbSE=D;nDk}o-yAj<3)(ue&36A zqb<|V&oT{qNb=*esh{|s2a=u!gM^ELB6a#4#2sw87Av=rnd;X83_&BouNBI?z-T8D zp0cr=;w8d@AQfe8$OVx0ttEG)tNr0$KpA^Mknk`U%p+nK2!S>6!I6)S?WAFkNM*P>L198FAW?g@XK`MaIX5P zP>~rpvxtT6EoNdf>9H=@Z{g}`86+jbVeQ)9(hpLsENwXrDI0!zRHSI%r`YmGnm#KhyS1kBv(eG+Ux36+5E;m@ zgyk`z|AP0%v5v&fQ(}$h3|!+2NPi{8tv0i^hn!#KgQpTb)r4khe@q`#G+&QR8;M?y zoN#^@9(;<>bdfJnnlE^+e@EwT^G0jCi;i28ysSCU1T6^3h1AO(w=F>{U3x07(xp24 zAI4ktxV!t`=yo>YTJwRaW7v!DR`JaZkJY9tsd_922YzI9p0VEwF^zg8SC1}04sF8* z=-(6cy_)<@CDDSGs)8_`OH5UZOns2GulCP4&LQrHY-tpm4ruRol8t}+rse1%&(Uvc zbu+C+*+1SUEOJEtZ;Bpnjo*%vURPP*-TnLh?#t%e94H_R^iQ|kE<093H$FC+6-*i2 z-G}F}{4BE%^ikAZH@dv)6#E->{@Vn-$277|=NtJ6jKpla z$i2SdvWng{dHj-!aYaS9QOtBlE!zG_--jY+6^UGk1&l{kbHY$P!QxDOEw_%Z{l2(3 zU49(=X`OCCNxt2G1p zu$f0$i8BX8{=6Nrp{}jm7ty^Q&G2>nWyv9xG#4g2+Wo5S_`YyGO)R&TM($10^YQM0 zBd&HPdPAR6Qy~Q2E#uhz|PC9>lFs$B;8SVEEklUf7 z30S8>MK_?M*bwEVQxeQBD8Hm~2}D>2!vHMB%=s^XTqk#jj55aYF94QEk}9G?CmU8< zt}gLJ0-!!ZyGTlgm`GfKCYFP%fzhhA?*CZ|8&-XskIO{?Py!hJTbKaYJ-_YWVw@Zh zfyxDzK;-oFddcSrkqmWF<=90y{Js}3wzDO71`7!S>_4u0VfJQV?$g~kg2`3ZiLiri zck}mr@|hXJ3XU8-Z3#;(m&3AR#dC4u*>qKr|1BQq)3bB- z8$8V_T5@?);+Bd}P4f%7srV?>+%l`Pe${?l8u>Vh@SH;?EE)Hi1Sbvd4(A#gLb70h z61f#UgoH3f%rUbx`=lYhU92KB1@P&+P55Qf_2e5@CECg2uj~^yE7;%;>hLKBsrF56 z?a8ce?Q8P0680zbJBKP}w*N}y5}dQs=ojGv!|}>A4N<8{OK!19Om!gzwduz}R!?y1 z7aUi5cgv>soO1op>Mw$uFCLrq<>ZM!*y9a~)Kco{$H)VRgRSB+XITsJS8wJBC6CSD zJSIYWcqVDu7It+zB`!BU38E~GH=a~Y@JQedQ+lfORt|+9%6Bvnor))SikLER29=KV~=Cuag5x0QUqWy=1}4yI z#qEf3XBvsxzN{fuZVeSHV04#v2xE)lofn-}BXj(5X_{^;v$n70EmvpV8sPGqvhk4t z(p{^!$uwm!&z~{V%p{giEYo0L!HtNu*;7Y^k0(zI=z5@1U@$*Py=YFG{Gf{an|cM| zUfalHSCla!U1SL~>-+WmFlZJumeaPy$PE{blt!yT$)XdoIo3d%f`Gf+gRc zZvgi*8or*mEtdE&D(VaE=yOa{2<3h7LG~me>h(hNwTr9P;+tNmH*fS*?Cm{+Psc_n zT^XLehIZ;W%Y9@6+Mz*WVD_nlUbk2q{Z~P1qT&LBDTLj>>BruSwUACN%V`RraL#;7 zt2|!XdwJun-H#UL^2jv%z@z7=*l(&{)$<3PdJo!<$P%=4#7B$CrJ>Cmt)AkxjnEE{ zoNL@(Q$6wdIjI8U#c0M{R^;A1+Vj(OK@-itUT-ArCe4?s%im5`-B}y%oLtIR0W#&v znQafrlr{QXp2WS!yfD<9Vxi-NoyCE#*L!sI^`FI*%q_2dEB-1>{RjsJD$5h8WeaEB z_kEi=lhBRbl~tW9Q!S8-{O{;LeII`0mjpv{$T@fPeGl&M-1m5sx72VVv+ibwQ?}Jh z%qqDcb~m2;q)iQ`tPbdzg`A-J4Y3v`I~#5#vA2|vPD55`|JNQX?e4{D03UP0GtOPCJ?TDA5Pp8Y( zFVK7B%{}1hI_=LV@042J5-Vf(ldFc2&(|E=7}_ zZHq|VJk}_wi=P!;iG&WEmIW;sE){hrqUNH32H|#tk;}4bu`VD^U0LZ9LZbLDAoNhC z2$s>yylV7vg2(c+Buk{)%ByBeFeCZbuQ7Q>8+z4UwqhHxBrj;YGRrsf0|Vz$TvAau zKDR|aakcXu-na*h>^=KY_mi-^T(sy z6u&Xo%CPA~V3FafoXYv_A@_RdLkyAjI898oa+rF`4CCQa)MhmoQTHpsTj<0S*R}+c z(emJ`X~&CNAw4D|xxM+v`&X!fb8No$?A7TXaVmUgkNJ)a3Z0Ip%QM0>3cS=tcz!p_ zo8C6stMxb(RjLmq-H<=(XbtA3hEa7#%Gw)<=j^*r7Y5!GzU!b56ygu!Ps@Q7%&puf zim|S<8){Eg3eC>U>^+H8k`*|xS-X4|E9yFQSq(b%8_vQW*l2&&30!My53b|$0%nHh ziM@8abOs6v0NdrLhZs!QVFGL&?hk+`B=PX7LYt3n9fBU&i<(KKzHN7C89Xs3l|%00 znEv7z9hD5YXea3AZV|fg&AJ8(986LX)21;9t+&svNj@Otm#B>-hd_bV0#6gK7tMGF zpL)U*X{)fCV5w%Z0(l38ElBm@ ze~-8ns>H=|4dc2H$q`gz9j6v|BIC1$trVV?uvffAPCYDr6n>(cPulP8Adga19@Mwc zqrKF3m1KCn>m?wFhbSOWdFtZN*RR^#m66EaIeIabPfyA};xsQt903oY&x8+8B#5n& zTI=*3xz1}4IjG_y$gP9%@?clBnQ?Z&iNMW`i9&<*iFAQ7oM;kT@KTKMYa%Us@(h(x z*%j``5EowBFO8XlyHue)Eu%7L=Kk7jivCk{9j%`CJRxfrn(SFJ(c<@u$`nPsMYZ-- z7Wpar_@h^?mkl5JEv`QR+vsKD+oHI%-j`k{Erw@5gBE(CYDKh+fcjrSoPEy0<-&aU z2g-Ua7$O!EqLOI=S{SV14f|JpM3qqcRROFtFH=9yr5e{4j+2vXXWU*KsQDS2z2W`* z7r^zw$d^k1Ng8XiHk|3?&(lZ402y@H-49LOiQVkV+%W|dg{3@&c+fZKx={+9VGHCU zun{U7zse_$&D81m#lxO)S@VMq{<8+=t%e!`BSNp2qZ#GJB3F*pyG{6@M5cnE^Oei; zXn`jg@Xn|Xz%oGWrRUu_zg}Fs@-*(8mpAnF3u{$7OFAeEbrBOG!TI@$+b%VT*Pk2v zUA;Ku0R*ZwOu+Jsenb$t_H-y|DmP~O8}$v+&9eEP3RhFpW_s3FlA7xTZ1CRK)gS1y zdbZvk$AMxjeWa37pMg`kI<}y7=5Fy+<9W~tuwJ9%gb_;_iGVZr|F`=@ppCC|T0>XL zUq2$`FMy%(#<+)Zb-z^wVTJF|(MH2{qjN(i6is8EcObe(=zrcZ)TkszO3I{IyppME z|J=wLrbgN1J>2Emin_B0`~}RsG>*@cy3}pKsZgf8bnOc+SxYzLJEkUfgmsIlg|G&G(XM9 z#QA6%yR57Ze`_FrgMgnIBp8!=sdPTE#0U<#K-o#Kv~PpuJ#D;3eToIQZ({o5o;^N; z%0{|6!Z^xYI)m`DyhFu0GG)g8>r|zS=@eIWJIL%Yt2^|8pYznosOs>Z0pgi?2?@IU~pM$ zn#+e|K^iP3k2x;7I>N z!Qii2d1-$EOGvj2$3JO?FCR|!vUe@>QA9Tb>BrB2?v|-c8weJ<{)iJfGx#^B!>upW zj>KFrE6|UuHZa*rllH3Sw zmwx<2wl5}ff}Q^14EZu#W`~HZ=BT_Y?c*C0y(y};oyK}pi(v_?njt0#5`??%>`Abh zG^(aso41awgmQvR{UU+PsBiw8LG(rC2fmnkGH!#MGtI~;pi3sz28&z76Nid>bU%uy z(Jb&~J=zv_b1>SFtRCs4!&Q;o+_nyF!^*-gND>#$OU|jjJM{VudRpqYr}OM1BnyW0Z9k z!i*~fFtS*%lfbi86Vf)-*w1yAD5%@NeWH^Pk(pH(@%U$`X|*CfxE|;xakCt9>`o^W z&{sc5os#WE?f{1yTd4ddbgTa+BI)x_)sPld*T+IAYFVcoCt%hUX*ERRy=tnxTrLg%~lz_K^EdtGPVar zuxFN6&Y3ren$Pq#`+&*gZd`3b{{pC_m}OGmRbeAGQa#1A)6k(pY;DGu@vs2EKnYH& z2*$8)-}`b*fBS^5V(v?t2+f8|rddM-)dk9a>w~B!>cz)(sn%9)U;Vo4-8s&pr{R@8 z?a&vA)n=(RYfG5+91xrg(a*Cu?OM@2LRmucd6l%~=9>Yi6+i54?iP~PnP*)@kI}4# zW`=9Fs>XAkNJAg}{+$Lk8cm+zSZJK)J15=+rngm#B3HfSqfQBF#Ic*I?Z?s#A8Y7di5z<5|RHH)fZLiK$aHkA06j~Qe)r_JGk2cs^;XQ!aaTe z)M8YFF8nn(_)piYo7fuU7tz*}E$%bYu004X=7aL9FY|adK6B6@v)5;|3~B%-Z>kmJ z)xhw4T`h8$Y`pI)d#&E~G}qMBqZTv`$*?zKiZj5ePGCjD>-PvwV9->~n^Uggxdvwf z!;ea}%i3S`c1|D4%1NZ>xrH8#c)vZoj~eZxI`5@x`uU;o6px;G+u5V0vkOV1cX?j> z2is3%KTOXXbYxZx9W`q!a1atg0I6z>z7daM*nlUQJ(!Mew9P0`7a_*EA7;;xLSnF2d69VK#vb<(s@LKQt^dQ2x`Pldm z)QhuA70G20XUt0D5I%Y?+nkF#*gqj`QWc%5VD(f=h`8Z(VIxLjNHfH!OYAKJS#F}9 zK&mio6K03(WM?&LBXE=5sA#00y!DtGR>s5y|YxdP;s;ZUz>z)X~aP0#=XM z7p#v!iv+i~g5xv<sfi1dFaLhW{zd(5mb2K!vGl#|6s=Mg=tn|l+l0AO~Z z$z@?a{?A&Hj#h!c%XakVtEDl@PgZYy#1@?*ObiAy6kcANSsb78G4Ym}Y= zx|_SgRL!c%jreTT5L11fkj7XqMx&YCw$O|n%gpq~?0-jX7kkn(EwoHJsjn6awLASY z$>BVj!CY@*lQ_~L z?T36&kZMGh{OAHh(Srs9%{WEH$42vzVCopp)pi|i*XhFP{}i#A4%g%hO>Y#{lo^px z2FY|a%E#<_5A9;OXICASW4;B6Z$q90*mN5ky@}&r#b1si`^whZjTJtFT-#9 z+oD>zz6vHhQF?XZI`W_^I|ca^ zpC%$bQ+ngICH|#!+x_4#frtJ)vL+Ls1YuK(4g?$l#zQ7-7C(0RgPdCQ@RoCv&SRp%7!S*#}9 zz<%YsBxo?nb4qeIdUnzzqXDb?-sSFB_2q!UFuy~DIzhuyz;>*(XCej&r6Hr`vGn2A zW}!Ubkk zSjl$;!Mf_-)kWpgaDmaGgt{wiPbPo?TU~~v#Y^jS>k-VG1~?fgjY$?E=~k?$s0K2r z2PDD!4&pzrM_FNQcvLkX`U{h{nj&|JT?|8rk$TIqomMMAI4(#@J`F7%)H~X2pzzyz z8&XkCH^(?0scqTp*XqmvU?|?mink+%edTs{H%Y7Q^hhSh6_vk$dC;v;K4s+MQgO&- z|KO9(q2mZ`J$$LhKnAyo?#1S<(BqwU^Y~MJ!I<^l@BAOI)h?%u2?!)(STbeLa>p_E zTF>l5VnLOTPQ+r&2{@uG;V0`Y3^Yx9zj)!{kFn*$tPoG9L5v%wBU4xZ4#*Oh;xb!} z=^yxx9K&m^3RC!hY^fwtP6yjEtvpjI3Qep;h|T67+bDj%S%`h|<9bSg*4m)FXzU%H>1$@SqTI;~kb>BepjYIXc|A_8|M7cxqVRA@Wa zpZU%OMIsR65X}X|mmCXBYf9N1BXJGVxS5^O-{-7!#+ryrdsOA`8nj|#I&rJ(W^Yy*&fiT$c1z4xYW3xq>8ELzZYUQ65^Q$ zGl@pa|2BI$%n19H;|<^-rUV?YyAvGbKUw$vfP6u1b(uuno@#3pm7;0!qvXj}t@NtJ z9*t4KLtjgx5XKk0jz)6{`I_G;_aY2q-U0WDUZE%T9yNf35t>QyvBPxSCAf+0hemx6p2+B`V7UeWkc>ql>P<|j1f5IBBvdTdnK=XbxCo#Wc+53z;$DF*ZH zcT^rq-@13Bu;u2EBj^)-U-m!cbwRa!9#)yxLz*)K17@=|Yfg-DS%8ux_-W{?{3QGy zYHY+bnJ@lfQ-RKnBVTR$yqP$N!|H{ZkFg#I)ZHGW?!YQ4xZ(7{k0g zQ9Q%5`)*%Svz{s)>M;gHFW~5DtyP>da^VVGZ7t;k6>eZTZ&%cdc zy`gk{g6`WGQ*-KsaWF7I-4hO-AXFD7L)n!Iz4aqcJgd--CW3Me#S)|{}8>bx(qygD^M_;pf}EVHEe9N~`8K4H`bF1j_of)3HI0Hk z{^0wM-^pyok$N>KiuU^yPM|+Adj^MEllk$d!W%y&;)^1yGgiq}f?)8(g{B5y0a)C+ ze}&r&qxh!s+&tLRw-v+FZvd6*p~zl%W6s_jaa=d?sWBg`$7f9+IP%-il+W>O7f0d3`q zT&|y1g6pt?G%81bmV<}x&euF^(H-(tlx_oqX4QBCcTac-ocF&c7VOgJx9;3VI`$JI z3S`gC8dhb{g5dQSy4B0_4J9bc9>lfI!z%$7(+Gz>12(RNcMd8q6xx3GYIom zO>OW6w-`T|P+YR zKMMHF>l=Gfs`UEH-AJF}5F7P^!GOv0-BNKO32Ml|PT*k}Roc{eTyHB5GPt%Z3 zX@iPSiH#O4Ki&2{PucE&Z|+Sbc_-K*<`h-2Zp3ql#hT||-D(snfrd=J)<1Z`2AGDb zzh8M>WOSpM4U(jQWlx#;;J}Rd_n+?Ejs60ttu>SBQdZ*oPpmw2NiO7G5d0|F_|L+9 z^kJF0G)Q12)SNbC$`rS3E!?|hinBdrfJEzR4W^vD58z}`q2=%&J!m0|l(f9m8<{!v zCR=|lJMX2c76dJcos+!&ZcYk#0;CL`6C3{Uok**~wOqcs z_a(-@3(k0tj^gFkgfL24Ojo70o!#X>J^-)21;p&xn^WPwPP>g%uJ-ZI+!k~M+^-*$ za7n6-DYQM8QP@Zr$oE~IE2UE0R<^V^M!89TU%)-QbQZJTrvF%++-K;C$t;IxAZ-X_ zYSMF;bu~<6sMoWFMue+t|0oDr8kOWvChBs;1>|?xrLYzZ`Ltaj1(Bnl4`P!W9nj^l zNYZ+((^rDCprK#6xPcfesY>bO$u*um|KE#~i?p;5^OD!Y_nDK`z*-cm@((OK83M5- ziL;4+0V|C~R`hIGQbkjyR&lHE&z@87NLzN^;8s9~<<&Qf#o+ZKB*twUga~(&S`y@9 z9$zHLR=g0hJ#}>4fnc$b|8XnrmE=UxT+T?Z1=Kw3Jfil*5z_fpg3(M@Mm*1qZL)jp zgttYQP<{c|@J6MVuvX76#o(J_ zfOat7RL*C(( z5=!GjU$uITbjw<)SK*piQG++`%z%}X2yNXJ+sL`KdG#z4nlz*BHs^&b@>td9W7SHr zNlB@OdcMm(7LNiJaAbyQjJK|`%l4KBHARjMVRBLGMf*T4Z$*@HJ&?c}N2nr4aK_)p zR1TyGI#}uRHG6x1+~@_I&>_@Q<9#)aR^fr6ZaBj`jb6)B!ET&CPRx^=4#kN6xo^VgUgMcY}rMSP0#JM~2pRi5uHbcvd=Kli73i zdTN&Z0>yom)pxv?Z|tJtqw1SR-%Dz3KWZv#p&HQP*?(yU9X0oS9u^ebu5DUlGrmbF z#;bfi{O8E1dn6~`CtH3vheEJtq=+Cwn-|BsyYdGEMU-4{H@~6F8|iKlhuL=tT+v>X z3cl6TV8mZDZ36Zn$N+K+qvAym2d2M(adO1R>D8!@0=i=lF?S=f!^%iqH#*W|%>+JA zvEw_oE4&OTgV4S1VD9%cNGm=q^+t!=Jn;M-};kC{~6ZgcSL8BUJXXw6jAyjxhMbnLG*c} z7KYxH1B;jbd+5h18TSwc*f(ft7X=U?!3zaucJ8^fvjwo^L4*?{Kct5t=t zkR2@|KeDV#1>>`y@_l)HG!%?n9E1b3*VNp?U)oKEoe>yRwCcbZ0<(9q8q6}F+@6Tm7j$cqd4WP;PCo)A_4v*3Uln+}E7G`_q z%@plU+;Ur#y&#!D19O)l!wbusXdWmR%q&=eSkvlpMKevAZag@!xZ5D_l zNdBED-v6AHdB4$tafo{4hlv!KPW@xX%oq1IF$P=izMjyB?b@+?ut)m0r0Y$u9iGJZ z@x&EUC?ma58`avI)tMQJ-Jvty^%eK0Kgob!zqPFUcQfzP^QtF;fPKw^72PWh0d6TJ zyw4*779&{9M$?fY0F=*-CJO$pTu+Dn4o9YRFRy!|q3$PnKm`JG!TAj=!4Bh##?--+fk|>tX@Fh< z%Yqq`X7QO0(WMRi3^fj;QR!+K0xEJLNTzHa=lG3=z0=EeWiVn9ESTh)p&N>;kT^9c zvU|H@kYuTs6-@V#Z4x4fVh>nWj4=N0vbFFDZzpx-Mp@crq(=3aT47Gw*i(I%1k_z-zFsx<@H@*d%N~|OD=WOW^o_3PtJH-vV$-P|O{y(dJu>w} zd4w7D)8;Ron0&wpo2;{I z%IEvu*$r%)VP9U2d20@3Z&bYOsjhn=I6E z(33b!Ssf;ZU@Ipx`d;LDq^}zKlBY{?JjcigB?rGuK;#adnsNFMt3^e|Tay1~bZzuS-{}?3d03 zdw$PzwEHrQc96*j0--OQlzlTqdEeI|LE5|EdR`Wza#YLn$F|`IdyY8W9G#FtH4G6n z1!f*E{JP4z|6Q?;f_zM_L zB03L{OHEN*J>ePe@SWc?b`J0kB{)RY@jVmd?z4=&Ba71J(Q_YbKiT-a`80T`8?}@& zIcuc!=ONiO(Y1Mv3@4pTBgOVgygB2zP6YF53cVs`(LU3|svu=PLn3dSFnNTRp}{kj zE%ToKsj?|)b(w%>FjuHc*}cm$2K}rR{68$4GO#R9K@mSuNU*Gl(m0gAX1UMMm0ISK zH7{{&&V}g)@U(d=XTii-P6UVGMJe>hfQv)-1bJnM)^cZwsB(G5C(F-$QVlZcRO%iC zDK18SDFOw33=B@GH%+fbNOsua0;D<||@v}ff6ct^)Ox|ZT}_n|>FGsjD~zd8uQZv&<~-Ghf|yJh;5NHXc9Af7du{@YXB01BmxZ zhw!x(Nt`X_W44#mtc4x;G~Bo#*RyRWd=2xY#d@!3)C87z!a;MdE(C_`g#5X*p0SX> z#KDz1=|AXpz1M+z=VIT1PMqB`w!;*E{>S(~0f8`n&q8h4E-6l>Bp_@`v>$3WWG9Op zocs-g*t+4ncgB{o9$*LJjbQmmrdKK_Je|b->qxx9)n~&lzNQElDva5G%Q8O3YdORd zk>?vdBvA3>?AX>!D;h}~_p=LD&IxXnCPI|)mGcZ}lrRxFG*=`9AHOYL)Q z?pp+@A!(37Nh*aTg9a%aSvq8I{*L9t_c77uOh1M=>xZ#>Y);b;eVdzC1C6Pt5J)4) zAE>WU@J|&n#P};$4_dC$`2Y*MR{;gJ^&`4Y;QWrFsTjb&dgGeq_7>t##EVuJKUJze z)dJ$Jtnn1xw{ZJ+4U?7BsFj1%4w1HzPb{u#n4TPeV! zk*r60rSLZxaTghJE#?J+VV3xcts5?L7kHa>xRmIus8)+J&}&nc2Z}GwChd<1;|{ro zwqCnR;k>CD?w~b^Aa#li@y%?|$I8Id{JbspEHH>tz$5EXJaxrv;8rXeX6FuD8#QV(5qI14X6W|%~S#M+v`;hG_knEaMzUEt}6AQUuDvx zpO}%~%xUzX#~roUocY&Zz@a9?i4HRMD}Q2(%g5BEspqZ)=2quJ{{R&ai`v`^w-<2R zNLfr}Y{(xzwW09lBGxs-sJkXETa-Kkbj&*X5eBNfMwFzJp@3E9b*7_|U8$iDu?LT` zc2ynGq0%dy2ao_JO$&o63WloeXkeRVcN>2+z@tR&AC<;QPNl>GAoUu5G}t9x@}|#G zDF>rhN@1l~vKBiYjRIEwh)2NG9Rvh!TIA zRZm{0{{Y;sK3~NX4ZlVqZim~{Eg+3LAKsoNqM{^Dw4dCciIVVQfB8ZPdhT_3XXHPM zQ5QD=lDJs{pqsp$q^CS zfwek~918$`HM|v(~nbK&gGX_FMXeMI}n z2g41n9@_;cM97UK{{VXY>Vu2n?x%=vt=1C{1Y9H?GZ}>su$pHccY6rnpNE*!g_~?t zxU`ZIRA7$D(tPHxzQu6HM-rvQv(06v$1!O{gu*g{ubP*yl_kblE4KHSFP2pbbd%g` z{8o$p9WZpO3%qsx7jm*RSBL?)9k&L2>5SPfHi*TILHI^-rWRPm@33-^rr*zVZUE}I z&X7OzO#cAySPnaCTg4tkXcR|tq5lAC^-s0TEz@e7#(_)ru(TbHT|$SX57NB-i&`S{ zh~IH2Jhw`m00;xZNgrD7K25e$E+r#)_6dgDV%!5}=0F^60kvtob@|oS=Y{PqZTT)T zuo9!XVq>?JSAWAZYDWYi4TTjUYC?}lohSCKiB`y3bWe*f(v`_C$6ug9ABQm6 zSz|c|^AiTM4nEND#l*unRXA=I)vc@E^Jew5n_NMKLWJr<^o1m=U3;3; z9pcNZQCI1^yj!+qY$cW&KZdfFhI612lC={CM1jy$qOfapJ`%fBrFx8tNDsq@lw`qj0x+S>mBiOjjB zo0mg?)H+l6N=ed5AEjS-I>k!1ybXxwFj1)6E?GZwRGdMg&Eu>c(&m&{WbGz^ z=EK%1L(CxscFR%*XByWpaDTH?K`q{4=Se9UR67oaqIh}2#p)a$EZRhG8jsvmwLQ|VbQ27J6vmcFmYlPCN`RD-JoP(PkK z83Z3n-Rzdz_UK!Gm`u_S()oWM1_sgF$aFNC6OmU434L*DBB@Ak*!sN zb7*9XMM<8A>M0wGAqp`QpfxhBL4@_`L|a+(+tRzK%Om+Jk1|<}PF}Opl2O~Pm0`cO z24x}$gFR}z@_BA4CV!gL;bm{|W=GU;MK8G@{+vAbpv@Xb-hdQ@_(aiVUSCSlMmc|( zB{8V-qBaujsinzEiceriN?A-KC@9Whr&{F`%RHR9`UOl^n{;|E-=M)d>H!{Nnc{eY zEM2#3Te{$NK_s@B4bH^*O*?0p<7qE8lql|ya{=?xj<84|D=wPpuc)e7Tiua3#lxw? zu2z=Rm8G&qZHWNZd4u3?T5%(4RH;RjxROSNb1EM~ex|g0ixBfAr7WndWm?XKQR%1r z(@}>h7X?WCKLiB?>LaxJ#Y1CwvM5G_rG$IBWl2Fv$`3=VedeIJgN42J?VG!D5c^6p zN@NtML7ytOTeeoC_yi;XK_nSIhiZ1?K`4@y5I@Q_qQW4bBAG$I;2b*^wg%$VA;Khu z858T&edzoV!7T9IqOICWNahliC-V-P9gd_`{3HV){Dl=slOBCV8TnRI{HYY4D#0!9 zEu1Bwr$S0V{{T0Yd6SAbX?P|wO~Yj^1^CQKOwD^H=G6*8@QLYJx3q2#w#8ox$~lES zWXvAkldVKwS3`I+TcR8MKG}P}4yp>$+MH5=078M&T{>wMsw+h3lCGK>%{WU9#duM) z+1feJvLyZzz$$E?@{_-zFo;fxr6ovE@g&GndjbxksO7Mk*X3hAA-6-exq&5M z%0N^}-VJ1YM)Qq(MBwdQX$!i$wdK0lBslHAfPW@|P#N0fR+Ar;wfngu8LGVr@Q2OnnHw^>WAk`hUr zv!Nh)5;Q&F)A4~c*N@&6;vp$H2_;8fngLBPY+lb5!>sYECldX&(=EEtM}VlQAd^0) z$Zu13S7>ZMXvsoLflg;8bD7FS9SVWzG$h(>GTrrPsJm|BE}XVymXd`fUhssNG8L6Z z02@g0b@ZvrYcl<)L*?5y91+V}M}!EOgQoGnTCU*B_tg=HvijJ0@Emd12vR~|%8GO; z1~izBH5Fp8aZFo2mmj$eG!z4APGmx&2_(ttK@kA-(z!{I8QJDeK5?6G@iT7oaE9Bx zv@%pqQOZuU*VnB!_Nq|-02*C1;Dw7cO5=_$9ifbDG01@GBB)-CL#1bT^Hqa7& zY1W?c^|1c{^7o(7NqMCt&VU#M08f$WTG<^qf5Cn=wKpHx8^G-aIEK;^R2?EG@)4=} zew9Jv8x=Lg7YQg*e-UZ}Vnmp&W7(y=;wZ|6t!V?K<{@7Xo}aZt`$E80IF8ZuHaTFF zN<@i?{U_x^$26upI4rp78NISOZKXm4D?_%aGx{2|aSjys^xQidLP>2+WKkV5n4gxE zi})|QVv4Z11O+%Uz}WQl(>JVEyX^Y}Zyol3bA?>ClJ&aWPE`pyXm$1dMPD{Vnvq<1 zsx-{IhOW7M*3=RZQc~uV309Il2-C>bgV`d^7sXh0`>2*1c~ln5A+`lVdocUYmvf z1#U9k6pW4|lX0PfCK99RF+Go!PjG@>MUk?({{Xpaavfxm(ETW!8JhElT4nzL6Nqd! zqDJ~?eq-(pN!i=`z0g{wa+H(h=Si*XUnWgBzk&2C^AdIFDMAQLPLyH+fg4aMCSo-2 zM2bvZG$Is6k`!db2-1(5H9AlO1_>JWq+c;PPPNZO>49?#LZm?=tC*Tdcq#y$>T%Vp zG#v#*VrVKRD)?vCNtMfwyc|fJ)_G_IJz|OkYLKc zzO+^8uEunsNRROP10!m2DB$R2}ulrJ|VZ?^=l27|pcY0;b~X&Y0J-~j>* zohtE_3r#S=2|h3g=}A0GY0MB;Jpk9`P6Qnyh^*42s0fV+9VnJk#;WlTgpFpKT-rA1 z1c0HwwE7RlnLtj&X-K{TLV`$4f=LHiC!nYc8Pg)JY#Mby{xr=|Z}85-GJtP3$)A?$ZAN6X8{s znobhpuPxTKlNRn6QZ|wju7G}O)9hcny>f1!b+(##^HQivhT=8sU{6}iv7{#i+|I||sZx%LI&aF*5`QPB;Agv1QRM?!ZR{V18LRWorVzW$i~tpM8zt7`{KoSced?ZW7zdbO#nB6*{6-6S+!B8}*6Sv|c&k z*PTLMxl%cZR$^KNq5Jxb=gyw++1O3L-NjBp}XOcZz zMpNzcgOb(Vwl!?gDk&|#6V*Kl^rbz9*n9o0!Zc0e8B~RKAb}^iAxa0x`Bv$S;I{a? zN@nE)AwdNv&wtQXWy81~#{_XmTEdO1juwn;rj%n|=iwvKL#-~`YMB`GMtIs^$r@)3 z*`=iBXEG9?b0m#YW^$hKCt8+yosP;H&uRvkEIyZzL2be&H6Fj;dU*OF71c<~mRBj* z#V8w2lMzBjkOd}%48(QPl?nvF8rMQW1YJzY2{XMldO$KYr^<|~0D@wfK8HzIOea7= ziS^Q~*?0|>4zp4BsR|OEe|nN~HBO-Rrk5xnMp*>GGq9@N1x0aMERM&m5X72R_(Sk% z0#iEg>0XDAKG@Sd*Df71G-|oC5updYa+U|mh*vT}-%v#&}wDVAgV|rskfX+gK)3Jf)7JOdmP2av|j!BGO1Fsf!AVajb7$d zrEnuu{`H>@APNBi&y4rjRGuG-a~23tk~i|IJ3~KVBJ$13q(Y`U)P=K!Y7wtlsyLP`AAZe zo=F=IyweO#YjYzEmR)iR!dwc1CM1zNd4o&dHlcf(+rQch<|ywrmlX{hVctDISh1{)c( zOcGpLK;^cikrC!%W2H;UuBEiymHVbH6ynkn>ReGuo5-OJq?pVRpiwe>hKJ6R!?4?v zaM-rHZ7LA56o5;s3Co^lVhGR@CZFA6%_k3NhM7o0IfznS5I_==gc3FX0AU)@Fqai@ z3-*z2g)e0`lcWRifvFHQ+g_2av8!Namto^~UgG1{-r()h0#c&sP*7L|iGU+rHP&-g zfnM=*hXva??y6dp5TwF#_4rPk1HWnORo3wPw5_vudfm$zii>Y}vjRi{4uFHOoescN z&cfZWkU|2IjZvI=XlK(%?M}lTD`ZAzVHQheN>OE?R&ykzo#0H*r(x+*%(S7EDk633 zG+TDw3LMu0Q<_keh*y;V02KAM6Pi;XjVHOQQCZ{~H*AA?Ugx+x0V-=hgh82(`p2YG z_S#8Qh&|)I3RnOnDG4S=l?S{u+hNz84WMr+@WoYtmPY&+WtP^UxI1We`BQ9n24fSm zaiunr=2MnMO4}qB!kf#MBu3FoB^rK}4i<%e{G%&OuHcD!)d|Dy6`V8xPGR|adiFkJ z&ZQKL$@JLw6s7f`b0eq_v>n9}*47e{n597OKZ>h`BK_2wXoSTQZPbU7laL&${g3zF zk!s);5l2A~eLbaGVaX7Bk##A<05AfiN`no_PzkU^2I)Y(W5GE%h@ zns(APu3^z%#lTC!K*2g4DTGh?sz(=s0Ru_uDm5Tw-nTds{{UHm^%&M~{u+^#p*od7 zk26efoXXm}%{y^IB5D^Kd^F0GqgZWOv5Gq?oLze;QggvO)>Fhdk+f5p6)NZ_S*gA` z;sh|_(pRY_1lBjj*UQ8d;(5-XsbMMQm?WP~>d=jix&64di|-HOk6pdZNiGE_p`br8 z?^-txhk1x$ABgDysX#(gRIUTS`F3m}DTRi>p29X2vT(i>(+wmg8|&(6dCPQzxpj@Q zV~1>t8B41Q3IkHT>d|VD5(P1|Wjws30+YBqREr4!2@^!c8b&`WCuTWBBnSWj<+Uh) z02x4y=zzeOnpGf?v{MNg)ATgJB+>y03UOj(TgmxS*))P@=Rl^?CMKn(Qs`#t8b)OV zfJl;Z?@dkt!wIkM{ z?ulu|DG5>&n4IcX@w&Xke`2jlNIFzq_8Qx&ORh?abny=TzLXh7)uFsuc9Sd5BG}=` zO_N0=c$+EE10>1ox!bOt>ZgpqdW(mgxxZyCC0W)~GbtKOk+*PSU~g87w$g<>E>={B z4nmSnpikFLenP4kql~8E(%THEDFw6wf>MDy$3do~PJ^v3y&sHhi+z?@W&78QYeL`v zuMz@+VK6n#nsy&rkZFsCEK!GAHo!DW`0vJ#MB#@kRZ%B|KCi6|yXQ6$XL)@A1{ z!rovsvQL03Gzst4hb&_9$mk-_5YSX9dhRu(+qH4TWV&so=A6I}2>$>~b@ivsA!Q0o zWQ_rw#=7CP@`@7Sl9&WQGAJ(^2lqd)8v!6CxIh8`6si}X3MmRAPP$V@o8>&C23BQh zCu2uaa@;7bB(_o0rvCt~RcNb2gsCb}5I{4y?Lf4GjfETl4nRc4-c(nLlBA_5BohRV zl_@4t*&BSp4Ui<0wPzes%9`SPd)d*(3sKoYK%cQ6O4VFUqfxC;J)~MGKId}Md?~nv zp3;;1z@@X}b!NZQ^DgQvUkZ9sH77w&Up@jz=}@>*!AcU<8i))vop8mWhx(Ya;fdFY%?EFAr=i zw2ph`c1*wnx!lK*tf)cF(l%6$t03y zp0+^iO-2SzN3p_02#Qf^(0aunRg?m#k2+PQ1FS%ujU6F5CL9nKJt(dyPNpczT&f4s zC=`hrokvqqC8D8B0kqO31wCkT}=)u#)>wF^-7W|7n8DEG3p8yJt#F#o) zrmQVewU^y0aY|7#vQCqr){ibEl_vcf!H(N+;6oK@Hz|D#p-U=QAw$FTJMC3GX9LE{ zo;IVJ956Dd0+HHJ_cf>5*rcX1gp~=S2`BRDwO9WD%~FuYPJTQn!*Jdk z;s#a{wpLtQiCc&sB!E@gbtmLSY8Yl2dd0Wmw2a9_=1BkuAH4ci(Thggo>Eeg);gbF zv@Y6aC*ko3Gq2jTaPc9FCHS~bhj8BD4P-%}*~=zY;4AppiuKqtR?Wkm}A0Fh%QASEGW>pM`T)TTrL zRk7}A%JB55BgSSTncZ4kPF9jnh;`6;%?B)B!D!Ah(_@5$rCGs)I^|4Htt_yTH3MGV zDkbOcEtitv5Rzajcl4#xb)=CpCV@=vvi(EFu$8!6FclCCbfpVmi3d?l5qFt0pq!tT;icJm^gk+@aX~<9sO?Pd569GV}lgkg$4BU$~_Z=qQBhHZ_vt)=np*Qu~oo4EkrqzgmxY!U<6G6#SDK>N`^l zp#wv=dV*4-FB2fO+{HqsV``ebNC_vbR2d`~{i{#u^Q%Tb)uDY^Z^iE6txN%|)l&py zgg<=YADG~x5Rwv+pa5-0;9|Gj57hH%V_)>A z@t+Sg@|AMZ5|F8w8y)IHg7DNcY|;=0fN$xoNO1~>^tGc>RaFkQ4D5g<;ilgh5v4(x!0L4p2IS9YrAs zj^#v~DhNnO{{V_rE&{abNE_279KJnCj`~+N`Vu4&O(9o~_DU)fpT2uO)Xc86;Te1ZBvudcGNdZC#9#mqnn2ipVUrsjGQWY{Nt>bNwNQ3EB zl#>MbSwBSMhcxzzgvapa@f#-?GKG=hSlkh)KOTMLU0Om6(5mw|BA#vh5i?Y|CT%-xcrZkS;xQ_^Id(Ye+{){BE+*KVn& zZc>KRColpJar5a&a3(!#jUho6*DOIIT|rJ`zh1Ei=T}Bh5Qx|3TXUa+D26CrruQO37`kYQ}w-%%&K&cU! z0tpk}wJ5!5o*9PO;mx{>D|E0PaZfn}lBKJ45_KlH;q`G8M#{EUx^Lc$CTorpaT0^D zJ>XS)6T@#8a$UAoWUF+kdmk_2tkhC~dJ3eVrRYp~WsTY^YqE0Q+%DnF%F;qi0DoFf z-qPEgktt9c5&KnB!XAt6Ah zbTocwQVb5YT><-XE~QCO@6*<#Ev`mHiPY+8c)x}>E;>rq$BrO4fC`B51qf6@257FR z0Qz<@s8fwDIlL7$prMS`YHt<0i$UpsR zHpycto}#k33bHeAjIR>1R464H>S?XO6Pv9axyUqxfiXg~$~|ZdA|oykOM3R}Rp-(J zsu9+#w}A4hnIeBz!s#p7p@Mf_m@Xo3yOo5Y(AJ z#VM+Y8Au|%2ObI-(mcN_H{8hNN8`RBZqU>z!lUvZ?^2jZ31mk53W~-g+}nC?6BWg6=g+laAN@cRdD6r?oT5h_}Q1disa zSY{`~c!}pVa^4{GYK0Z3FDdPpVORXCNG2R{?XpE&O!dGCv$nguQcj9(vQz&6+iFp# zIF|4Xmd)BpiG9I8M9nz7#hOD203|7n2Eb8mG1eA?k|jElXnSL>qO^yN;P*ipuW?s% zY*NU|KBi3>yBiU5k~1kl6ookIi6gF)9VvzWEh!~S&F*_NJ0iEYcZ;HFf$oiGz#_QHyFx)cU%H{Hgh-i?cl`25c2pys- zWs;R36u|^Ys9Z4tO(ho&mlmC6MisAc4!^xkpa75tfCvJ$lIp1QQ*FJ601lPa)x^Nw zD~bel#I%yP2ueoe^rHd@(vc1}?sTPTSOld)KGbxGOighX7;n7LjcB5SHlPhBt#LLP zMJP#8&N>b0*e>8?38-GkXamPSm3=0l67y#lE{9kzAC-&)-V*kzG#<*$pvMo$Fi5H#AEGOW$Wkwm^xODbDw8T{!eny-Eoziwvk`+@JQtQMYn(v?b;0Dz!K z`GHtBvfC~q!+4|LTeG-zqT1VaEu^TbM2!T@9c$3--(dF_PFg?8xw~ur>J;i$q;@4B zm>(*gWma~K5|lQB5+n4YEVG1MQuOcfvC3*~+h3xL!nlg@yscmt>Lxr+tXUqt9`CHV4@He;|2;q+ND3PGKTca>r30AoS7fNOn-*>i76%2 z*@L_XRDb@o$ND!GC2C7{ctXVZTy@2bd%zmiuMo#lr*tGHW|FhVTPIQivcM$ITIAUg zwnVYQSo_VDU1FALTBpL>DuLz!H79GsxRZ?8SYEw6=d;cOB%vfAdI4VZ=}pr zo*9fG$B=@wl&eB#V^>@v>ztG~hn96dC5N^7KtHuP#8SI6exWY0raa*M5xh0LrqxZn zs47#J1=TCnM|1LuwS^p~VNp10!B_V`>DfD^NnCwQyQV*n{a%*#SUMSypm9x>3r~n3WF^)6R!+wIt%g6olqN zN{j${_lmC3Q=Uqe--T*#P-F=^>L>!q@F1kY*V4IbQGu}7$4WC}NP)if%G)hMsTxoz zq^J;N$(laGfE{Y*1WQRua-FGfK}Bj?t^eBP|%f#dx0Q5DFy@x=IG#0iu4uRpSbHsT!EmwPbuC z@%M(#kUCbCgj^B?NcF5NyOU?Af=V=8?HnXQ*G(zwV3^cpjR)^g_Dk@E1x}SD!t)>! zHqwL!OJc7=TL8ymY9ZxhaF3lzI-`~h8J+2a!!qZ}u8I+}s1h{L(rOYljXdk8Vkf-< zw32q~Ue1pIHNQgKwOTFm6{-NwpxjCNO*X?E-73*Q_32Nuf)sV1Dv!NSCg+%+fy1x< zLbJKJyGOG4Ha?fg={}F-vD+-EDTNKX(pb6{s}-j-sHDIL%9>%{qi4c=SBG&B#KKLhw)YBHdF25jPVq7b(mK}7X^i5}G?rS!UUYC*assfyN z>f)QWX(?lgXPf;Q0aLOrfj>Pc{{6%a_znd6{Ltt>wHpB9cfY4FZ&KSg3T-9Sg=m!# zsPvhqZgER(Je3=kQLeI~{fb^mr{LwLtr4!`IF$Y-6MBd{XlW#4jk2=0>~Bfd2P}Uz zZf_q&%W`roj=CSxqj4LnWfHG;LbTs4O@7f1O$cDv@4a8LlymH(;aZwL6Xt75z;RAW zklrVY*xUeR4Ysn{=gtTR(hX)-_v%96Go330fChuku3Rja@6g(9*uHQDjKNlDAb;+p z?f0T%pI-#|S0%P<7)CX2q=dmK$_F!fAI(3*-aO?1RS8Hj>P=TLKpSK*;$`MWr%is9 zJ8JJNlAfo3I@E-_QRn9pb(ad;(1Lmq)|0$Ph$$y2o&4#ItEetyN^<7Z6st-7smrR+ zwoIRdsy0_c=xFUz7V(K)GW*Uj^EYDMC|tabT35^;<&*IGjcMr!QY69Cew088NmN9{ zidCT5Y@`4|-l*9voEsa!%|IJW=9L3I4IBjMnA>UxLWZf-brn{=O4wB>gn$&71_>r9 zHMpJiaE*Wf(IF^7S1ds@sq6Eklw4B0B`E_^2k%mqm!Re1;3*O(P3dI1lqM2p2GdA{ z5;Nu`ldU3zqDd|QPW67YL)iVhlqq>Z6TD8eyoCy{+gRG#VmDZWYb$=>%Ci}bv;cR3 z^c|_vaU_d?(IR_h6ZERz%Q;1|Tp6J|QbBUqSR0LKRN`Y(1H9Cb7U6kXQo9cEUS9tI z6nLf@>+IV%bm_ek3m`-!jplXOX{<+1zK_NnT3PU!2Xy!JJ^seL*X`$nZgJHs7{k#w ztSFZnANO7@U_ALJ)*q+N3+%~qUmJpfIPJws$$thz#Xc$hqyoz497~Cw{u_!2fYNWqhy+i zj?{~)InRVpHo0sCEk*{aJ!p;-n8e4WS~~*P$c)qCBYwVART1!==-R>90Ad9u=?N2* zO?zg1_~#Y@(C&V8o6RNW3RayD>)wZW)2s>w?-FQpR<>P9#o8HJsZ2bkmH<#c*{Q1s zjKpuHHASO$tt}d%)OeD=*!fdTKIKa=u9_b@$mHO7-}KK>!Q}nu{{U#StxY=CRUO8_ z^`<8S!k@SbdfPgqQmtpT4Y4RnT!3SxKVfl|N)kzt(gtXU;|lqr0%u4qXkPNr)994Y}OOvdyVsBPP2A!(HL9qPTBXEx6v;n!SW zeT=hrZ-jqOw9b(0Ma6`vKbuvmXVd9gQE`N_pqq!>F{)BjqxP+D4B`AGrvs_8X-Ybh zv;t~E=ElGU1lqQkIzm9BWXoWo7~}P}V)$C07frU5Uo^E48(c&vz#VH|!!EG=Cdsz% zv5Z3zxCs&q>nNZ6Q@Gc2y+q=ctc4H4MuRXhs5KkTC@Jx~8xRzAF+KLF$2H+jL>!T% z;MZw43w^K<3Pz)1D#q2)a)*c>IRzujuimrH6ueuHa#l73f;xfF)s-(EctDVVp9r0& zT7DX%8zeSb6_^Cv+7L+bW$Qo3ol0F~Hb&}7d7QNm^qoJNrdr%Q)E3r38h}ofXl~@) z;OmqKDa?>g+D$PQ#b+Ioj4L;VTBSot=zCB&f`>Vcoety9qH!y?Ev@YDt^6r)%FuP6 z5BpSu7d+-r?q36`&6M;z)akO7L)17FpNCL3ibYTDDOAqU5lz(MgDG*cKw4x@@z~O= ztD4eZPJ$sxI?Z$a5+v*r6t_qxXd3!an@o)iC$ZenKJHivNbz?xTXI*bj=7?(1b1x* zeMv)l_(#~&JDb~PP@-IJ37vQPRD;tXnQE32>lkm?bg4V>(dtPp-*= zv5qs3lu8?PCVC)i6z7B4P2Ov@610WEB+uTf*xnp$Y?Mpf)T%;dI*rKn+N8Jv_1j=) zm8f=Hg&B!EdQhP*%ruu+Xwi`X1P{st(+iL++AA59+>${v6R*;j59pl_i5Z9c(>zEa z`$abPV%n1;3=!A%rYgn0O2#jXDW@_O=CpUi1pffrSNTto-^!~uuJfcTRdjm z*2j{x#