diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..506bcda --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +DCLIST_TOKEN= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..edfd70e --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +### Eclipse ### +.metadata +bin/ +tmp/ +*.tmp +*.bak +*.swp +*~.nib + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +# pyenv +.python-version +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +### VisualStudioCode ### +.vscode/* +.vscode/ +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +### CURRENT PROJECT ### +temp/ +*.log +*.log.* \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..edd0d06 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 ilkergzlkkr + +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. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..74215c3 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include README.md +include LICENSE \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e11c9f --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# DCList.net Python SDK + +This module is official python sdk for dclist.net. + +It's open-source and always open to prs and contributions. + +## Installation + +You can install package via [pip](https://www.pypi.org/dclist.py) or [github](https://github.com/dclist/python-sdk) with following commands : + +**Recomended**: +``` +pip install dclist.py +``` + +or + +```sh +git clone https://github.com/dclist/python-sdk.git +cd python-sdk +python -m venv env +pip install . +``` + +## Gettings Started + +### Posting botstats automaticly as a Cog: +```py +import dclist + +from discord.ext import commands, tasks + +class dclistpy(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.dclistapi = dclist.DCLClient(bot, "YOUR_TOKEN_HERE") + # you can get the token from your bot's page on dclist.net + # you have option to pass your token as environment variable as `DCLIST_TOKEN` + self.update_stats.start() + + def cog_unload(self): + self.update_stats.cancel() + + @tasks.loop(minutes=30.0) + async def update_stats(self): + await self.bot.wait_until_ready() + try: + await self.dclistapi.postBotStats() + except dclist.DCListException as e: + print(e) + # print sucs lol use logger instead :walter_the_dog: + else: + print('Posted stats to dclist.net successfully') + +def setup(bot): + bot.add_cog(dclistpy(bot)) +``` + +### Getting bot or user info from api: +```py + @commands.group(invoke_without_command=True) + async def dclist(self, ctx): + await ctx.send('available commands -> `dclist bot` `dclist user` `dclist voted`') + + @dclist.command(name="bot") + async def get_dclist_bot(self, ctx, bot_id): + bot = await self.dclistapi.getBotById(bot_id) + to_send = f"found bot {bot['username']} using this github {bot['github']} and vote_count is {bot['stats']['voteCount']}" + await ctx.send(to_send) + + @dclist.command(name="user") + async def get_dclist_user(self, ctx, user_id): + user = await self.dclistapi.getUserById(user_id) + to_send = f"found user {user['username']} using this website {user['website']} and discriminator is {user['discriminator']}" + await ctx.send(to_send) + + @dclist.command(name="voted") + async def get_dclist_user(self, ctx, user_id): + is_voted = await self.dclistapi.isUserVoted(user_id) + if is_voted: + await ctx.send('yessir, i did voted from this dude.') + else: + await ctx.send('this user is not voted :(') +``` +## More + +You can use sdk to get more information like `getUserComment`. \ No newline at end of file diff --git a/dclist/__init__.py b/dclist/__init__.py new file mode 100644 index 0000000..bd7a866 --- /dev/null +++ b/dclist/__init__.py @@ -0,0 +1,26 @@ +""" +GQL-API wrapper +~~~~~~~~~~~~~~~~~~~ + +A gql wrapper for the dclist.net API. + +:copyright: (c) 2021-present ilkergzlkkr +:license: MIT, see LICENSE for more details. + +""" + +__title__ = 'dclist.py' +__author__ = 'ilkergzlkkr' +__copyright__ = 'Copyright 2021-present ilkergzlkkr' +__license__ = 'MIT' +__version__ = '0.1.0' + +from collections import namedtuple +VersionInfo = namedtuple('VersionInfo', 'major minor micro releaselevel serial') +version_info = VersionInfo(major=0, minor=1, micro=0, releaselevel='final', serial=0) + +version_info = f'{version_info.major}.{version_info.minor}.{version_info.micro}' + +from .client import DCLClient +from .gqlhttp import GQLHTTPClient +from .errors import * \ No newline at end of file diff --git a/dclist/client.py b/dclist/client.py new file mode 100644 index 0000000..a13a94a --- /dev/null +++ b/dclist/client.py @@ -0,0 +1,154 @@ +""" +MIT License + +Copyright (c) 2021 ilkergzlkkr + +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 logging, os +import typing as t + +from . import errors +from .gqlhttp import GQLHTTPClient + +log = logging.getLogger(__name__) + +class DCLClient: + """ + API wrapper for dclist.net + -------------------------- + Parameters + ---------- + bot: discord.Client + An instance of a discord.py Client object. + token: str + Your bot's Dclist.Net API Token. + + **loop: Optional[event loop] + An `event loop` to use for asynchronous operations. + Defaults to ``bot.loop``. + **transporter: Optional[gql.transport] + A `gql.transport` to use for transporting graphql queries. + """ + + def __init__(self, bot, api_token: t.Optional[str]=None, *args, **kwargs): + if api_token is None: + log.warning("No Token Provided. DCLClient never gonna post bot stats.") + self.bot = bot + self.bot_id = None + self.loop = kwargs.get("loop", bot.loop) + self.http = GQLHTTPClient(api_token, loop=self.loop, transporter=kwargs.get('transporter')) + + async def __get_ready(self): + await self.bot.wait_until_ready() + if self.bot_id is None: + self.bot_id = self.bot.user.id + + async def _get_app_info(self): + await self.__get_ready() + return self.bot_id, (await self.bot.application_info()).owner.id + + + async def postBotStats(self, guild_count: t.Optional[int]=None, + user_count: t.Optional[int]=None, shard_count: t.Optional[int]=None): + """ + Post bot stats to the API + Parameters + ---------- + :param guild_count: Guild count (optional) + :param user_count: User count (optional) + :param shard_count: User count (optional) + """ + await self.__get_ready() + if guild_count is None: + guild_count = len(self.bot.guilds) + if user_count is None: + guild_count = len(list(self.bot.get_all_members())) + data = await self.http.postBotStats(guild_count, user_count, shard_count) + return data['postBotStats'] + + async def getBotById(self, bot_id: t.Optional[int]) -> dict: + """ + Get a bot listed on dclist.net + Parameters + ---------- + :param bot_id: Bot id to be fetched + if bot_id is not given. self bot will be used for getting stats. + Returns + ------- + bot: Bot as a dict fetched from gql-api + """ + if bot_id is None: + bot_id, _ = await _get_app_info() + + data = await self.http.getBotById(bot_id) + return data['getBot'] + + async def getUserById(self, user_id: t.Optional[int]) -> dict: + """ + Get a user from dclist.net. + Parameters + ---------- + :param user_id: User id to be fetched. + if user_id is not given. self bot owner will be used for getting stats. + Returns + ------- + user: User as a dict fetched from gql-api. + """ + if user_id is None: + _, user_id = await _get_app_info() + + data = await self.http.getUserById(user_id) + return data['getUser'] + + async def isUserVoted(self, user_id: t.Optional[int]) -> bool: + """ + Is user voted for my bot from dclist.net. + Parameters + ---------- + :param user_id: User id to be checked. + if user_id is not given. self bot owner will be used for getting voted info. + Returns + ------- + :return bool: True or False is user voted. + """ + if user_id is None: + _, user_id = await _get_app_info() + + data = await self.http.isUserVoted(user_id) + return data['isUserVoted'] + + async def getUserComment(self, user_id: t.Optional[int]) -> dict: + """ + Get a user comment from dclist.net from your bot page. + Parameters + ---------- + :param user_id: User id to be checked. + if user_id is not given. self bot owner will be used for getting comment info. + Returns + ------- + :return Comment: Comment stats as a dict fetched from gql-api. + given user must be commented to your any bots. if not so. return value will be None. + """ + if user_id is None: + _, user_id = await _get_app_info() + + data = await self.http.getUserComment(user_id) + return data['getUserComment'] diff --git a/dclist/errors.py b/dclist/errors.py new file mode 100644 index 0000000..81f3cda --- /dev/null +++ b/dclist/errors.py @@ -0,0 +1,59 @@ +""" +GQL-API wrapper errors module +~~~~~~~~~~~~~~~~~~~ +A gql wrapper errors module for the dclist.net API. +:copyright: (c) 2021-present ilkergzlkkr +:license: MIT, see LICENSE for more details. +""" + +class DCListException(Exception): + """Base exception class for dclistpy. + + + This could be caught to handle any exceptions thrown from this library. + """ + pass + +class ClientException(DCListException): + """Exception that is thrown when an operation in the :class:`DCLClient` fails. + + These are usually for exceptions that happened due to user input. + """ + pass + +class NoTokenProvided(DCListException): + """Exception that's thrown when no API Token is provided. + + Subclass of :exc:`DCListException` + """ + pass + +class HTTPException(DCListException): + """Exception that's thrown when an HTTP request operation fails. + + Attributes + ---------- + response: `aiohttp.ClientResponse` + The response of the failed HTTP request. + text: str + The text of the error. Could be an empty string. + """ + + def __init__(self, response, message): + self.response = response + if isinstance(message, dict): + self.text = message.get('message', '') + self.code = message.get('extensions', {}).get('code', 0) + else: + self.text = message + + fmt = f"{self.text} (status code: {getattr(self, 'code', 0) or self.text})" + + super().__init__(fmt) + +class Unauthorized(HTTPException): + """Exception that's thrown for when unauthorized exception occurs. + + Subclass of :exc:`HTTPException` + """ + pass \ No newline at end of file diff --git a/dclist/gqlhttp.py b/dclist/gqlhttp.py new file mode 100644 index 0000000..5b07325 --- /dev/null +++ b/dclist/gqlhttp.py @@ -0,0 +1,128 @@ +""" +MIT License + +Copyright (c) 2021 ilkergzlkkr + +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 logging, os +import typing as t +import json +import asyncio +from gql import Client, gql +try: + from gql.transport.aiohttp import AIOHTTPTransport +except ModuleNotFoundError: + raise DeprecationWarning('please uninstall gql and install with "pip install --pre gql[aiohttp]"') +from gql.transport import exceptions + +from .helpers.queries import Queries +from . import errors + +logging.getLogger().setLevel(logging.DEBUG) ## delete this + +log = logging.getLogger(__name__) +logging.getLogger('gql').setLevel(logging.WARNING) + +def json_or_text(response, *, retry=False): + try: + return json.loads(response) + except Exception as e: + if retry: + response = response.replace("'", '"') + return json_or_text(response) + return response + + +class GQLHTTPClient: + + BASE = 'https://api.dclist.net/graphql' + # we're using gql-aiohttp api but this sdk can provide rest and ws api ? :thinking: + + def __init__(self, api_token, *args, **kwargs): + self.loop = kwargs.get('loop') or asyncio.get_event_loop() + self.token_provided = api_token is not None + self.__auth = 'Bearer '+ (api_token or os.getenv('DCLIST_TOKEN') or '') + + self._transporter = kwargs.get('transporter') or AIOHTTPTransport(url=self.BASE, headers={'Authorization': self.__auth}) + + + async def execute(self, query, variable_values: t.Optional[dict]=None): + async with Client(transport=self._transporter) as session: + try: + raw_response = await session.execute(gql(query), variable_values=variable_values) + log.debug('RAW_RESPONSE :: %s', raw_response) + response = json_or_text(raw_response) + log.debug('json payload :: %s', response) + return response + except exceptions.TransportQueryError as e: + data = json_or_text(str(e), retry=True) + exc = errors.HTTPException(e.__repr__(), data) + code = getattr(exc, 'code', '') + log.debug('raised exc. code=%s, message=%s', code, exc.text) + if 'rate limit' in exc.text or code == 'RATE_LIMIT': + log.warning('we are being ratelimited from dclist.net gql-api') + elif code == 'BAD_USER_INPUT': + raise errors.ClientException(exc.text, code) + raise exc + + + async def postBotStats(self, guild_count, user_count, shard_count): + query = Queries.postBotStats + params = {"stats": { + "guildCount": guild_count, + "userCount": user_count, + "shardCount": shard_count or 1}} + if self.token_provided or os.getenv('DCLIST_TOKEN'): + log.debug('posted bot stats, guild_count=%s, user_count=%s, shard_count=%s', guild_count, user_count, (shard_count or 1)) + return await self.execute(query, params) + # {'postBotStats': True} + + return {'postBotStats': False} + + async def getBotById(self, bot_id): + raw_query = Queries.getBotById + fields = Queries.bot_fields() + query = Queries.fields(raw_query, fields) + params = {"botId": str(bot_id)} + return await self.execute(query, params) + # {'getBot': {'id': '297343587538960384', 'username': 'Eevnxxbot', 'website': 'https://eevnxx.tk' ...}} + + async def getUserById(self, user_id): + raw_query = Queries.getUser + fields = Queries.user_fields() + query = Queries.fields(raw_query, fields) + params = {"userId": str(user_id)} + return await self.execute(query, params) + # {'getUser': {'id': '223071656510357504', 'username': 'Eevnxx', 'discriminator': '4378', 'github': https://github.com/ilkergzlkkr ...}} + + async def isUserVoted(self, user_id): + query = Queries.isUserVoted + params = {"userId": str(user_id)} + return await self.execute(query, params) + # {'isUserVoted': False} + + async def getUserComment(self, user_id): + raw_query = Queries.getUserComment + fields = Queries.comment_fields() + query = Queries.fields(raw_query, fields) + params = {"userId": str(user_id)} + return await self.execute(query, params) + # {'getUserComment': None} diff --git a/dclist/helpers/queries.py b/dclist/helpers/queries.py new file mode 100644 index 0000000..202eb41 --- /dev/null +++ b/dclist/helpers/queries.py @@ -0,0 +1,50 @@ +class Queries: + postBotStats = """mutation postBotStats($stats: BotStatsInput!) { postBotStats(stats: $stats) }""" + getBotById = """query getBotById($botId: String!) { getBot(botId: $botId) { $FIELDS$ } }""" + getUser = """query getUser($userId: String!) { getUser(userId: $userId) { $FIELDS$ } }""" + getUserComment = """query getUserComment($userId: String!) { getUserComment(userId: $userId) { $FIELDS$ } }""" + isUserVoted = """query isUserVoted($userId: String!) { isUserVoted(userId: $userId) }""" + # if you guys wanna implement wss:subscribe we're always open to prs and contributions! + + @staticmethod + def fields(query:str, fields) -> str: + return query.replace('$FIELDS$', str(fields)) + + @staticmethod + def user_fields() -> str: + return """ + id + username + discriminator + avatar + website + github + """ + + @staticmethod + def bot_fields() -> str: + return Queries.user_fields() + """ + stats { + userCount + guildCount + voteCount + } + prefix + prefixType + tags + """ + + + @staticmethod + def comment_fields() -> str: + return """ + type + like + content + subject { + """ + Queries.user_fields() + """ + } + author { + """ + Queries.user_fields() + """ + } + """ \ No newline at end of file diff --git a/examples/dclistcog.py b/examples/dclistcog.py new file mode 100644 index 0000000..1d69519 --- /dev/null +++ b/examples/dclistcog.py @@ -0,0 +1,52 @@ +import dclist +import logging + +log = logging.getLogger(__name__) + +from discord.ext import commands, tasks + +class dclistpy(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.dclistapi = dclist.DCLClient(bot) + self.update_stats.start() + + def cog_unload(self): + self.update_stats.cancel() + + @tasks.loop(minutes=120.0) + async def update_stats(self): + await self.bot.wait_until_ready() + try: + await self.dclistapi.postBotStats() + except dclist.DCListException as e: + log.warning(e) + else: + log.info('Posted stats to dclist.net successfully') + + @commands.group(invoke_without_command=True) + async def dclist(self, ctx): + await ctx.send('available commands -> `dclist bot $botId` `dclist user $userId` `dclist voted $userId`') + + @dclist.command(name="bot") + async def get_dclist_bot(self, ctx, bot_id): + bot = await self.dclistapi.getBotById(bot_id) + to_send = f"found bot {bot['username']} using this github {bot['github']} and vote_count is {bot['stats']['voteCount']}" + await ctx.send(to_send) + + @dclist.command(name="user") + async def get_dclist_user(self, ctx, user_id): + user = await self.dclistapi.getUserById(user_id) + to_send = f"found user {user['username']} using this website {user['website']} and discriminator is {user['discriminator']}" + await ctx.send(to_send) + + @dclist.command(name="voted") + async def get_dclist_user(self, ctx, user_id): + is_voted = await self.dclistapi.isUserVoted(user_id) + if is_voted: + await ctx.send('yessir, i did voted from this dude.') + else: + await ctx.send('this user is not voted :(') + +def setup(bot): + bot.add_cog(dclistpy(bot)) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5ddbdd1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +gql[aiohttp]>=3.0.0a5 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..934eba2 --- /dev/null +++ b/setup.py @@ -0,0 +1,44 @@ +from setuptools import setup +import re + +requirements = [] +with open('requirements.txt') as f: + requirements = f.read().splitlines() + +version = '' +with open('dclist/__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') + +extras_require = {'dev': ['python-dotenv']} +readme = '' +with open('README.md') as f: + readme = f.read() + +setup( + name = 'dclist.py', + packages = ['dclist', 'dclist.helpers'], + version = version, + license = 'MIT', + description = 'GQL-API wrapper for dclist.net', + author = 'ilkergzlkkr', + author_email = 'guzelkokarilker@gmail.com', + url = 'https://github.com/dclist/python-sdk', + download_url = 'https://github.com/dclist/python-sdk.git', + install_requires = requirements, + long_description = readme, + extras_require = extras_require, + long_description_content_type = "text/markdown", + classifiers = [ + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3', + 'Natural Language :: English', + 'Operating System :: OS Independent', + 'Topic :: Internet', + 'Topic :: Software Development :: Libraries', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Topic :: Utilities', + ], +) \ No newline at end of file