diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000..1afb6e7 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,5 @@ +build: + image: latest + +python: + version: 3.6 \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..0b756d9 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +language: python +python: + - "3.6" + - "3.7-dev" +install: + - pip install -r requirements.txt + diff --git a/build_all.bat b/build_all.bat new file mode 100644 index 0000000..6779fdf --- /dev/null +++ b/build_all.bat @@ -0,0 +1,5 @@ +@echo off + +python setup.py sdist +python setup.py bdist_egg +python setup.py bdist_wheel diff --git a/ksoftapi/__init__.py b/ksoftapi/__init__.py new file mode 100644 index 0000000..85deb53 --- /dev/null +++ b/ksoftapi/__init__.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- + +"""KSoft.Si API Wrapper with discord.py integration +""" + +__title__ = 'ksoftapi' +__author__ = 'AndyTempel' +__license__ = 'MIT' +__copyright__ = 'Copyright 2018 AndyTempel' +__version__ = '0.1.2b' + +import logging +from collections import namedtuple + +from .client import Client +from .data_objects import * +from .errors import * +from .events import * + +VersionInfo = namedtuple('VersionInfo', 'major minor micro releaselevel serial') + +version_info = VersionInfo(major=0, minor=1, micro=2, releaselevel='beta', 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/ksoftapi/client.py b/ksoftapi/client.py new file mode 100644 index 0000000..296a6c4 --- /dev/null +++ b/ksoftapi/client.py @@ -0,0 +1,244 @@ +# -*- coding: utf-8 -*- +import asyncio +import logging +import time +import traceback + +from .data_objects import Image, RedditImage, TagCollection, WikiHowImage, Ban, BanIterator +from .errors import APIError +from .events import BanEvent, UnBanEvent +from .http import krequest, Route + +logger = logging.getLogger() + + +class Client: + """ + .. _aiohttp session: https://aiohttp.readthedocs.io/en/stable/client_reference.html#client-session + + Client object for KSOFT.SI API + + This is a client object for KSoft.Si API. Here are two versions. Basic without discord.py bot + and a pluggable version that inserts this client object directly into your discord.py bot. + + + Represents a client connection that connects to ksoft.si. It works in two modes: + 1. As a standalone variable. + 2. Plugged-in to discord.py Bot or AutoShardedBot, see :any:`Client.pluggable` + + Parameters + ------------- + api_key: :class:`str` + Your ksoft.si api token. + Specify different base url. + **bot: Bot or AutoShardedBot + Your bot client from discord.py + **loop: asyncio loop + Your asyncio loop. + **low_memory: bool[Optional] + Low memory mode, save images to files instead of memory. WIP + + """ + + def __init__(self, api_key: str, bot=None, loop=asyncio.get_event_loop(), **kwargs): + self.api_key = api_key + self._loop = loop + self.http = krequest(global_headers=[ + ("Authorization", f"NANI {self.api_key}") + ], loop=self._loop, lowmem=kwargs.get("lowmem_mode")) + self.bot = bot + + self._ban_hook = [] + self._last_update = time.time() - 60 * 10 + + if self.bot is not None: + self.bot.loop.create_task(self._ban_updater) + + logger_string = str("NANI") + self.api_key[-4:].rjust(len(self.api_key), "*") + logger.info(f"KSOFT API Logging in as {logger_string}") + + def register_ban_hook(self, func): + if func not in self._ban_hook: + logger.info("Registered event hook", func.__name__) + self._ban_hook.append(func) + + def unregister_ban_hook(self, func): + if func in self._ban_hook: + logger.info("Unregistered event hook", func.__name__) + self._ban_hook.remove(func) + + async def _dispatch_ban_event(self, event): + logger.info('Dispatching event of type %s to %d hooks', event.__class__.__name__, len(self._ban_hook)) + for hook in self._ban_hook: + await hook(event) + + async def _ban_updater(self): + await self.bot.wait_until_ready() + while not self.bot.is_closed(): + try: + if self._ban_hook: + r = await self.http.request(Route.bans("GET", "/updates"), params={"timestamp": self._last_update}) + self._last_update = time.time() + for b in r['data']: + if b['active'] is True: + await self._dispatch_ban_event(BanEvent(**b)) + else: + await self._dispatch_ban_event(UnBanEvent(**b)) + except Exception as e: + logger.error("Error in the ban update loop: %s" % e) + traceback.print_exc() + finally: + await asyncio.sleep(60 * 5) + + @classmethod + def pluggable(cls, bot, api_key: str, *args, **kwargs): + """ + Pluggable version of Client. Inserts Client directly into your Bot client. + Called by using `bot.ksoft` + + + Parameters + ------------- + bot: discord.ext.commands.Bot or discord.ext.commands.AutoShardedBot + Your bot client from discord.py + api_key: :class:`str` + Your ksoft.si api token. + + + .. note:: + Takes the same parameters as :class:`Client` class. + Usage changes to ``bot.ksoft``. (``bot`` is your bot client variable) + + """ + try: + return bot.ksoft + except AttributeError: + bot.ksoft = cls(api_key, bot=bot, *args, **kwargs) + return bot.ksoft + + async def random_image(self, tag: str, nsfw: bool = False) -> Image: + """|coro| + + This function gets a random image from the specified tag. + + Parameters + ------------ + tag: :class:`str` + Image tag from string. + nsfw: :class:`bool` + If to display NSFW images. + + + :return: :class:`ksoftapi.data_objects.Image` + + """ + g = await self.http.request(Route.meme("GET", "/random-image"), params={"tag": tag, "nsfw": nsfw}) + return Image(**g) + + async def random_meme(self) -> RedditImage: + """|coro| + + This function gets a random meme from multiple sources from reddit. + + + + :return: :class:`ksoftapi.data_objects.RedditImage` + + """ + g = await self.http.request(Route.meme("GET", "/random-meme")) + return RedditImage(**g) + + async def random_aww(self) -> RedditImage: + """|coro| + + This function gets a random cute pictures from multiple sources from reddit. + + + + :return: :class:`ksoftapi.data_objects.RedditImage` + + """ + g = await self.http.request(Route.meme("GET", "/random-aww")) + return RedditImage(**g) + + async def random_wikihow(self) -> WikiHowImage: + """|coro| + + This function gets a random WikiHow image. + + + + :return: :class:`ksoftapi.data_objects.WikiHowImage` + + """ + g = await self.http.request(Route.meme("GET", "/random-wikihow")) + return WikiHowImage(**g) + + async def random_reddit(self, subreddit: str) -> RedditImage: + """|coro| + + This function gets a random post from specified subreddit. + + + + :return: :class:`ksoftapi.data_objects.RedditImage` + + """ + g = await self.http.request(Route.meme("GET", "/rand-reddit/{subreddit}", subreddit=subreddit)) + return RedditImage(**g) + + async def tags(self) -> TagCollection: + """|coro| + + This function gets all available tags on the api. + + + + :return: :class:`ksoftapi.data_objects.TagCollection` + + """ + g = await self.http.request(Route.meme("GET", "/tags")) + return TagCollection(**g) + + # BANS + async def bans_add(self, user_id: int, reason: str, proof: str, **kwargs): + arg_params = ["mod", "user_name", "user_discriminator", "appeal_possible"] + data = { + "user": user_id, + "reason": reason, + "proof": proof + } + for arg, val in kwargs.items(): + if arg in arg_params: + data.update({arg: val}) + else: + raise ValueError(f"unknown parameter: {arg}") + r = await self.http.request(Route.bans("POST", "/add"), data=data) + if r.get("success", False) is True: + return True + else: + raise APIError(**r) + + async def bans_check(self, user_id: int) -> bool: + r = await self.http.request(Route.bans("GET", "/check"), params={"user": user_id}) + if r.get("is_banned", None) is not None: + return r['is_banned'] + else: + raise APIError(**r) + + async def bans_info(self, user_id: int) -> Ban: + r = await self.http.request(Route.bans("GET", "/info"), params={"user": user_id}) + if r.get("is_ban_active", None) is not None: + return Ban(**r) + else: + raise APIError(**r) + + async def bans_remove(self, user_id: int) -> bool: + r = await self.http.request(Route.bans("DELETE", "/remove"), params={"user": user_id}) + if r.get("done", None) is not None: + return True + else: + raise APIError(**r) + + def ban_get_list_iterator(self): + return BanIterator(self, Route.bans("GET", "/list")) diff --git a/ksoftapi/data_objects.py b/ksoftapi/data_objects.py new file mode 100644 index 0000000..9cc5705 --- /dev/null +++ b/ksoftapi/data_objects.py @@ -0,0 +1,138 @@ +class Image: + def __init__(self, **kwargs): + self.snowflake = kwargs.get("snowflake") + self.url = kwargs.get("url") + self.nsfw = kwargs.get("nsfw") + self.tag = kwargs.get("tag") + + +class RedditImage: + def __init__(self, **kwargs): + self.title = kwargs.get("title") + self.image_url = kwargs.get("image_url") + self.url = self.image_url + self.source = kwargs.get("source") + self.subreddit = kwargs.get("subreddit") + self.upvotes = kwargs.get("upvotes") + self.downvotes = kwargs.get("downvotes") + self.comments = kwargs.get("comments") + self.created_at = kwargs.get("created_at") + self.nsfw = kwargs.get("nsfw") + + +class WikiHowImage: + def __init__(self, **kwargs): + self.url = kwargs.get("url") + self.title = kwargs.get("title") + self.nsfw = kwargs.get("nsfw") + self.article_url = kwargs.get("article_url") + + +class Tag: + def __init__(self, **kwargs): + self.name = kwargs.get("name") + self.nsfw = kwargs.get("nsfw") + + def __hash__(self): + return hash("{}_{}".format(int(self.nsfw), self.name)) + + def __str__(self): + return self.name + + +class TagCollection: + def __init__(self, **kwargs): + self._raw = kwargs.get("models") + self.models = [Tag(**t) for t in kwargs.get("models")] + self.sfw_tags = kwargs.get("tags") + self.nsfw_tags = kwargs.get("nsfw_tags", []) + + def __len__(self): + return len(self.models) + + def __dict__(self): + return self._raw + + def __getitem__(self, item): + for t in self.models: + if item == t.name: + return t + + def __iter__(self): + for t in self.models: + yield t + + def __str__(self): + return ", ".join([t.name for t in self.models]) + + def exists(self, name): + for t in self.models: + if name == t.name: + return True + else: + return False + + +class Ban: + def __init__(self, **kwargs): + self.id = kwargs.get("id") + self.name = kwargs.get("name") + self.discriminator = kwargs.get("discriminator") + self.moderator_id = kwargs.get("moderator_id") + self.reason = kwargs.get("reason") + self.proof = kwargs.get("proof") + self.is_ban_active = kwargs.get("is_ban_active") + self.can_be_appealed = kwargs.get("can_be_appealed") + self.timestamp = kwargs.get("timestamp") + self.appeal_reason = kwargs.get("appeal_reason") + self.appeal_date = kwargs.get("appeal_date") + self.requested_by = kwargs.get("requested_by") + self.exists = kwargs.get("exists") + + +class BanSimple: + def __init__(self, **kwargs): + self.id = kwargs.get("id") + self.reason = kwargs.get("reason") + self.proof = kwargs.get("proof") + self.moderator_id = kwargs.get("moderator_id") + self.active = kwargs.get("active") + + +class PaginatorListing: + def __init__(self, **kwargs): + self.count = kwargs.get("ban_count") + self.page_count = kwargs.get("page_count") + self.per_page = kwargs.get("per_page") + self.page = kwargs.get("page") + self.on_page = kwargs.get("on_page") + self.next_page = kwargs.get("next_page") + self.previous_page = kwargs.get("previous_page") + self.data = [Ban(**b) for b in kwargs.get("data")] + + +class BanIterator: + def __init__(self, client, route): + self._client = client + self._route = route + self._object_list = [] + + async def __aiter__(self): + done = False + page = 1 + while not done: + r = await self._client.http.request(self._route, params={"page": page}) + for b in r['data']: + yield Ban(**b) + if r['next_page'] is not None: + page += 1 + else: + done = True + + async def get_count(self): + r = await self._client.http.request(self._route, params={"per_page": 1}) + return r['ban_count'] + + async def paginator(self, page: int = 1, per_page: int = 20): + r = await self._client.http.request(self._route, params={"per_page": per_page, "page": page}) + return PaginatorListing(**r) diff --git a/ksoftapi/errors.py b/ksoftapi/errors.py new file mode 100644 index 0000000..70e6211 --- /dev/null +++ b/ksoftapi/errors.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- + + +class RequireFormatting(Exception): + pass + + +class MissingRequiredArguments(Exception): + pass + + +class WeirdResponse(Exception): + pass + + +class SpecifyClient(Exception): + pass + + +class Forbidden(Exception): + pass + + +class DiscordPyNotInstalled(Exception): + pass + + +class NotFound(Exception): + pass + + +class InvalidMethod(Exception): + pass + + +class APIError(Exception): + def __init__(self, err_msg: str=None, **data): + self.message = data.get("message", "No message provided") + self.code = data.get("code", 0) + if err_msg is not None: + err_msg = " | Additional info: {}".format(err_msg) + err_msg = "code {}: {}{}".format(self.code, self.message, err_msg) + super().__init__(err_msg) diff --git a/ksoftapi/events.py b/ksoftapi/events.py new file mode 100644 index 0000000..ee13980 --- /dev/null +++ b/ksoftapi/events.py @@ -0,0 +1,14 @@ +class BanEvent: + def __init__(self, **kwargs): + self.user_id = kwargs.get("id") + self.moderator_id = kwargs.get("mod") + self.reason = kwargs.get("reason") + self.proof = kwargs.get("proof") + + +class UnBanEvent: + def __init__(self, **kwargs): + self.user_id = kwargs.get("id") + self.moderator_id = kwargs.get("mod") + self.reason = kwargs.get("reason") + self.proof = kwargs.get("proof") diff --git a/ksoftapi/http.py b/ksoftapi/http.py new file mode 100644 index 0000000..82a233b --- /dev/null +++ b/ksoftapi/http.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- + +import asyncio +import logging +import sys +import traceback + +import aiohttp + +from . import __version__ +from .errors import * + +logger = logging.getLogger() + + +class Route: + BASE = 'https://api.ksoft.si/' + + def __init__(self, method, path, subpath: str = "", **parameters): + self.path = subpath + path + self.method = method + url = (self.BASE + self.path) + if parameters: + self.url = url.format(**parameters) + else: + self.url = url + + @classmethod + def meme(cls, method, path, **parameters): + return cls(method, path, "meme", **parameters) + + @classmethod + def bans(cls, method, path, **parameters): + return cls(method, path, "bans", **parameters) + + @classmethod + def trola(cls, method, path, **parameters): + return cls(method, path, "trola", **parameters) + + +class krequest(object): + def __init__(self, return_json=True, global_headers={}, loop=asyncio.get_event_loop(), **kwargs): + self.bot = kwargs.get("bot", None) + self.loop = loop + self.session = aiohttp.ClientSession(loop=self.loop) + self.lowmem = kwargs.get("lowmem", False) + self.headers = { + "User-Agent": "{}ksoftapi.py/{} (Github: AndyTempel) KRequests/alpha " + "(Custom asynchronous HTTP wrapper)".format( + f"{self.bot.user.username}/{self.bot.user.discriminator} " if self.bot else "", __version__), + "X-Powered-By": "python/{} aiohttp/{}".format(sys.version, aiohttp.__version__) + } + self.return_json = return_json + for name, value in global_headers: + logger.info(f"KSOFTAPI Added global header {name}") + self.headers.update({ + name: value + }) + + logger.debug(f"Here are global headers: {str(self.headers)}") + + async def _proc_resp(self, response): + logger.debug(f"Request {response.method}: {response.url}") + logger.debug(f"Response headers: {str(response.headers)}") + if self.return_json: + try: + resp = await response.json() + logger.debug(f"Response content: {str(resp)}") + return resp + except Exception: + print(traceback.format_exc()) + print(response) + resp = await response.text() + logger.debug(f"Response content: {str(resp)}") + return {} + else: + resp = await response.text() + logger.debug(f"Response content: {str(resp)}") + return resp + + async def request(self, route: Route, params=None, data=None, json=None, headers=None): + url = route.url + method = route.method + if method == "POST": + return await self.post(url, data=data, json=json, headers=headers) + elif method == "GET": + return await self.get(url, params=params, headers=headers) + elif method == "DELETE": + return await self.delete(url, params=params, headers=headers) + else: + raise InvalidMethod + + async def get(self, url, params=None, headers=None, verify=True): + headers = headers or {} + headers.update(self.headers) + async with self.session.get(url, params=params, headers=headers) as resp: + r = await self._proc_resp(resp) + await resp.release() + return r + + async def delete(self, url, params=None, headers=None, verify=True): + headers = headers or {} + headers.update(self.headers) + async with self.session.delete(url, params=params, headers=headers) as resp: + r = await self._proc_resp(resp) + await resp.release() + return r + + async def post(self, url, data=None, json=None, headers=None, verify=True): + headers = headers or {} + headers.update(self.headers) + if json is not None: + async with self.session.post(url, json=json, headers=headers) as resp: + r = await self._proc_resp(resp) + await resp.release() + return r + else: + async with self.session.post(url, data=data, headers=headers) as resp: + r = await self._proc_resp(resp) + await resp.release() + return r + + async def download_get(self, url, filename, params=None, headers=None, verify=True): + headers = headers or {} + headers.update(self.headers) + async with self.session.get(url, params=params, headers=headers) as response: + if response.status != 200: + raise Forbidden + with open(filename, 'wb') as f_handle: + while True: + chunk = await response.content.read(1024) + if not chunk: + break + f_handle.write(chunk) + await response.release() + + async def download_post(self, url, filename, data=None, json=None, headers=None, verify=True): + headers = headers or {} + headers.update(self.headers) + if json is not None: + async with self.session.post(url, json=json, headers=headers) as response: + if response.status != 200: + raise Forbidden + with open(filename, 'wb') as f_handle: + while True: + chunk = await response.content.read(1024) + if not chunk: + break + f_handle.write(chunk) + await response.release() + else: + async with self.session.post(url, data=data, headers=headers) as response: + if response.status != 200: + raise Forbidden + with open(filename, 'wb') as f_handle: + while True: + chunk = await response.content.read(1024) + if not chunk: + break + f_handle.write(chunk) + await response.release() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f2082d2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +aiohttp>=3.3.0,<3.4.0 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..5abea00 --- /dev/null +++ b/setup.py @@ -0,0 +1,49 @@ +import re + +from setuptools import setup + + +def get_requirements(): + with open('requirements.txt') as f: + requirements = f.read().splitlines() + return requirements + + +version = '' +with open('ksoftapi/__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') + +readme = '' +with open('README.md') as f: + readme = f.read() + +print(readme) +setup( + name='ksoftapi', + packages=['ksoftapi'], + version=version, + description='KSoft.Si API Wrapper, customised for use in discord.py', + long_description=str(readme), + author='AndyTempel', + author_email='andraz@korenc.eu', + url='https://github.com/AndyTempel/ksoftapi', + download_url=f'https://github.com/AndyTempel/ksoftapi/archive/{version}.tar.gz', + keywords=['ksoftapi'], + include_package_data=True, + install_requires=get_requirements(), + classifiers=[ + 'Development Status :: 4 - Beta', + 'License :: OSI Approved :: MIT License', + 'Intended Audience :: Developers', + 'Natural Language :: English', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 3.6', + 'Topic :: Internet', + 'Topic :: Software Development :: Libraries', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Topic :: Utilities', + ] +)