PK!csirbot/__init__.py# -*- coding: utf-8 -*- """ sirbot ~~~~~~~~~~~~~~~~~~~ The good Sir Bot-a-lot. An asynchronous python bot framework. :copyright: (c) 2017 by Python Developers Slack Community :licence: MIT, see LICENCE for more details """ # http://patorjk.com/software/taag/#p=display&f=Star%20Wars&t=sirbot # _______. __ .______ .______ ______ .___________. # / || | | _ \ | _ \ / __ \ | | # | (----`| | | |_) | | |_) | | | | | `---| |----` # \ \ | | | / | _ < | | | | | | # .----) | | | | |\ \----. | |_) | | `--' | | | # |_______/ |__| | _| `._____| |______/ \______/ |__| # ___ __ ______ .___________. # / \ | | / __ \ | | # / ^ \ _______ | | | | | | `---| |----` # / /_\ \ | | | | | | | | | | # / _____ \ ------- | `----.| `--' | | | # /__/ \__\ |_______| \______/ |__| from .bot import SirBot # noQa: F401 from .__version__ import __version__ # noQa: F401 PK!Msirbot/__version__.py__version__ = "0.0.5" PK!xYvv sirbot/bot.pyimport asyncio import logging import aiohttp.web from . import endpoints LOG = logging.getLogger(__name__) class SirBot(aiohttp.web.Application): def __init__(self, user_agent=None, **kwargs): super().__init__(**kwargs) self.router.add_route("GET", "/sirbot/plugins", endpoints.plugins) self["plugins"] = dict() self["http_session"] = aiohttp.ClientSession( loop=kwargs.get("loop") or asyncio.get_event_loop() ) self["user_agent"] = user_agent or "sir-bot-a-lot" self.on_shutdown.append(self.stop) def start(self, **kwargs): LOG.info("Starting SirBot") aiohttp.web.run_app(self, **kwargs) def load_plugin(self, plugin, name=None): name = name or plugin.__name__ self["plugins"][name] = plugin plugin.load(self) async def stop(self, sirbot): await self["http_session"].close() @property def plugins(self): return self["plugins"] @property def http_session(self): return self["http_session"] @property def user_agent(self): return self["user_agent"] PK!9=æsirbot/endpoints/__init__.pyfrom aiohttp.web import json_response async def plugins(request): data = [k for k in request.app["plugins"].keys()] return json_response({"plugins": data}) PK!sirbot/plugins/__init__.pyPK!P44&sirbot/plugins/apscheduler/__init__.pyfrom .plugin import APSchedulerPlugin # noQa: F401 PK!4^Dii$sirbot/plugins/apscheduler/plugin.pyimport logging from apscheduler.schedulers.asyncio import AsyncIOScheduler LOG = logging.getLogger(__name__) class APSchedulerPlugin: """ Handle code execution scheduling Register a new job running every hour with: .. code-block:: python APSchedulerPlugin.scheduler.add_job(job, 'cron', hour=1, kwargs={'bot': bot}) Args: **kwargs: Arguments for :class:`apscheduler.schedulers.asyncio.AsyncIOScheduler`. **Variables** * **scheduler**: Instance of :class:`apscheduler.schedulers.asyncio.AsyncIOScheduler`. """ __name__ = "scheduler" def __init__(self, **kwargs): self.scheduler = AsyncIOScheduler(**kwargs) def load(self, sirbot): LOG.info("Loading apscheduler plugin") sirbot.on_startup.append(self.start) async def start(self, sirbot): self.scheduler.start() PK!//!sirbot/plugins/github/__init__.pyfrom .plugin import GithubPlugin # noQa: F401 PK!֢ꩤsirbot/plugins/github/plugin.pyimport os import logging from gidgethub import ValidationFailure from aiohttp.web import Response from gidgethub.sansio import Event from gidgethub.aiohttp import GitHubAPI from gidgethub.routing import Router LOG = logging.getLogger(__name__) class GithubPlugin: """ Handle GitHub webhook. The webhook must be set to ``/github`` in your github account. Register a new event handler with: .. code-block:: python GithubPlugin.router.add(handler, event_type) **Endpoints**: * ``/github``: Github webhook. **Variables**: * **router**: Instance of :class:`gidgethub.routing.Router`. * **api**: Instance of :class:`gidgethub.aiohttp.GitHubAPI`. """ __name__ = "github" def __init__(self, *, verify=None): self.api = None self.router = Router() self.verify = verify or os.environ["GITHUB_VERIFY"] def load(self, sirbot): LOG.info("Loading github plugin") self.api = GitHubAPI(session=sirbot.http_session, requester=sirbot.user_agent) sirbot.router.add_route("POST", "/github", dispatch) async def dispatch(request): github = request.app.plugins["github"] payload = await request.read() try: event = Event.from_http(request.headers, payload, secret=github.verify) await github.router.dispatch(event, app=request.app) except ValidationFailure: LOG.debug( "Github webhook failed verification: %s, %s", request.headers, payload ) return Response(status=401) except Exception as e: LOG.exception(e) return Response(status=500) else: return Response(status=200) PK!e++#sirbot/plugins/postgres/__init__.pyfrom .plugin import PgPlugin # noQa: F401 PK!d!sirbot/plugins/postgres/plugin.pyimport os import logging import asyncpg import aiofiles from aiocontext import async_contextmanager import ujson LOG = logging.getLogger(__name__) class PgPlugin: """ Handle database connection and sql migration for postgresql. Database migration to new version are automatically handled at startup when the ``version`` and ``sql_migration_directory`` argument are passed. The ``sql_migration_directory`` should be a directory with a single sql file per version and ``version`` should follow semantic versioning. Args: version: Current version of the bot. sql_migration_directory: Directory where migration sql files are located. **kwargs: Arguments for :func:`asyncpg.pool.create_pool`. **Variables**: * **pool**: Instance of :class:`asyncpg.pool.Pool`. """ __name__ = "pg" def __init__(self, *, sql_migration_directory=None, version=None, **kwargs): self.pool_kwargs = kwargs self.pool = None self.version = version if sql_migration_directory: if not os.path.isabs(sql_migration_directory): sql_migration_directory = os.path.abspath(sql_migration_directory) self.sql_migration_directory = sql_migration_directory else: self.sql_migration_directory = None def load(self, sirbot): LOG.info("Loading postgres plugin") sirbot.on_startup.insert(0, self.startup) sirbot.on_shutdown.append(self.shutdown) async def startup(self, sirbot): self.pool = await asyncpg.create_pool( **self.pool_kwargs, init=self._init_connection ) if self.sql_migration_directory and self.version: await self.migrate() async def shutdown(self, sirbot): await self.pool.close() @async_contextmanager async def connection(self): """ Acquire a connection from the pool :return: Instance of :class:`asyncpg.connection.Connection` """ async with self.pool.acquire() as pg_con: yield pg_con async def migrate(self): LOG.info("Start of database migration") current_version = [int(n) for n in self.version.split(".")] async with self.connection() as connection: old_version = await self._check_database_version(connection) if current_version != old_version: async with connection.transaction(): if old_version is None: await self._init_database(connection) old_version = [0, 0, 0] for version in self._find_update_version( start=old_version, end=current_version ): await self._execute_sql_file(connection, version) await self._update_db_version(connection, current_version) LOG.info("End of database migration") def _find_update_version(self, start, end): files = [] for file in os.listdir(self.sql_migration_directory): if file == "init.sql": continue name, _ = os.path.splitext(file) file_version = [int(n) for n in name.split(".")] if end >= file_version > start: files.append(file_version) files = sorted(files) files = [".".join(str(l) for l in f) for f in files] LOG.debug("Database migration versions: %s", files) return files async def _init_database(self, connection): LOG.debug("Executing initial migration") await connection.execute( """ CREATE TABLE metadata (db_version TEXT); INSERT INTO metadata (db_version) VALUES ('0.0.0'); """ ) if os.path.exists(os.path.join(self.sql_migration_directory, "init.sql")): await self._execute_sql_file(connection, "init") async def _execute_sql_file(self, connection, version): LOG.debug("Database migration to version %s: STARTED", version) async with aiofiles.open( os.path.join(self.sql_migration_directory, f"{version}.sql"), mode="r" ) as f: await connection.execute((await f.read())) LOG.debug("Database migration to version %s: OK", version) @staticmethod async def _check_database_version(connection): try: metadata = await connection.fetchrow("""SELECT * FROM metadata""") return [int(n) for n in metadata["db_version"].split(".")] except asyncpg.exceptions.UndefinedTableError: LOG.debug('No "metadata" table found in database') @staticmethod async def _update_db_version(connection, version): await connection.execute( """UPDATE metadata SET db_version=$1""", ".".join(str(l) for l in version) ) async def _init_connection(self, connection): await connection.set_type_codec( "jsonb", encoder=self._json_encoder, decoder=self._json_decoder, schema="pg_catalog", ) @staticmethod def _json_encoder(value): return ujson.dumps(value) @staticmethod def _json_decoder(value): return ujson.loads(value) PK!y ,,&sirbot/plugins/readthedocs/__init__.pyfrom .plugin import RTDPlugin # noQa: F401 PK!b $sirbot/plugins/readthedocs/plugin.pyimport asyncio import logging from aiohttp.web import Response LOG = logging.getLogger(__name__) class RTDPlugin: """ Handle readthedocs webhook Register a new handler with: .. code-block:: python RTDPlugin.register_handler(project_name, handler) **Endpoints**: * ``/readthedocs``: Readthedocs webhook. """ __name__ = "readthedocs" def __init__(self): self._projects = {} self._session = None def load(self, sirbot): LOG.info("Loading read the docs plugin") sirbot.router.add_route("POST", "/readthedocs", incoming_notification) self._session = sirbot["http_session"] async def build(self, project, branch="latest"): """ Trigger a build of project branch. The project must first be registered with :func:`register_project` :param project: Readthedocs project name :param branch: Branch to build :return: """ url = self._projects[project]["build_url"] token = self._projects[project]["jeton"] return await self._session.post(url, json={"branch": branch, "token": token}) def register_project(self, project, build_url, jeton, handlers=None): """ Register a project Find project information in the ``admin > integration`` section of your project readthedocs dashboard. :param project: Readthedocs project name :param build_url: Readthedocs project webhook :param jeton: Integration token :param handlers: Project notification handlers """ if project not in self._projects: self._projects[project] = {} if handlers: self._projects[project]["handlers"] = handlers elif "handlers" not in self._projects[project]: self._projects[project]["handlers"] = [] self._projects[project]["build_url"] = build_url self._projects[project]["jeton"] = jeton def register_handler(self, project, handler): """ Register a new project notification handler. To setup a notification webhook go to the ``admin > notifications`` setting of your project readthedocs dashboard :param project: Readthedocs project name. :param handler: Coroutine callback. """ if project not in self._projects: self._projects[project] = {"handlers": [handler]} else: self._projects[project]["handlers"].append(handler) def dispatch(self, payload): for handler in self._projects[payload["slug"]].get("handlers", []): yield handler async def incoming_notification(request): try: payload = await request.json() except Exception as e: LOG.debug(e) return Response(status=400) if ( "build" not in payload or "success" not in payload["build"] or "slug" not in payload ): return Response(status=400) LOG.debug("Incoming readthedocs notification: %s", payload) handlers = [] try: for handler in request.app["plugins"]["readthedocs"].dispatch(payload): handlers.append(handler(payload, request.app)) except KeyError: return Response(status=400) if handlers: finished, _ = await asyncio.wait(handlers, return_when=asyncio.ALL_COMPLETED) for f in finished: f.result() return Response(status=200) PK!!.. sirbot/plugins/slack/__init__.pyfrom .plugin import SlackPlugin # noQa: F401 PK!C@,,!sirbot/plugins/slack/endpoints.pyimport asyncio import logging import aiohttp.web from aiohttp.web import Response from slack.events import Event from slack.sansio import validate_request_signature from slack.actions import Action from slack.commands import Command from slack.exceptions import InvalidTimestamp, FailedVerification, InvalidSlackSignature LOG = logging.getLogger(__name__) async def incoming_event(request): slack = request.app.plugins["slack"] payload = await request.json() LOG.log(5, "Incoming event payload: %s", payload) if payload.get("type") == "url_verification": if slack.signing_secret: try: raw_payload = await request.read() validate_request_signature( raw_payload.decode("utf-8"), request.headers, slack.signing_secret ) return Response(body=payload["challenge"]) except (InvalidSlackSignature, InvalidTimestamp): return Response(status=500) elif payload["token"] == slack.verify: return Response(body=payload["challenge"]) else: return Response(status=500) try: verification_token = await _validate_request(request, slack) event = Event.from_http(payload, verification_token=verification_token) except (FailedVerification, InvalidSlackSignature, InvalidTimestamp): return Response(status=401) if event["type"] == "message": return await _incoming_message(event, request) else: futures = list(_dispatch(slack.routers["event"], event, request.app)) if futures: return await _wait_and_check_result(futures) return Response(status=200) async def _incoming_message(event, request): slack = request.app.plugins["slack"] if slack.bot_id and ( event.get("bot_id") == slack.bot_id or event.get("message", {}).get("bot_id") == slack.bot_id ): return Response(status=200) LOG.debug("Incoming message: %s", event) text = event.get("text") if slack.bot_user_id and text: mention = slack.bot_user_id in event["text"] or event["channel"].startswith("D") else: mention = False if mention and text and text.startswith(f"<@{slack.bot_user_id}>"): event["text"] = event["text"][len(f"<@{slack.bot_user_id}>") :] event["text"] = event["text"].strip() futures = [] for handler, configuration in slack.routers["message"].dispatch(event): if configuration["mention"] and not mention: continue elif configuration["admin"] and event["user"] not in slack.admins: continue f = asyncio.ensure_future(handler(event, request.app)) if configuration["wait"]: futures.append(f) else: f.add_done_callback(_callback) if futures: return await _wait_and_check_result(futures) return Response(status=200) async def incoming_command(request): slack = request.app.plugins["slack"] payload = await request.post() try: verification_token = await _validate_request(request, slack) command = Command(payload, verification_token=verification_token) except (FailedVerification, InvalidSlackSignature, InvalidTimestamp): return Response(status=401) LOG.debug("Incoming command: %s", command) futures = list(_dispatch(slack.routers["command"], command, request.app)) if futures: return await _wait_and_check_result(futures) return Response(status=200) async def incoming_action(request): slack = request.app.plugins["slack"] payload = await request.post() LOG.log(5, "Incoming action payload: %s", payload) try: verification_token = await _validate_request(request, slack) action = Action.from_http(payload, verification_token=verification_token) except (FailedVerification, InvalidSlackSignature, InvalidTimestamp): return Response(status=401) LOG.debug("Incoming action: %s", action) futures = list(_dispatch(slack.routers["action"], action, request.app)) if futures: return await _wait_and_check_result(futures) return Response(status=200) def _callback(f): try: f.result() except Exception as e: LOG.exception(e) def _dispatch(router, event, app): for handler, configuration in router.dispatch(event): f = asyncio.ensure_future(handler(event, app)) if configuration["wait"]: yield f else: f.add_done_callback(_callback) async def _wait_and_check_result(futures): dones, _ = await asyncio.wait(futures, return_when=asyncio.ALL_COMPLETED) try: results = [done.result() for done in dones] except Exception as e: LOG.exception(e) return Response(status=500) results = [result for result in results if isinstance(result, aiohttp.web.Response)] if len(results) > 1: LOG.warning("Multiple web.Response for handler, returning none") elif results: return results[0] return Response(status=200) async def _validate_request(request, slack): if slack.signing_secret: raw_payload = await request.read() validate_request_signature( raw_payload.decode("utf-8"), request.headers, slack.signing_secret ) return None else: return slack.verify PK!܎""sirbot/plugins/slack/plugin.pyimport os import asyncio import logging from slack import methods from slack.events import EventRouter, MessageRouter from slack.actions import Router as ActionRouter from slack.commands import Router as CommandRouter from slack.io.aiohttp import SlackAPI from . import endpoints LOG = logging.getLogger(__name__) class SlackPlugin: """ Handle communication from and to slack **Endpoints**: * ``/slack/events``: Incoming events. * ``/slack/commands``: Incoming commands. * ``/slack/actions``: Incoming actions. Args: token: slack authentication token (env var: `SLACK_TOKEN`). bot_id: bot id (env var: `SLACK_BOT_ID`). bot_user_id: user id of the bot (env var: `SLACK_BOT_USER_ID`). admins: list of slack admins user id (env var: `SLACK_ADMINS`). verify: slack verification token (env var: `SLACK_VERIFY`). signing_secret: slack signing secret key (env var: `SLACK_SIGNING_SECRET`). (disables verification token if provided). **Variables**: * **api**: Slack client. Instance of :class:`slack.io.aiohttp.SlackAPI`. """ __name__ = "slack" def __init__( self, *, token=None, bot_id=None, bot_user_id=None, admins=None, verify=None, signing_secret=None ): self.api = None self.token = token or os.environ["SLACK_TOKEN"] self.admins = admins or os.environ.get("SLACK_ADMINS", []) if signing_secret or "SLACK_SIGNING_SECRET" in os.environ: self.signing_secret = signing_secret or os.environ["SLACK_SIGNING_SECRET"] self.verify = None else: self.verify = verify or os.environ["SLACK_VERIFY"] self.signing_secret = None self.bot_id = bot_id or os.environ.get("SLACK_BOT_ID") self.bot_user_id = bot_user_id or os.environ.get("SLACK_BOT_USER_ID") self.handlers_option = {} if not self.bot_user_id: LOG.warning( "`SLACK_BOT_USER_ID` not set. It is required for `on mention` routing and discarding " "message coming from Sir Bot-a-lot to avoid loops." ) self.routers = { "event": EventRouter(), "command": CommandRouter(), "message": MessageRouter(), "action": ActionRouter(), } def load(self, sirbot): LOG.info("Loading slack plugin") self.api = SlackAPI(session=sirbot.http_session, token=self.token) sirbot.router.add_route("POST", "/slack/events", endpoints.incoming_event) sirbot.router.add_route("POST", "/slack/commands", endpoints.incoming_command) sirbot.router.add_route("POST", "/slack/actions", endpoints.incoming_action) if self.bot_user_id and not self.bot_id: sirbot.on_startup.append(self.find_bot_id) def on_event(self, event_type, handler, wait=True): """ Register handler for an event Args: event_type: Incoming event type. handler: Handler to call. wait: Wait for handler execution before responding to the slack API. """ if not asyncio.iscoroutinefunction(handler): handler = asyncio.coroutine(handler) configuration = {"wait": wait} self.routers["event"].register(event_type, (handler, configuration)) def on_command(self, command, handler, wait=True): """ Register handler for a command Args: command: Incoming command. handler: Handler to call. wait: Wait for handler execution before responding to the slack API. """ if not asyncio.iscoroutinefunction(handler): handler = asyncio.coroutine(handler) configuration = {"wait": wait} self.routers["command"].register(command, (handler, configuration)) def on_message( self, pattern, handler, mention=False, admin=False, wait=True, **kwargs ): """ Register handler for a message kwargs are passed to :meth:`slack.events.MessageRouter.register` Args: pattern: Regex pattern matching the message text. handler: Handler to call. mention: Only trigger handler when the bot is mentioned. admin: Only trigger handler if posted by an admin. wait: Wait for handler execution before responding to the slack API. """ if not asyncio.iscoroutinefunction(handler): handler = asyncio.coroutine(handler) if admin and not self.admins: LOG.warning( "Slack admins ids are not set. Admin limited endpoint will not work." ) configuration = {"mention": mention, "admin": admin, "wait": wait} self.routers["message"].register( pattern=pattern, handler=(handler, configuration), **kwargs ) def on_action(self, action, handler, name="*", wait=True): """ Register handler for an action Args: action: `callback_id` of the incoming action. handler: Handler to call. name: Choice name of the action. wait: Wait for handler execution before responding to the slack API. """ if not asyncio.iscoroutinefunction(handler): handler = asyncio.coroutine(handler) configuration = {"wait": wait} self.routers["action"].register(action, (handler, configuration), name) async def find_bot_id(self, app): rep = await self.api.query( url=methods.USERS_INFO, data={"user": self.bot_user_id} ) self.bot_id = rep["user"]["profile"]["bot_id"] LOG.warning( '`SLACK_BOT_ID` not set. For a faster start time set it to: "%s"', self.bot_id, ) PK!%>t55sirbot-0.1.0.dist-info/LICENSEThe MIT License (MIT) Copyright (c) 2017 Pyslackers 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. PK!HnHTUsirbot-0.1.0.dist-info/WHEEL A н#Z;/"d&F[xzw@Zpy3Fv]\fi4WZ^EgM_-]#0(q7PK!HWqosirbot-0.1.0.dist-info/METADATAKS0ԙҗL۱Ž}N!@,b:<973MCʤ(|hZЌܢ~nZYFM F a%ܡ&0@AD` &xrGG̘yY<-o k&9O7֤0.ò,ke>(4 -%zfL٫GzaާT)qf&y.da7XVcqÌ0TKwW1)Sp59:KDyS&WA"eu7hWg: hqHtoN(cL'ŶApN#;[Qacoz G/PK!Husirbot-0.1.0.dist-info/RECORD}ɒJ}? T3^ Ȇ`1QaEEnrgfu~{^Vg}4 |+>ޅaj4FeK unѼY[Q(W3PO3-ѮT|)6_VG _v*.* {Z=с QS&uPgʮN`p7A(uԀw<!X(KTzpn N!' b+|4rLHмpjrFژ> 6OQT6w>X;kcːb4RH[ݽ@$/s:~45MޯvzNekL(ĸrwӪB mpw Np\+ӒFp!j ""ܗ*hKBRN=ykAܮK 致Nؕ]I^QYN),$ARhP@K_kr(rڕ%C*+Yr94o/؏A6by@P s=6QYtNIHKq|&$nőݣka'h9YiSν*8N/7/xWڕUeĊ-vO:{5DC+c +DO}s l;1iTfMt[MݑeΌ=&?ʓT[7,})6Wݮ5seH#~:VLɊ`r^kM29 oX=ylOC(B?$՗|Z[v6d:p UqB!"Uܙ{T,Q+Ypzm6+pu_XCAPK!csirbot/__init__.pyPK!Msirbot/__version__.pyPK!xYvv fsirbot/bot.pyPK!9=æ sirbot/endpoints/__init__.pyPK! sirbot/plugins/__init__.pyPK!P44& sirbot/plugins/apscheduler/__init__.pyPK!4^Dii$ sirbot/plugins/apscheduler/plugin.pyPK!//!Bsirbot/plugins/github/__init__.pyPK!֢ꩤsirbot/plugins/github/plugin.pyPK!e++#sirbot/plugins/postgres/__init__.pyPK!d!sirbot/plugins/postgres/plugin.pyPK!y ,,&",sirbot/plugins/readthedocs/__init__.pyPK!b $,sirbot/plugins/readthedocs/plugin.pyPK!!.. d:sirbot/plugins/slack/__init__.pyPK!C@,,!:sirbot/plugins/slack/endpoints.pyPK!܎"";Psirbot/plugins/slack/plugin.pyPK!%>t55gsirbot-0.1.0.dist-info/LICENSEPK!HnHTU lsirbot-0.1.0.dist-info/WHEELPK!HWqolsirbot-0.1.0.dist-info/METADATAPK!HuDnsirbot-0.1.0.dist-info/RECORDPKr