├── .dockerignore ├── .flake8 ├── .gitignore ├── .isort.cfg ├── .platform.app.yaml ├── .platform ├── routes.yaml └── services.yaml ├── .pyup.yml ├── .travis.yml ├── CONTRIBUTING.rst ├── Pipfile ├── Pipfile.lock ├── README.rst ├── docker ├── docker-compose.yml ├── dockerfile └── example.env ├── logging.yml ├── scripts ├── dev_down.sh ├── dev_up.sh ├── run_tests.sh └── test.sh ├── sirbot_pyslackers ├── __init__.py ├── __main__.py ├── endpoints │ ├── __init__.py │ ├── apscheduler.py │ ├── readthedocs.py │ └── slack │ │ ├── __init__.py │ │ ├── actions.py │ │ ├── commands.py │ │ ├── events.py │ │ ├── messages.py │ │ └── utils.py └── plugins │ ├── __init__.py │ ├── pypi.py │ └── stocks.py ├── sql ├── 0.0.10.sql ├── 0.0.11.sql ├── 0.0.2.sql ├── 0.0.3.sql ├── 0.0.4.sql ├── 0.0.5.sql ├── 0.0.6.sql ├── 0.0.7.sql ├── 0.0.8.sql ├── 0.0.9.sql └── init.sql └── tests └── endpoints └── slack └── test_messages.py /.dockerignore: -------------------------------------------------------------------------------- 1 | .* 2 | !.flake8 3 | !.isort.cfg 4 | 5 | ansible/ 6 | 7 | scripts/ 8 | !scripts/test.sh 9 | 10 | docker/ 11 | test.sh 12 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 80 3 | select = C,E,F,W,B,B950 4 | ignore = 5 | E501, 6 | F401 7 | W503 8 | exclude = 9 | .git, 10 | .tox, 11 | __pycache__, 12 | docs/source/conf.py, 13 | max-complexity = 10 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .env/ 3 | .idea/ 4 | .vscode/ 5 | __pycache__ 6 | docker/sirbot.env 7 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | line_length=90 3 | length_sort=True 4 | sections=FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 5 | no_lines_before=FIRSTPARTY 6 | skip_glob=.tox 7 | not_skip=__init__.py 8 | -------------------------------------------------------------------------------- /.platform.app.yaml: -------------------------------------------------------------------------------- 1 | # This file describes an application. You can have multiple applications 2 | # in the same project. 3 | # 4 | # See https://docs.platform.sh/user_guide/reference/platform-app-yaml.html 5 | 6 | # The name of this app. Must be unique within a project. 7 | name: sirbot 8 | 9 | # The runtime the application uses. 10 | type: "python:3.7" 11 | 12 | # The build-time dependencies of the app. 13 | dependencies: 14 | python3: 15 | pipenv: "*" 16 | 17 | # The hooks executed at various points in the lifecycle of the application. 18 | hooks: 19 | build: | 20 | curl -sS https://platform.sh/cli/installer | php 21 | pipenv install --system --deploy 22 | 23 | deploy: | 24 | python3 -m sirbot_pyslackers migrate 25 | 26 | # The size of the persistent disk of the application (in MB). 27 | disk: 128 28 | # The relationships of the application with services or other applications. 29 | # 30 | # The left-hand side is the name of the relationship as it will be exposed 31 | # to the application in the PLATFORM_RELATIONSHIPS variable. The right-hand 32 | # side is in the form `:`. 33 | relationships: 34 | database: "postgresql:postgresql" 35 | 36 | crons: 37 | renewcert: 38 | # Force a redeploy at 10 am (UTC) on the 1st and 15th of every month. 39 | spec: '0 10 1,15 * *' 40 | cmd: | 41 | if [ "$PLATFORM_BRANCH" = master ]; then 42 | platform redeploy --yes --no-wait 43 | fi 44 | snapshot: 45 | # Take a snapshot of master environment everyday a 3 am (UTC) 46 | spec: '0 3 * * *' 47 | cmd: | 48 | if [ "$PLATFORM_BRANCH" = master ]; then 49 | platform snapshot:create --yes --no-wait 50 | fi 51 | 52 | 53 | # The configuration of app when it is exposed to the web. 54 | web: 55 | commands: 56 | start: "python3 -m sirbot_pyslackers" 57 | -------------------------------------------------------------------------------- /.platform/routes.yaml: -------------------------------------------------------------------------------- 1 | https://{default}/: 2 | type: upstream 3 | upstream: "sirbot:http" 4 | -------------------------------------------------------------------------------- /.platform/services.yaml: -------------------------------------------------------------------------------- 1 | postgresql: 2 | disk: 4096 3 | type: "postgresql:11" 4 | configuration: 5 | extensions: 6 | - plpgsql 7 | -------------------------------------------------------------------------------- /.pyup.yml: -------------------------------------------------------------------------------- 1 | schedule: "every two weeks on monday" 2 | 3 | requirements: 4 | - Pipfile 5 | - Pipfile.lock 6 | 7 | branch: master 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: python 3 | python: 4 | - 3.6 5 | 6 | services: docker 7 | 8 | addons: 9 | apt: 10 | update: true 11 | 12 | install: 13 | - docker build -f docker/dockerfile -t pyslackers/sirbot-pyslackers:$TRAVIS_COMMIT -t pyslackers/sirbot-pyslackers:latest . 14 | 15 | script: 16 | - docker run --entrypoint scripts/test.sh pyslackers/sirbot-pyslackers:$TRAVIS_COMMIT 17 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | First off, thanks for taking the time to contribute! 6 | 7 | Contributions are welcome by anybody and everybody. We are not kidding! Looking for help ? Join us on `Slack`_ by requesting an `invite`_. 8 | 9 | The rest of this document will be guidelines to contributing to the project. Remember that these are just guidelines, not rules. Use common sense as much as possible. 10 | 11 | .. _invite: http://pyslackers.com/ 12 | .. _Slack: https://pythondev.slack.com/ 13 | 14 | Pull Request Guidelines 15 | ----------------------- 16 | 17 | Before you submit a pull request, check that it meets these guidelines: 18 | 19 | 1. The pull request should include tests (if necessary). If you have any questions about how to write tests then ask the community. 20 | 2. If the pull request adds functionality update the docs where appropriate. 21 | 3. Use a `good commit message`_. 22 | 23 | .. _good commit message: https://github.com/spring-projects/spring-framework/blob/30bce7/CONTRIBUTING.md#format-commit-messages 24 | 25 | Types of Contributions 26 | ---------------------- 27 | 28 | Report Bugs 29 | ^^^^^^^^^^^ 30 | 31 | The best way to report a bug is to file an `issue `_. 32 | 33 | Please include: 34 | 35 | * Your operating system name and version. 36 | * Any details about your local setup that might be helpful in troubleshooting. 37 | * Detailed steps to reproduce the bug. 38 | 39 | Fix Bugs & Features 40 | ^^^^^^^^^^^^^^^^^^^ 41 | 42 | Look through the github issues for bugs or features request. 43 | Anything tagged with "help-wanted" is open to whoever wants to implement it. 44 | 45 | Write Documentation 46 | ^^^^^^^^^^^^^^^^^^^ 47 | 48 | Sirbot-pyslackers could always use more documentation and examples, whether as docstring or guide for setting things up. 49 | 50 | Submit Feedback 51 | ^^^^^^^^^^^^^^^ 52 | 53 | The best way to submit feedback is to file an `issue `_. 54 | 55 | If you are proposing a feature: 56 | 57 | * Explain in detail how it would work. 58 | * Keep the scope as narrow as possible, to make it easier to implement. 59 | * Remember that this is a volunteer-driven project, and that contributions 60 | are welcome :) 61 | 62 | Get started 63 | ----------- 64 | 65 | Sirbot is build using pipenv for dependencies management and deployed as docker container. Some custom pipenv command are setup to ease the development workflow. 66 | 67 | Before starting make sure ``docker`` and ``pipenv`` is installed. 68 | 69 | Environment setup 70 | ^^^^^^^^^^^^^^^^^ 71 | 72 | Some environment variable are needed for Sirbot to operate. An example file is provided (`docker/example.env `_). To overwrite it and set your own instance variable create a ``sirbot.env`` file in the ``docker`` directory. 73 | 74 | Setup a development slack team 75 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 76 | 77 | To test the bot it is required to create a development slack team and an app that use workspace token. To create a team click `here `_ and to create a workspace app click `here `_. 78 | 79 | Deploy a development bot 80 | ^^^^^^^^^^^^^^^^^^^^^^^^ 81 | 82 | To deploy a development version of the bot on your own machine use the ``pipenv run up`` command. It will start a docker stack composed of: 83 | 84 | * The bot container 85 | * A postgresql database 86 | * An ngrok instance 87 | 88 | Connect to `http://localhost:4040 `_ to access the ngrok management interface and find your url. 89 | 90 | After making some modification rebuild and restart the bot container using the same ``pipenv run up`` command. 91 | 92 | To shutdown all started containers use the ``pipenv run down`` command. 93 | 94 | Code style testing 95 | ^^^^^^^^^^^^^^^^^^ 96 | 97 | To run the CI tests use the ``pipenv run tests`` command. It will build a new docker image and run tests on it. 98 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | 3 | url = "https://pypi.python.org/simple" 4 | verify_ssl = true 5 | name = "pypi" 6 | 7 | 8 | [packages] 9 | 10 | aiohttp = "*" 11 | aiohttp-xmlrpc = "*" 12 | distance = "*" 13 | gidgethub = "*" 14 | raven = "*" 15 | slack-sansio = "*" 16 | asyncpg = "*" 17 | sirbot = "*" 18 | pyyaml = "*" 19 | cython = "*" 20 | cchardet = "*" 21 | platformshconfig = "*" 22 | 23 | 24 | [dev-packages] 25 | 26 | flake8 = "*" 27 | isort = "*" 28 | black = "==19.10b0" 29 | pytest = "*" 30 | 31 | 32 | [requires] 33 | 34 | python_version = "3.7" 35 | 36 | 37 | [scripts] 38 | 39 | up = "/bin/sh scripts/dev_up.sh" 40 | down = "/bin/sh scripts/dev_down.sh" 41 | tests = "/bin/sh scripts/run_tests.sh" 42 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "ab2da2596253399cf336c1c00deefc5570904ca3722a39281985aea6841d1e4c" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.7" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.python.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "aiofiles": { 20 | "hashes": [ 21 | "sha256:377fdf7815cc611870c59cbd07b68b180841d2a2b79812d8c218be02448c2acb", 22 | "sha256:98e6bcfd1b50f97db4980e182ddd509b7cc35909e903a8fe50d8849e02d815af" 23 | ], 24 | "version": "==0.5.0" 25 | }, 26 | "aiohttp": { 27 | "hashes": [ 28 | "sha256:1e984191d1ec186881ffaed4581092ba04f7c61582a177b187d3a2f07ed9719e", 29 | "sha256:259ab809ff0727d0e834ac5e8a283dc5e3e0ecc30c4d80b3cd17a4139ce1f326", 30 | "sha256:2f4d1a4fdce595c947162333353d4a44952a724fba9ca3205a3df99a33d1307a", 31 | "sha256:32e5f3b7e511aa850829fbe5aa32eb455e5534eaa4b1ce93231d00e2f76e5654", 32 | "sha256:344c780466b73095a72c616fac5ea9c4665add7fc129f285fbdbca3cccf4612a", 33 | "sha256:460bd4237d2dbecc3b5ed57e122992f60188afe46e7319116da5eb8a9dfedba4", 34 | "sha256:4c6efd824d44ae697814a2a85604d8e992b875462c6655da161ff18fd4f29f17", 35 | "sha256:50aaad128e6ac62e7bf7bd1f0c0a24bc968a0c0590a726d5a955af193544bcec", 36 | "sha256:6206a135d072f88da3e71cc501c59d5abffa9d0bb43269a6dcd28d66bfafdbdd", 37 | "sha256:65f31b622af739a802ca6fd1a3076fd0ae523f8485c52924a89561ba10c49b48", 38 | "sha256:ae55bac364c405caa23a4f2d6cfecc6a0daada500274ffca4a9230e7129eac59", 39 | "sha256:b778ce0c909a2653741cb4b1ac7015b5c130ab9c897611df43ae6a58523cb965" 40 | ], 41 | "index": "pypi", 42 | "version": "==3.6.2" 43 | }, 44 | "aiohttp-xmlrpc": { 45 | "hashes": [ 46 | "sha256:a28bc5f16a39766860d985467d25e865290c7a3b4c94bc779c8cb5167183e52e", 47 | "sha256:b42d453c137077bc4bd1b1ab0c0a9d8b0bda9b9d456af07ecab4647538e42d0f" 48 | ], 49 | "index": "pypi", 50 | "version": "==0.8.1" 51 | }, 52 | "apscheduler": { 53 | "hashes": [ 54 | "sha256:3bb5229eed6fbbdafc13ce962712ae66e175aa214c69bed35a06bffcf0c5e244", 55 | "sha256:e8b1ecdb4c7cb2818913f766d5898183c7cb8936680710a4d3a966e02262e526" 56 | ], 57 | "version": "==3.6.3" 58 | }, 59 | "async-timeout": { 60 | "hashes": [ 61 | "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", 62 | "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" 63 | ], 64 | "version": "==3.0.1" 65 | }, 66 | "asyncio-contextmanager": { 67 | "hashes": [ 68 | "sha256:93b4620cd79623c3988c9f43e6f502263968645cd13aed3327a7ec8be43221d5" 69 | ], 70 | "version": "==1.0.1" 71 | }, 72 | "asyncpg": { 73 | "hashes": [ 74 | "sha256:058baec9d6b75612412baa872a1aa47317d0ff88c318a49f9c4a2389043d5a8d", 75 | "sha256:0c336903c3b08e970f8af2f606332f1738dba156bca83ed0467dc2f5c70da796", 76 | "sha256:1388caa456070dab102be874205e3ae8fd1de2577d5de9fa22e65ba5c0f8b110", 77 | "sha256:25edb0b947eb632b6b53e5a4b36cba5677297bb34cbaba270019714d0a5fed76", 78 | "sha256:2af6a5a705accd36e13292ea43d08c20b15e52d684beb522cb3a7d3c9c8f3f48", 79 | "sha256:391aea89871df8c1560750af6c7170f2772c2d133b34772acf3637e3cf4db93e", 80 | "sha256:394bf19bdddbba07a38cd6fb526ebf66e120444d6b3097332b78efd5b26495b0", 81 | "sha256:5664d1bd8abe64fc60a0e701eb85fa1d8c9a4a8018a5a59164d27238f2caf395", 82 | "sha256:57666dfae38f4dbf84ffbf0c5c0f78733fef0e8e083230275dcb9ccad1d5ee09", 83 | "sha256:74510234c294c6a6767089ba9c938f09a491426c24405634eb357bd91dffd734", 84 | "sha256:95cd2df61ee00b789bdcd04a080e6d9188693b841db2bf9a87ebaed9e53147e0", 85 | "sha256:a981500bf6947926e53c48f4d60ae080af1b4ad7fa78e363465a5b5ad4f2b65e", 86 | "sha256:a9e6fd6f0f9e8bd77e9a4e1ef9a4f83a80674d9136a754ae3603e915da96b627", 87 | "sha256:ad5ba062e09673b1a4b8d0facaf5a6d9719bf7b337440d10b07fe994d90a9552", 88 | "sha256:ba90d3578bc6dddcbce461875672fd9bdb34f0b8215b68612dd3b65a956ff51c", 89 | "sha256:c773c7dbe2f4d3ebc9e3030e94303e45d6742e6c2fc25da0c46a56ea3d83caeb", 90 | "sha256:da238592235717419a6a7b5edc8564da410ebfd056ca4ecc41e70b1b5df86fba", 91 | "sha256:e39aac2b3a2f839ce65aa255ce416de899c58b7d38d601d24ca35558e13b48e3", 92 | "sha256:ec6e7046c98730cb2ba4df41387e10cb8963a3ac2918f69ae416f8aab9ca7b1b", 93 | "sha256:f0c9719ac00615f097fe91082b785bce36dbf02a5ec4115ede0ebfd2cd9500cb", 94 | "sha256:f7184689177eeb5a11fa1b2baf3f6f2e26bfd7a85acf4de1a3adbd0867d7c0e2" 95 | ], 96 | "version": "==0.20.1" 97 | }, 98 | "attrs": { 99 | "hashes": [ 100 | "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", 101 | "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" 102 | ], 103 | "version": "==19.3.0" 104 | }, 105 | "cchardet": { 106 | "hashes": [ 107 | "sha256:0f6e4e464e332da776b9c1a34e4e83b6301d38c2724efc93848c46ade66d02bb", 108 | "sha256:217a7008bd399bdb61f6a0a2570acc5c3a9f96140e0a0d089b9e748c4d4e4c4e", 109 | "sha256:27b0f23088873d1dd36d2c8a2e45c9167e312e1aac7e4baeb47f7428a2669638", 110 | "sha256:2a958fb093f69ee5f16be7a1aee5122e07aff4350fa4dc9b953b87c34468e605", 111 | "sha256:2aa1b008965c703ad6597361b0f6d427c8971fe94a2c99ec3724c228ae50d6a6", 112 | "sha256:2c05b66b12f9ab0493c5ffb666036fd8c9004a9cc9d5a9264dc24738b50ab8c3", 113 | "sha256:4096759825a130cb27a58ddf6d58e10abdd0127d29fbf53fde26df7ad879737b", 114 | "sha256:40c199f9c0569ac479fae7c4e12d2e16fc1e8237836b928474fdd228b8d11477", 115 | "sha256:4486f6e5bdf06f0081d13832f2a061d9e90597eb02093fda9d37e3985e3b2ef2", 116 | "sha256:54d2653520237ebbd2928f2c0f2eb7c616ee2b5194d73d945060cd54a7846b64", 117 | "sha256:5e38cfad9d3ca0f571c4352e9ca0f5ab718508f492a37d3236ae70810140e250", 118 | "sha256:68409e00d75ff13dd7a192ec49559f5527ee8959a51a9f4dd7b168df972b4d44", 119 | "sha256:79b0e113144c2ef0050bc9fe647c7657c5298f3012ecd8937d930b24ddd61404", 120 | "sha256:7a2d98df461d3f36b403fdd8d7890c823ed05bd98eb074412ed56fbfedb94751", 121 | "sha256:7bba1cbb4358dc9a2d2da00f4b38b159a5483d2f3b1d698a7c2cae518f955170", 122 | "sha256:84d2ce838cf3c2fe7f0517941702d42f7e598e5173632ec47a113cd521669b98", 123 | "sha256:8b1d02c99f6444c63336a76638741eaf4ac4005b454e3b8252a40074bf0d84a1", 124 | "sha256:8f7ade2578b2326a0a554c03f60c8d079331220179a592e83e143c9556b7f5b2", 125 | "sha256:953fe382304b19f5aa8fc2da4b092a3bb58a477d33af4def4b81abdce4c9288c", 126 | "sha256:acc96b4a8f756af289fa90ffa67ddef57401d99131e51e71872e3609483941ce", 127 | "sha256:af284494ea6c40f9613b4d939abe585eb9290cb92037eab66122c93190fcb338", 128 | "sha256:b76afb2059ad69eab576949980a17413c1e9e5a5624abf9e43542d8853f146b3", 129 | "sha256:ccb9f6f06265382028468b47e726f2d42539256fb498d1b0e473c39037b42b8a", 130 | "sha256:cf134e1cfb0c53f08abb1ab9158a7e7f859c3ddb451d5fe535a2cc5f2958a688", 131 | "sha256:dff9480d9b6260f59ad10e1cec5be13905be5da88a4a2bd5a5bd4d49c49c4a05", 132 | "sha256:e27771798c8ad50df1375e762d59369354af94eb8ac21eca5bfd1eeef589f545", 133 | "sha256:f245f045054e8d6dab2a0e366d3c74f3a47fb7dec2595ae2035b234b1a829c7a", 134 | "sha256:f5c94994d876d8709847c3a92643309d716f43716580a2e5831262366a9ee8b6", 135 | "sha256:fd16f57ce42a72397cd9fe38977fc809eb02172731cb354572f28a6d8e4cf322" 136 | ], 137 | "version": "==2.1.6" 138 | }, 139 | "chardet": { 140 | "hashes": [ 141 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 142 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 143 | ], 144 | "version": "==3.0.4" 145 | }, 146 | "cython": { 147 | "hashes": [ 148 | "sha256:0754ec9d45518d0dbb5da72db2c8b063d40c4c51779618c68431054de179387f", 149 | "sha256:0bb201124f67b8d5e6a3e7c02257ca56a90204611971ecca76c02897096f097d", 150 | "sha256:0f3488bf2a9e049d1907d35ad8834f542f8c03d858d1bca6d0cbc06b719163e0", 151 | "sha256:1024714b0f7829b0f712db9cebec92c2782b1f42409b8575cacc340aa438d4ba", 152 | "sha256:10b6d2e2125169158128b7f11dad8bb0d8f5fba031d5d4f8492f3afbd06491d7", 153 | "sha256:16ed0260d031d90dda43997e9b0f0eebc3cf18e6ece91cad7b0fb17cd4bfb29b", 154 | "sha256:22d91af5fc2253f717a1b80b8bb45acb655f643611983fd6f782b9423f8171c7", 155 | "sha256:2d84e8d2a0c698c1bce7c2a4677f9f03b076e9f0af7095947ecd2a900ffceea5", 156 | "sha256:34dd57f5ac5a0e3d53da964994fc1b7e7ee3f86172d7a1f0bde8a1f90739e04d", 157 | "sha256:384582b5024007dfdabc9753e3e0f85d61837b0103b0ee3f8acf04a4bcfad175", 158 | "sha256:4473f169d6dd02174eb76396cb38ce469f377c08b21965ddf4f88bbbebd5816e", 159 | "sha256:57f32d1095ad7fad1e7f2ff6e8c6a7197fa532c8e6f4d044ff69212e0bf05461", 160 | "sha256:5dfe519e400a1672a3ac5bdfb5e957a9c14c52caafb01f4a923998ec9ae77736", 161 | "sha256:60def282839ed81a2ffae29d2df0a6777fd74478c6e82c6c3f4b54e698b9d11c", 162 | "sha256:7089fb2be9a9869b9aa277bc6de401928954ce70e139c3cf9b244ae5f490b8f2", 163 | "sha256:714b8926a84e3e39c5278e43fb8823598db82a4b015cff263b786dc609a5e7d6", 164 | "sha256:7352b88f2213325c1e111561496a7d53b0326e7f07e6f81f9b8b21420e40851c", 165 | "sha256:809f0a3f647052c4bcbc34a15f53a5dab90de1a83ebd77add37ed5d3e6ee5d97", 166 | "sha256:8598b09f7973ccb15c03b21d3185dc129ae7c60d0a6caf8176b7099a4b83483e", 167 | "sha256:8dc68f93b257718ea0e2bc9be8e3c61d70b6e49ab82391125ba0112a30a21025", 168 | "sha256:9bfd42c1d40aa26bf76186cba0d89be66ba47e36fa7ea56d71f377585a53f7c4", 169 | "sha256:a21cb3423acd6dbf383c9e41e8e60c93741987950434c85145864458d30099f3", 170 | "sha256:a49d0f5c55ad0f4aacad32f058a71d0701cb8936d6883803e50698fa04cac8d2", 171 | "sha256:a985a7e3c7f1663af398938029659a4381cfe9d1bd982cf19c46b01453e81775", 172 | "sha256:b3233341c3fe352b1090168bd087686880b582b635d707b2c8f5d4f1cc1fa533", 173 | "sha256:b32965445b8dbdc36c69fba47e024060f9b39b1b4ceb816da5028eea01924505", 174 | "sha256:b553473c31297e4ca77fbaea2eb2329889d898c03941d90941679247c17e38fb", 175 | "sha256:b56c02f14f1708411d95679962b742a1235d33a23535ce4a7f75425447701245", 176 | "sha256:b7bb0d54ff453c7516d323c3c78b211719f39a506652b79b7e85ba447d5fa9e7", 177 | "sha256:c5df2c42d4066cda175cd4d075225501e1842cfdbdaeeb388eb7685c367cc3ce", 178 | "sha256:c5e29333c9e20df384645902bed7a67a287b979da1886c8f10f88e57b69e0f4b", 179 | "sha256:d0b445def03b4cd33bd2d1ae6fbbe252b6d1ef7077b3b5ba3f2c698a190d26e5", 180 | "sha256:d490a54814b69d814b157ac86ada98c15fd77fabafc23732818ed9b9f1f0af80" 181 | ], 182 | "version": "==0.29.20" 183 | }, 184 | "distance": { 185 | "hashes": [ 186 | "sha256:60807584f5b6003f5c521aa73f39f51f631de3be5cccc5a1d67166fcbf0d4551" 187 | ], 188 | "index": "pypi", 189 | "version": "==0.1.3" 190 | }, 191 | "gidgethub": { 192 | "hashes": [ 193 | "sha256:cfabfa696d422ee91eaf1e3f01ea75e576721233cc3ea8badc7d86c30061df8e", 194 | "sha256:fab527a36fd89a27f68686f4c726b2efd989c3d2cac1c46149b1dd46c4e3ab7d" 195 | ], 196 | "version": "==4.1.1" 197 | }, 198 | "idna": { 199 | "hashes": [ 200 | "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", 201 | "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" 202 | ], 203 | "version": "==2.10" 204 | }, 205 | "lxml": { 206 | "hashes": [ 207 | "sha256:06748c7192eab0f48e3d35a7adae609a329c6257495d5e53878003660dc0fec6", 208 | "sha256:0790ddca3f825dd914978c94c2545dbea5f56f008b050e835403714babe62a5f", 209 | "sha256:1aa7a6197c1cdd65d974f3e4953764eee3d9c7b67e3966616b41fab7f8f516b7", 210 | "sha256:22c6d34fdb0e65d5f782a4d1a1edb52e0a8365858dafb1c08cb1d16546cf0786", 211 | "sha256:2754d4406438c83144f9ffd3628bbe2dcc6d62b20dbc5c1ec4bc4385e5d44b42", 212 | "sha256:27ee0faf8077c7c1a589573b1450743011117f1aa1a91d5ae776bbc5ca6070f2", 213 | "sha256:2b02c106709466a93ed424454ce4c970791c486d5fcdf52b0d822a7e29789626", 214 | "sha256:2d1ddce96cf15f1254a68dba6935e6e0f1fe39247de631c115e84dd404a6f031", 215 | "sha256:4f282737d187ae723b2633856085c31ae5d4d432968b7f3f478a48a54835f5c4", 216 | "sha256:51bb4edeb36d24ec97eb3e6a6007be128b720114f9a875d6b370317d62ac80b9", 217 | "sha256:7eee37c1b9815e6505847aa5e68f192e8a1b730c5c7ead39ff317fde9ce29448", 218 | "sha256:7fd88cb91a470b383aafad554c3fe1ccf6dfb2456ff0e84b95335d582a799804", 219 | "sha256:9144ce36ca0824b29ebc2e02ca186e54040ebb224292072250467190fb613b96", 220 | "sha256:925baf6ff1ef2c45169f548cc85204433e061360bfa7d01e1be7ae38bef73194", 221 | "sha256:a636346c6c0e1092ffc202d97ec1843a75937d8c98aaf6771348ad6422e44bb0", 222 | "sha256:a87dbee7ad9dce3aaefada2081843caf08a44a8f52e03e0a4cc5819f8398f2f4", 223 | "sha256:a9e3b8011388e7e373565daa5e92f6c9cb844790dc18e43073212bb3e76f7007", 224 | "sha256:afb53edf1046599991fb4a7d03e601ab5f5422a5435c47ee6ba91ec3b61416a6", 225 | "sha256:b26719890c79a1dae7d53acac5f089d66fd8cc68a81f4e4bd355e45470dc25e1", 226 | "sha256:b7462cdab6fffcda853338e1741ce99706cdf880d921b5a769202ea7b94e8528", 227 | "sha256:b77975465234ff49fdad871c08aa747aae06f5e5be62866595057c43f8d2f62c", 228 | "sha256:c47a8a5d00060122ca5908909478abce7bbf62d812e3fc35c6c802df8fb01fe7", 229 | "sha256:c79e5debbe092e3c93ca4aee44c9a7631bdd407b2871cb541b979fd350bbbc29", 230 | "sha256:d8d40e0121ca1606aa9e78c28a3a7d88a05c06b3ca61630242cded87d8ce55fa", 231 | "sha256:ee2be8b8f72a2772e72ab926a3bccebf47bb727bda41ae070dc91d1fb759b726", 232 | "sha256:f95d28193c3863132b1f55c1056036bf580b5a488d908f7d22a04ace8935a3a9", 233 | "sha256:fadd2a63a2bfd7fb604508e553d1cf68eca250b2fbdbd81213b5f6f2fbf23529" 234 | ], 235 | "version": "==4.5.1" 236 | }, 237 | "multidict": { 238 | "hashes": [ 239 | "sha256:1ece5a3369835c20ed57adadc663400b5525904e53bae59ec854a5d36b39b21a", 240 | "sha256:275ca32383bc5d1894b6975bb4ca6a7ff16ab76fa622967625baeebcf8079000", 241 | "sha256:3750f2205b800aac4bb03b5ae48025a64e474d2c6cc79547988ba1d4122a09e2", 242 | "sha256:4538273208e7294b2659b1602490f4ed3ab1c8cf9dbdd817e0e9db8e64be2507", 243 | "sha256:5141c13374e6b25fe6bf092052ab55c0c03d21bd66c94a0e3ae371d3e4d865a5", 244 | "sha256:51a4d210404ac61d32dada00a50ea7ba412e6ea945bbe992e4d7a595276d2ec7", 245 | "sha256:5cf311a0f5ef80fe73e4f4c0f0998ec08f954a6ec72b746f3c179e37de1d210d", 246 | "sha256:6513728873f4326999429a8b00fc7ceddb2509b01d5fd3f3be7881a257b8d463", 247 | "sha256:7388d2ef3c55a8ba80da62ecfafa06a1c097c18032a501ffd4cabbc52d7f2b19", 248 | "sha256:9456e90649005ad40558f4cf51dbb842e32807df75146c6d940b6f5abb4a78f3", 249 | "sha256:c026fe9a05130e44157b98fea3ab12969e5b60691a276150db9eda71710cd10b", 250 | "sha256:d14842362ed4cf63751648e7672f7174c9818459d169231d03c56e84daf90b7c", 251 | "sha256:e0d072ae0f2a179c375f67e3da300b47e1a83293c554450b29c900e50afaae87", 252 | "sha256:f07acae137b71af3bb548bd8da720956a3bc9f9a0b87733e0899226a2317aeb7", 253 | "sha256:fbb77a75e529021e7c4a8d4e823d88ef4d23674a202be4f5addffc72cbb91430", 254 | "sha256:fcfbb44c59af3f8ea984de67ec7c306f618a3ec771c2843804069917a8f2e255", 255 | "sha256:feed85993dbdb1dbc29102f50bca65bdc68f2c0c8d352468c25b54874f23c39d" 256 | ], 257 | "version": "==4.7.6" 258 | }, 259 | "platformshconfig": { 260 | "hashes": [ 261 | "sha256:7a7fccc1eaebb255527a5875e85ac71c6f85a67576a9bd0f69df8078d1094319", 262 | "sha256:7c44af99c8bce6c4b0fc4780f5156d2109500d2ef78f558c96622f77e71a0744" 263 | ], 264 | "index": "pypi", 265 | "version": "==2.3.1" 266 | }, 267 | "pytz": { 268 | "hashes": [ 269 | "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", 270 | "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048" 271 | ], 272 | "version": "==2020.1" 273 | }, 274 | "pyyaml": { 275 | "hashes": [ 276 | "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", 277 | "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", 278 | "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", 279 | "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", 280 | "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", 281 | "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", 282 | "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", 283 | "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", 284 | "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", 285 | "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", 286 | "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" 287 | ], 288 | "version": "==5.3.1" 289 | }, 290 | "raven": { 291 | "hashes": [ 292 | "sha256:3fa6de6efa2493a7c827472e984ce9b020797d0da16f1db67197bcc23c8fae54", 293 | "sha256:44a13f87670836e153951af9a3c80405d36b43097db869a36e92809673692ce4" 294 | ], 295 | "index": "pypi", 296 | "version": "==6.10.0" 297 | }, 298 | "sirbot": { 299 | "hashes": [ 300 | "sha256:3252a917f6336f37fd95223f472cc0e225dac98b6b3492c5ce9953d8cda3cc4f", 301 | "sha256:97be6915ec814e76d7ecce504c1c037834d6be1dc618c3032653d6ecac0f1c0b" 302 | ], 303 | "index": "pypi", 304 | "version": "==0.1.1" 305 | }, 306 | "six": { 307 | "hashes": [ 308 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 309 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 310 | ], 311 | "version": "==1.15.0" 312 | }, 313 | "slack-sansio": { 314 | "hashes": [ 315 | "sha256:0403c02ba6c3e57f6de7e9522aef7973bd71e33453f5b2ea760ec513b619750a", 316 | "sha256:4dec16e6f9ced6003de201c5e3bd1dbfc053ab8c8772ab29529772805b8a18a1" 317 | ], 318 | "index": "pypi", 319 | "version": "==1.1.0" 320 | }, 321 | "tzlocal": { 322 | "hashes": [ 323 | "sha256:643c97c5294aedc737780a49d9df30889321cbe1204eac2c2ec6134035a92e44", 324 | "sha256:e2cb6c6b5b604af38597403e9852872d7f534962ae2954c7f35efcb1ccacf4a4" 325 | ], 326 | "version": "==2.1" 327 | }, 328 | "ujson": { 329 | "hashes": [ 330 | "sha256:019a17e7162f26e264f1645bb41630f7103d178c092ea4bb8f3b16126c3ea210", 331 | "sha256:0379ffc7484b862a292e924c15ad5f1c5306d4271e2efd162144812afb08ff97", 332 | "sha256:0959a5b569e192459b492b007e3fd63d8f4b4bcb4f69dcddca850a9b9dfe3e7a", 333 | "sha256:0e2352b60c4ac4fc75b723435faf36ef5e7f3bfb988adb4d589b5e0e6e1d90aa", 334 | "sha256:0f33359908df32033195bfdd59ba2bfb90a23cb280ef9a0ba11e5013a53d7fd9", 335 | "sha256:154f778f0b028390067aaedce8399730d4f528a16a1c214fe4eeb9c4e4f51810", 336 | "sha256:3bd791d17a175c1c6566aeaec1755b58e3f021fe9bb62f10f02b656b299199f5", 337 | "sha256:634c206f4fb3be7e4523768c636d2dd41cb9c7130e2d219ef8305b8fb6f4838e", 338 | "sha256:670018d4ab4b0755a7234a9f4791723abcd0506c0eed33b2ed50579c4aff31f2", 339 | "sha256:9c68557da3e3ad57e0105aceba0cce5f8f7cd07d207c3860e59c0b3044532830", 340 | "sha256:a32f2def62b10e8a19084d17d40363c4da1ac5f52d300a9e99d7efb49fe5f34a", 341 | "sha256:bea2958c7b5bf4f191f0def751b6f7c8b208edb5f7277e21776329f2ca042385", 342 | "sha256:c04d253fec814657fd9f150ef2333dbd0bc6f46208355aa753a29e0696b7fa7e", 343 | "sha256:c841a6450d64c24c64cbcca429bab22cdb6daef5eaddfdfebe798a5e9e5aff4c", 344 | "sha256:e0199849d61cc6418f94d52a314c6a27524d65e82174d2a043fb718f73d1520d", 345 | "sha256:f40bb0d0cb534aad3e24884cf864bda7a71eb5984bd1da61d1711bbfb3be2c38", 346 | "sha256:f854702a9aff3a445f4a0b715d240f2a3d84014d8ae8aad05a982c7ffab12525" 347 | ], 348 | "version": "==3.0.0" 349 | }, 350 | "uritemplate": { 351 | "hashes": [ 352 | "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f", 353 | "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae" 354 | ], 355 | "version": "==3.0.1" 356 | }, 357 | "yarl": { 358 | "hashes": [ 359 | "sha256:0c2ab325d33f1b824734b3ef51d4d54a54e0e7a23d13b86974507602334c2cce", 360 | "sha256:0ca2f395591bbd85ddd50a82eb1fde9c1066fafe888c5c7cc1d810cf03fd3cc6", 361 | "sha256:2098a4b4b9d75ee352807a95cdf5f10180db903bc5b7270715c6bbe2551f64ce", 362 | "sha256:25e66e5e2007c7a39541ca13b559cd8ebc2ad8fe00ea94a2aad28a9b1e44e5ae", 363 | "sha256:26d7c90cb04dee1665282a5d1a998defc1a9e012fdca0f33396f81508f49696d", 364 | "sha256:308b98b0c8cd1dfef1a0311dc5e38ae8f9b58349226aa0533f15a16717ad702f", 365 | "sha256:3ce3d4f7c6b69c4e4f0704b32eca8123b9c58ae91af740481aa57d7857b5e41b", 366 | "sha256:58cd9c469eced558cd81aa3f484b2924e8897049e06889e8ff2510435b7ef74b", 367 | "sha256:5b10eb0e7f044cf0b035112446b26a3a2946bca9d7d7edb5e54a2ad2f6652abb", 368 | "sha256:6faa19d3824c21bcbfdfce5171e193c8b4ddafdf0ac3f129ccf0cdfcb083e462", 369 | "sha256:944494be42fa630134bf907714d40207e646fd5a94423c90d5b514f7b0713fea", 370 | "sha256:a161de7e50224e8e3de6e184707476b5a989037dcb24292b391a3d66ff158e70", 371 | "sha256:a4844ebb2be14768f7994f2017f70aca39d658a96c786211be5ddbe1c68794c1", 372 | "sha256:c2b509ac3d4b988ae8769901c66345425e361d518aecbe4acbfc2567e416626a", 373 | "sha256:c9959d49a77b0e07559e579f38b2f3711c2b8716b8410b320bf9713013215a1b", 374 | "sha256:d8cdee92bc930d8b09d8bd2043cedd544d9c8bd7436a77678dd602467a993080", 375 | "sha256:e15199cdb423316e15f108f51249e44eb156ae5dba232cb73be555324a1d49c2" 376 | ], 377 | "version": "==1.4.2" 378 | } 379 | }, 380 | "develop": { 381 | "appdirs": { 382 | "hashes": [ 383 | "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", 384 | "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" 385 | ], 386 | "version": "==1.4.4" 387 | }, 388 | "attrs": { 389 | "hashes": [ 390 | "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", 391 | "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" 392 | ], 393 | "version": "==19.3.0" 394 | }, 395 | "black": { 396 | "hashes": [ 397 | "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b", 398 | "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539" 399 | ], 400 | "index": "pypi", 401 | "version": "==19.10b0" 402 | }, 403 | "click": { 404 | "hashes": [ 405 | "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", 406 | "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" 407 | ], 408 | "version": "==7.1.2" 409 | }, 410 | "entrypoints": { 411 | "hashes": [ 412 | "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", 413 | "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451" 414 | ], 415 | "version": "==0.3" 416 | }, 417 | "flake8": { 418 | "hashes": [ 419 | "sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c", 420 | "sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208" 421 | ], 422 | "version": "==3.8.3" 423 | }, 424 | "importlib-metadata": { 425 | "hashes": [ 426 | "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83", 427 | "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070" 428 | ], 429 | "version": "==1.7.0" 430 | }, 431 | "isort": { 432 | "hashes": [ 433 | "sha256:6ae9cf5414e416954e3421f861cbbfc099b3ace63cb270cc76c6670efd960a0a", 434 | "sha256:78661ad751751cb3c181d37302e175a0c644b3714877c073df058c596281d7fd" 435 | ], 436 | "version": "==5.0.4" 437 | }, 438 | "mccabe": { 439 | "hashes": [ 440 | "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", 441 | "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" 442 | ], 443 | "version": "==0.6.1" 444 | }, 445 | "more-itertools": { 446 | "hashes": [ 447 | "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5", 448 | "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2" 449 | ], 450 | "version": "==8.4.0" 451 | }, 452 | "packaging": { 453 | "hashes": [ 454 | "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", 455 | "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" 456 | ], 457 | "version": "==20.4" 458 | }, 459 | "pathspec": { 460 | "hashes": [ 461 | "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0", 462 | "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061" 463 | ], 464 | "version": "==0.8.0" 465 | }, 466 | "pluggy": { 467 | "hashes": [ 468 | "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", 469 | "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" 470 | ], 471 | "version": "==0.13.1" 472 | }, 473 | "py": { 474 | "hashes": [ 475 | "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", 476 | "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" 477 | ], 478 | "version": "==1.9.0" 479 | }, 480 | "pycodestyle": { 481 | "hashes": [ 482 | "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", 483 | "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" 484 | ], 485 | "version": "==2.6.0" 486 | }, 487 | "pyflakes": { 488 | "hashes": [ 489 | "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92", 490 | "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8" 491 | ], 492 | "version": "==2.2.0" 493 | }, 494 | "pyparsing": { 495 | "hashes": [ 496 | "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", 497 | "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" 498 | ], 499 | "version": "==2.4.7" 500 | }, 501 | "pytest": { 502 | "hashes": [ 503 | "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1", 504 | "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8" 505 | ], 506 | "version": "==5.4.3" 507 | }, 508 | "regex": { 509 | "hashes": [ 510 | "sha256:08997a37b221a3e27d68ffb601e45abfb0093d39ee770e4257bd2f5115e8cb0a", 511 | "sha256:112e34adf95e45158c597feea65d06a8124898bdeac975c9087fe71b572bd938", 512 | "sha256:1700419d8a18c26ff396b3b06ace315b5f2a6e780dad387e4c48717a12a22c29", 513 | "sha256:2f6f211633ee8d3f7706953e9d3edc7ce63a1d6aad0be5dcee1ece127eea13ae", 514 | "sha256:52e1b4bef02f4040b2fd547357a170fc1146e60ab310cdbdd098db86e929b387", 515 | "sha256:55b4c25cbb3b29f8d5e63aeed27b49fa0f8476b0d4e1b3171d85db891938cc3a", 516 | "sha256:5aaa5928b039ae440d775acea11d01e42ff26e1561c0ffcd3d805750973c6baf", 517 | "sha256:654cb773b2792e50151f0e22be0f2b6e1c3a04c5328ff1d9d59c0398d37ef610", 518 | "sha256:690f858d9a94d903cf5cada62ce069b5d93b313d7d05456dbcd99420856562d9", 519 | "sha256:6ad8663c17db4c5ef438141f99e291c4d4edfeaacc0ce28b5bba2b0bf273d9b5", 520 | "sha256:89cda1a5d3e33ec9e231ece7307afc101b5217523d55ef4dc7fb2abd6de71ba3", 521 | "sha256:92d8a043a4241a710c1cf7593f5577fbb832cf6c3a00ff3fc1ff2052aff5dd89", 522 | "sha256:95fa7726d073c87141f7bbfb04c284901f8328e2d430eeb71b8ffdd5742a5ded", 523 | "sha256:97712e0d0af05febd8ab63d2ef0ab2d0cd9deddf4476f7aa153f76feef4b2754", 524 | "sha256:b2ba0f78b3ef375114856cbdaa30559914d081c416b431f2437f83ce4f8b7f2f", 525 | "sha256:bae83f2a56ab30d5353b47f9b2a33e4aac4de9401fb582b55c42b132a8ac3868", 526 | "sha256:c78e66a922de1c95a208e4ec02e2e5cf0bb83a36ceececc10a72841e53fbf2bd", 527 | "sha256:cf59bbf282b627130f5ba68b7fa3abdb96372b24b66bdf72a4920e8153fc7910", 528 | "sha256:e3cdc9423808f7e1bb9c2e0bdb1c9dc37b0607b30d646ff6faf0d4e41ee8fee3", 529 | "sha256:e9b64e609d37438f7d6e68c2546d2cb8062f3adb27e6336bc129b51be20773ac", 530 | "sha256:fbff901c54c22425a5b809b914a3bfaf4b9570eee0e5ce8186ac71eb2025191c" 531 | ], 532 | "version": "==2020.6.8" 533 | }, 534 | "six": { 535 | "hashes": [ 536 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 537 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 538 | ], 539 | "version": "==1.15.0" 540 | }, 541 | "toml": { 542 | "hashes": [ 543 | "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", 544 | "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88" 545 | ], 546 | "version": "==0.10.1" 547 | }, 548 | "typed-ast": { 549 | "hashes": [ 550 | "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", 551 | "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", 552 | "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", 553 | "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", 554 | "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", 555 | "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", 556 | "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", 557 | "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", 558 | "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", 559 | "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", 560 | "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", 561 | "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", 562 | "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", 563 | "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", 564 | "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", 565 | "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", 566 | "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", 567 | "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", 568 | "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", 569 | "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", 570 | "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" 571 | ], 572 | "version": "==1.4.1" 573 | }, 574 | "wcwidth": { 575 | "hashes": [ 576 | "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784", 577 | "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83" 578 | ], 579 | "version": "==0.2.5" 580 | }, 581 | "zipp": { 582 | "hashes": [ 583 | "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", 584 | "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" 585 | ], 586 | "version": "==3.1.0" 587 | } 588 | } 589 | } 590 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | SirBot Pyslackers 2 | ================= 3 | 4 | Bot built with `Sir Bot-a-lot `_ for the `Pyslackers `_ community. 5 | 6 | .. image:: https://travis-ci.org/pyslackers/sirbot-pyslackers.svg?branch=master 7 | :target: https://travis-ci.org/pyslackers/sirbot-pyslackers 8 | :alt: Travis-ci status 9 | 10 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 11 | :target: https://github.com/ambv/black 12 | :alt: Code-style: black 13 | 14 | Features 15 | -------- 16 | 17 | * Welcome new user joining the slack team. 18 | * Various slack commands & actions (find them all using ``@sir_botalot help`` on slack). 19 | * Monitor github activity on the pyslackers organization. 20 | * Monitor community projects documentation build status on `readthedocs.org `_. 21 | * Monitor the slack community for easier administration. 22 | 23 | Contributing 24 | ------------ 25 | 26 | Contributions are welcome by anybody and everybody. We are not kidding! Looking for help ? Join us ! 27 | 28 | For contributing guidelines please read the `contributing.rst `_ file. 29 | 30 | Join Us 31 | ------- 32 | 33 | To join our growing slack community head over to `pyslackers.com `_ and request an invitation. 34 | 35 | Questions about the bot ? Ask us in the #community-projects channel. 36 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | ngrok: 5 | image: wernight/ngrok:latest 6 | environment: 7 | - NGROK_PORT=sirbot:9000 8 | ports: 9 | - 127.0.0.1:4040:4040 10 | 11 | db: 12 | image: postgres:latest 13 | volumes: 14 | - db:/var/lib/postgresql/data 15 | env_file: 16 | - example.env 17 | - sirbot.env 18 | expose: 19 | - 5432 20 | ports: 21 | - 127.0.0.1:5432:5432 22 | 23 | sirbot: 24 | image: sirbot-pyslackers 25 | build: 26 | context: ../ 27 | dockerfile: docker/dockerfile 28 | env_file: 29 | - example.env 30 | - sirbot.env 31 | depends_on: 32 | - db 33 | ports: 34 | - 127.0.0.1:9000:9000 35 | expose: 36 | - 9000 37 | 38 | volumes: 39 | db: 40 | -------------------------------------------------------------------------------- /docker/dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7-alpine 2 | 3 | ENV PYTHONUNBUFFERED 1 4 | ENV PIP_NO_CACHE_DIR 0 5 | 6 | RUN mkdir /sirbot 7 | WORKDIR /sirbot 8 | 9 | RUN apk add --update --no-cache \ 10 | build-base \ 11 | libffi-dev \ 12 | libxslt-dev \ 13 | postgresql-dev \ 14 | git \ 15 | && pip install pipenv \ 16 | && rm -rf /var/cache/apk/* 17 | 18 | COPY Pipfil* /sirbot/ 19 | RUN pipenv install --system --deploy --dev 20 | 21 | COPY . /sirbot 22 | ENTRYPOINT [ "python3", "-m", "sirbot_pyslackers" ] 23 | -------------------------------------------------------------------------------- /docker/example.env: -------------------------------------------------------------------------------- 1 | ################## 2 | ### POSTGRESQL ### 3 | ################## 4 | 5 | # Database password. Should match with `POSTGRES_DNS`. 6 | POSTGRES_PASSWORD=db_password 7 | 8 | ################ 9 | ### SIRBOT ### 10 | ################ 11 | 12 | # Sirbot listening address 13 | SIRBOT_ADDR=0.0.0.0 14 | 15 | ## Postgres plugin ## 16 | # Postgresql connection string. 17 | POSTGRES_DSN=postgres://postgres:db_password@db:5432/postgres 18 | 19 | ## Slack plugin ## 20 | # Slack API token 21 | SLACK_TOKEN=slack_token 22 | 23 | # Slack incoming webhook verification token 24 | SLACK_VERIFY=slack_verify 25 | 26 | # USER_ID of the application's bot 27 | # SLACK_BOT_USER_ID= 28 | 29 | # BOT_ID of the application's bot 30 | # SLACK_BOT_ID= 31 | 32 | # Comma separated list of the slack administrator's USER_ID 33 | # SLACK_ADMINS= 34 | 35 | ## Github plugin ## 36 | # Github incoming webhook verification token 37 | GITHUB_VERIFY=github_verify 38 | 39 | ## Deploy plugin ## 40 | # Custom incoming webhook verification token 41 | DEPLOY_TOKEN=deploy_token 42 | 43 | ## Sentry ## 44 | # https://sentry.io connection string 45 | # SENTRY_DSN= 46 | -------------------------------------------------------------------------------- /logging.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | disable_existing_loggers: false 3 | formatters: 4 | simple: 5 | format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s' 6 | handlers: 7 | console: 8 | class: logging.StreamHandler 9 | level: DEBUG 10 | formatter: simple 11 | stream: ext://sys.stdout 12 | loggers: 13 | sirbot: 14 | level: DEBUG 15 | propagate: true 16 | sirbot_pyslackers: 17 | level: DEBUG 18 | propagate: true 19 | slack: 20 | level: DEBUG 21 | propagate: true 22 | root: 23 | level: INFO 24 | handlers: 25 | - console 26 | propagate: no 27 | -------------------------------------------------------------------------------- /scripts/dev_down.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | docker-compose -p sirbot -f docker/docker-compose.yml down 4 | -------------------------------------------------------------------------------- /scripts/dev_up.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | docker-compose -p sirbot -f docker/docker-compose.yml up --build --detach 6 | docker logs --follow sirbot_sirbot_1 7 | -------------------------------------------------------------------------------- /scripts/run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | docker build -f docker/dockerfile -t sirbot-pyslackers . 6 | docker run --entrypoint scripts/test.sh sirbot-pyslackers 7 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | EXIT=0 4 | 5 | echo "TEST: black" 6 | black --check --diff . || EXIT=$? 7 | 8 | echo "TEST: isort" 9 | isort --recursive --check-only . || EXIT=$? 10 | 11 | export PYTHONWARNINGS="ignore" 12 | echo "TEST: flake8" 13 | flake8 . || EXIT=$? 14 | export PYTHONWARNINGS="default" 15 | 16 | echo "TEST: pytest" 17 | python -m pytest ./tests/ || EXIT=$? 18 | 19 | exit $EXIT 20 | -------------------------------------------------------------------------------- /sirbot_pyslackers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyslackers/sirbot-pyslackers/98e127fa39d5c8cba460d978007aeb34e7d799cd/sirbot_pyslackers/__init__.py -------------------------------------------------------------------------------- /sirbot_pyslackers/__main__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import asyncio 4 | import logging.config 5 | 6 | import yaml 7 | import raven 8 | import platformshconfig 9 | from sirbot import SirBot 10 | from raven.processors import SanitizePasswordsProcessor 11 | from sirbot.plugins.slack import SlackPlugin 12 | from raven.handlers.logging import SentryHandler 13 | from sirbot.plugins.postgres import PgPlugin 14 | from sirbot.plugins.apscheduler import APSchedulerPlugin 15 | from sirbot.plugins.readthedocs import RTDPlugin 16 | 17 | from . import endpoints 18 | from .plugins import PypiPlugin, StocksPlugin 19 | 20 | PORT = os.environ.get("SIRBOT_PORT", os.environ.get("PORT", 9000)) 21 | HOST = os.environ.get("SIRBOT_ADDR", "127.0.0.1") 22 | VERSION = "0.0.11" 23 | LOG = logging.getLogger(__name__) 24 | PSH_CONFIG = platformshconfig.Config() 25 | 26 | 27 | def make_sentry_logger(dsn): 28 | 29 | if PSH_CONFIG.is_valid_platform(): 30 | version = PSH_CONFIG.treeID 31 | else: 32 | version = VERSION 33 | 34 | client = raven.Client( 35 | dsn=dsn, release=version, processor=SanitizePasswordsProcessor 36 | ) 37 | handler = SentryHandler(client) 38 | handler.setLevel(logging.WARNING) 39 | raven.conf.setup_logging(handler) 40 | 41 | 42 | def setup_logging(): 43 | try: 44 | with open( 45 | os.path.join(os.path.dirname(os.path.realpath(__file__)), "../logging.yml") 46 | ) as log_configfile: 47 | logging.config.dictConfig(yaml.safe_load(log_configfile.read())) 48 | 49 | except Exception as e: 50 | logging.basicConfig(level=logging.DEBUG) 51 | LOG.exception(e) 52 | 53 | sentry_dsn = os.environ.get("SENTRY_DSN") 54 | if sentry_dsn: 55 | make_sentry_logger(sentry_dsn) 56 | 57 | 58 | def configure_postgresql_plugin(): 59 | 60 | if "POSTGRES_DSN" in os.environ: 61 | dsn = os.environ["POSTGRES_DSN"] 62 | elif PSH_CONFIG.is_valid_platform(): 63 | dsn = PSH_CONFIG.formatted_credentials("database", "postgresql_dsn") 64 | else: 65 | dsn = None 66 | 67 | if dsn: 68 | return PgPlugin( 69 | version=VERSION, 70 | sql_migration_directory=os.path.join( 71 | os.path.dirname(os.path.realpath(__file__)), "../sql" 72 | ), 73 | dsn=dsn, 74 | ) 75 | else: 76 | raise RuntimeError( 77 | "No postgresql configuration available. Use POSTGRES_DSN environment variable" 78 | ) 79 | 80 | 81 | if __name__ == "__main__": 82 | 83 | setup_logging() 84 | 85 | if len(sys.argv) > 1 and sys.argv[1] == "migrate": 86 | postgres = configure_postgresql_plugin() 87 | loop = asyncio.get_event_loop() 88 | loop.run_until_complete(postgres.startup(None)) 89 | sys.exit(0) 90 | 91 | bot = SirBot() 92 | 93 | slack = SlackPlugin() 94 | endpoints.slack.create_endpoints(slack) 95 | bot.load_plugin(slack) 96 | 97 | pypi = PypiPlugin() 98 | bot.load_plugin(pypi) 99 | 100 | stocks = StocksPlugin() 101 | bot.load_plugin(stocks) 102 | 103 | scheduler = APSchedulerPlugin(timezone="UTC") 104 | endpoints.apscheduler.create_jobs(scheduler, bot) 105 | bot.load_plugin(scheduler) 106 | 107 | readthedocs = RTDPlugin() 108 | endpoints.readthedocs.register(readthedocs) 109 | bot.load_plugin(readthedocs) 110 | 111 | postgres = configure_postgresql_plugin() 112 | bot.load_plugin(postgres) 113 | 114 | bot.start(host=HOST, port=PORT, print=False) 115 | -------------------------------------------------------------------------------- /sirbot_pyslackers/endpoints/__init__.py: -------------------------------------------------------------------------------- 1 | from . import slack # noQa F401 2 | from . import apscheduler # noQa F410 3 | from . import readthedocs # noQa F401 4 | -------------------------------------------------------------------------------- /sirbot_pyslackers/endpoints/apscheduler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import datetime 3 | 4 | import pytz 5 | from slack import methods 6 | from slack.events import Message 7 | 8 | LOG = logging.getLogger(__name__) 9 | 10 | 11 | def create_jobs(scheduler, bot): 12 | scheduler.scheduler.add_job(slack_channel_list, "cron", hour=1, kwargs={"bot": bot}) 13 | scheduler.scheduler.add_job(slack_users_list, "cron", hour=2, kwargs={"bot": bot}) 14 | scheduler.scheduler.add_job( 15 | etc_finance_bell, 16 | "cron", 17 | day_of_week="0-4", 18 | hour=9, 19 | minute=30, 20 | timezone="America/New_York", 21 | kwargs={"bot": bot, "state": "open"}, 22 | ) 23 | scheduler.scheduler.add_job( 24 | etc_finance_bell, 25 | "cron", 26 | day_of_week="0-4", 27 | hour=16, 28 | timezone="America/New_York", 29 | kwargs={"bot": bot, "state": "closed"}, 30 | ) 31 | scheduler.scheduler.add_job( 32 | advent_of_code, 33 | "cron", 34 | month=12, 35 | day="1-25", 36 | hour=0, 37 | minute=5, 38 | second=0, 39 | timezone="America/New_York", 40 | kwargs={"bot": bot}, 41 | ) 42 | 43 | 44 | async def advent_of_code(bot): 45 | LOG.info("Creating Advent Of Code threads...") 46 | for_day = datetime.datetime.now(tz=pytz.timezone("America/New_York")) 47 | year, day = for_day.year, for_day.day 48 | for part in range(1, 3): 49 | message = Message() 50 | message["channel"] = "advent_of_code" 51 | message["attachments"] = [ 52 | { 53 | "fallback": "Official {} Advent Of Code Thread for Day {} Part {}".format( 54 | year, day, part 55 | ), 56 | "color": ["#ff0000", "#378b29"][ # red # green 57 | (part - 1) // 1 58 | ], # red=part 1, green=part 2 59 | "title": ":christmas_tree: {} Advent of Code Thread: Day {} Part {} :christmas_tree:".format( 60 | year, day, part 61 | ), 62 | "title_link": "https://adventofcode.com/{}/day/{}".format(year, day), 63 | "text": ( 64 | "Post solutions to part {} in this thread, in any language, to avoid spoilers!".format( 65 | part 66 | ) 67 | ), 68 | "footer": "Advent of Code", 69 | "footer_icon": "https://adventofcode.com/favicon.ico", 70 | "ts": int(for_day.timestamp()), 71 | } 72 | ] 73 | 74 | await bot["plugins"]["slack"].api.query( 75 | url=methods.CHAT_POST_MESSAGE, data=message 76 | ) 77 | 78 | 79 | async def slack_channel_list(bot): 80 | LOG.info("Updating list of slack channels...") 81 | async with bot["plugins"]["pg"].connection() as pg_con: 82 | async for channel in bot["plugins"]["slack"].api.iter( 83 | methods.CHANNELS_LIST, minimum_time=3, data={"exclude_members": True} 84 | ): 85 | await pg_con.execute( 86 | """INSERT INTO slack.channels (id, raw) VALUES ($1, $2) 87 | ON CONFLICT (id) DO UPDATE SET raw = $2""", 88 | channel["id"], 89 | channel, 90 | ) 91 | LOG.info("List of slack channels up to date.") 92 | 93 | 94 | async def slack_users_list(bot): 95 | LOG.info("Updating list of slack users...") 96 | async with bot["plugins"]["pg"].connection() as pg_con: 97 | async for user in bot["plugins"]["slack"].api.iter( 98 | methods.USERS_LIST, minimum_time=12 99 | ): 100 | await pg_con.execute( 101 | """INSERT INTO slack.users (id, name, deleted, admin, bot, raw) VALUES 102 | ($1, $2, $3, $4, $5, $6) ON CONFLICT (id) DO UPDATE SET 103 | name = $2, deleted = $3, admin = $4, bot = $5, raw = $6""", 104 | user["id"], 105 | user["profile"]["display_name"], 106 | user.get("deleted", False), 107 | user.get("is_admin", False), 108 | user.get("is_bot", False), 109 | user, 110 | ) 111 | LOG.info("List of slack users up to date") 112 | 113 | 114 | async def etc_finance_bell(bot, state): 115 | LOG.info("Posting %s bell to #etc_finance", state) 116 | 117 | holidays = [ 118 | datetime.date(2019, 2, 18), 119 | datetime.date(2019, 4, 19), 120 | datetime.date(2019, 5, 27), 121 | datetime.date(2019, 7, 4), 122 | datetime.date(2019, 9, 2), 123 | datetime.date(2019, 11, 28), 124 | datetime.date(2019, 12, 25), 125 | ] 126 | 127 | message = Message() 128 | 129 | message["channel"] = "etc_finance" 130 | 131 | if datetime.date.today() in holidays: 132 | message[ 133 | "text" 134 | ] = """:bell: :bell: :bell: The US Stock Market is *CLOSED for holiday*. :bell: :bell: :bell:""" 135 | 136 | state = "holiday" 137 | 138 | elif state == "open": 139 | message[ 140 | "text" 141 | ] = """:bell: :bell: :bell: The US Stock Market is now *OPEN* for trading. :bell: :bell: :bell:""" 142 | 143 | elif state == "closed": 144 | message[ 145 | "text" 146 | ] = """:bell: :bell: :bell: The US Stock Market is now *CLOSED* for trading. :bell: :bell: :bell:""" 147 | 148 | await bot["plugins"]["slack"].api.query(url=methods.CHAT_POST_MESSAGE, data=message) 149 | -------------------------------------------------------------------------------- /sirbot_pyslackers/endpoints/readthedocs.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from slack import methods 4 | from slack.events import Message 5 | 6 | LOG = logging.getLogger(__name__) 7 | 8 | 9 | def register(readthedocs): 10 | readthedocs.register_handler("sir-bot-a-lot", handler=build_failure) 11 | readthedocs.register_handler("slack-sansio", handler=build_failure) 12 | 13 | 14 | async def build_failure(data, app): 15 | msg = Message() 16 | msg["channel"] = "community_projects" 17 | msg["text"] = f"""Building of {data["name"]} documentation failed ! :cry:""" 18 | await app.plugins["slack"].api.query(methods.CHAT_POST_MESSAGE, data=msg) 19 | -------------------------------------------------------------------------------- /sirbot_pyslackers/endpoints/slack/__init__.py: -------------------------------------------------------------------------------- 1 | from . import events, actions, commands, messages 2 | 3 | 4 | def create_endpoints(plugin): 5 | messages.create_endpoints(plugin) 6 | commands.create_endpoints(plugin) 7 | actions.create_endpoints(plugin) 8 | events.create_endpoints(plugin) 9 | -------------------------------------------------------------------------------- /sirbot_pyslackers/endpoints/slack/actions.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import asyncio 4 | import logging 5 | import datetime 6 | 7 | from slack import methods 8 | from aiohttp.web import json_response 9 | from slack.events import Message 10 | from slack.exceptions import SlackAPIError 11 | from slack.io.aiohttp import SlackAPI 12 | 13 | from .utils import ADMIN_CHANNEL 14 | 15 | LOG = logging.getLogger(__name__) 16 | 17 | 18 | def create_endpoints(plugin): 19 | plugin.on_action("topic_change", topic_change_revert, name="revert") 20 | plugin.on_action("topic_change", topic_change_validate, name="validate") 21 | 22 | plugin.on_action("pin_added", pin_added_validate, name="validate") 23 | plugin.on_action("pin_added", pin_added_revert, name="revert") 24 | 25 | plugin.on_action("report", report) 26 | plugin.on_action("tell_admin", tell_admin) 27 | plugin.on_action("make_snippet", make_snippet) 28 | 29 | plugin.on_action("user_cleanup", user_cleanup_cancel, name="cancel") 30 | plugin.on_action("user_cleanup", user_cleanup_confirm, name="confirm") 31 | 32 | 33 | async def topic_change_revert(action, app): 34 | response = Message() 35 | response["channel"] = action["channel"]["id"] 36 | response["ts"] = action["message_ts"] 37 | response["attachments"] = action["original_message"]["attachments"] 38 | response["attachments"][0]["color"] = "danger" 39 | response["attachments"][0]["text"] = f'Change reverted by <@{action["user"]["id"]}>' 40 | del response["attachments"][0]["actions"] 41 | 42 | data = json.loads(action["actions"][0]["value"]) 43 | await app.plugins["slack"].api.query( 44 | url=methods.CHANNELS_SET_TOPIC, 45 | data={"channel": data["channel"], "topic": data["old_topic"]}, 46 | ) 47 | 48 | await app.plugins["slack"].api.query(url=action["response_url"], data=response) 49 | 50 | 51 | async def topic_change_validate(action, app): 52 | response = Message() 53 | response["channel"] = action["channel"]["id"] 54 | response["ts"] = action["message_ts"] 55 | response["attachments"] = action["original_message"]["attachments"] 56 | response["attachments"][0]["color"] = "good" 57 | response["attachments"][0][ 58 | "text" 59 | ] = f'Change validated by <@{action["user"]["id"]}>' 60 | del response["attachments"][0]["actions"] 61 | 62 | await app.plugins["slack"].api.query(url=action["response_url"], data=response) 63 | 64 | 65 | async def pin_added_validate(action, app): 66 | response = Message() 67 | response["channel"] = action["channel"]["id"] 68 | response["ts"] = action["message_ts"] 69 | response["attachments"] = action["original_message"]["attachments"] 70 | response["attachments"][0]["color"] = "good" 71 | response["attachments"][0][ 72 | "pretext" 73 | ] = f'Pin validated by <@{action["user"]["id"]}>' 74 | del response["attachments"][0]["actions"] 75 | 76 | await app.plugins["slack"].api.query(url=action["response_url"], data=response) 77 | 78 | 79 | async def pin_added_revert(action, app): 80 | response = Message() 81 | 82 | response["channel"] = action["channel"]["id"] 83 | response["ts"] = action["message_ts"] 84 | response["attachments"] = action["original_message"]["attachments"] 85 | response["attachments"][0]["color"] = "danger" 86 | response["attachments"][0]["pretext"] = f'Pin reverted by <@{action["user"]["id"]}>' 87 | del response["attachments"][0]["actions"] 88 | 89 | action_data = json.loads(action["actions"][0]["value"]) 90 | remove_data = {"channel": action_data["channel"]} 91 | 92 | if action_data["item_type"] == "message": 93 | remove_data["timestamp"] = action_data["item_id"] 94 | elif action_data["item_type"] == "file": 95 | remove_data["file"] = action_data["item_id"] 96 | elif action_data["item_type"] == "file_comment": 97 | remove_data["file_comment"] = action_data["item_id"] 98 | else: 99 | raise TypeError(f'Unknown pin type: {action_data["type"]}') 100 | 101 | try: 102 | await app.plugins["slack"].api.query(url=methods.PINS_REMOVE, data=remove_data) 103 | except SlackAPIError as e: 104 | if e.error != "no_pin": 105 | raise 106 | 107 | await app.plugins["slack"].api.query(url=action["response_url"], data=response) 108 | 109 | 110 | async def report(action, app): 111 | admin_msg = Message() 112 | admin_msg["channel"] = ADMIN_CHANNEL 113 | admin_msg["attachments"] = [ 114 | { 115 | "fallback": f'Report from {action["user"]["name"]}', 116 | "title": f'Report from <@{action["user"]["id"]}>', 117 | "color": "danger", 118 | "fields": [ 119 | { 120 | "title": "User", 121 | "value": f'<@{action["submission"]["user"]}>', 122 | "short": True, 123 | } 124 | ], 125 | } 126 | ] 127 | 128 | if action["submission"]["channel"]: 129 | admin_msg["attachments"][0]["fields"].append( 130 | { 131 | "title": "Channel", 132 | "value": f'<#{action["submission"]["channel"]}>', 133 | "short": True, 134 | } 135 | ) 136 | 137 | admin_msg["attachments"][0]["fields"].append( 138 | {"title": "Comment", "value": action["submission"]["comment"], "short": False} 139 | ) 140 | 141 | await app.plugins["slack"].api.query(url=methods.CHAT_POST_MESSAGE, data=admin_msg) 142 | 143 | async with app["plugins"]["pg"].connection() as pg_con: 144 | await pg_con.execute( 145 | """INSERT INTO slack.reports ("user", channel, comment, by) VALUES ($1, $2, $3, $4)""", 146 | action["submission"]["user"], 147 | action["submission"]["channel"], 148 | action["submission"]["comment"], 149 | action["user"]["id"], 150 | ) 151 | 152 | response = Message() 153 | response["response_type"] = "ephemeral" 154 | response["text"] = "Thank you for your report. An admin will look into it soon." 155 | 156 | await app.plugins["slack"].api.query(url=action["response_url"], data=response) 157 | 158 | 159 | async def tell_admin(action, app): 160 | admin_msg = Message() 161 | admin_msg["channel"] = ADMIN_CHANNEL 162 | admin_msg["attachments"] = [ 163 | { 164 | "fallback": f'Message from {action["user"]["name"]}', 165 | "title": f'Message from <@{action["user"]["id"]}>', 166 | "color": "good", 167 | "text": action["submission"]["message"], 168 | } 169 | ] 170 | 171 | await app.plugins["slack"].api.query(url=methods.CHAT_POST_MESSAGE, data=admin_msg) 172 | 173 | response = Message() 174 | response["response_type"] = "ephemeral" 175 | response["text"] = "Thank you for your message." 176 | 177 | await app.plugins["slack"].api.query(url=action["response_url"], data=response) 178 | 179 | 180 | async def make_snippet(action, app): 181 | 182 | if not action["message"]["text"].startswith("```"): 183 | response = Message() 184 | response["channel"] = action["channel"]["id"] 185 | response["text"] = f"""```{action["message"]["text"]}```""" 186 | 187 | tip_message = Message() 188 | tip_message["channel"] = action["channel"]["id"] 189 | tip_message["user"] = action["message"]["user"] 190 | tip_message["text"] = ( 191 | "Please use the snippet feature, or backticks, when sharing code. You can do so by " 192 | "clicking on the :heavy_plus_sign: on the left of the input box for a snippet.\n" 193 | "For more information on snippets click " 194 | ".\n" 195 | "For more information on inline code formatting with backticks click " 196 | "." 197 | ) 198 | 199 | await asyncio.gather( 200 | app.plugins["slack"].api.query( 201 | url=methods.CHAT_POST_EPHEMERAL, data=tip_message 202 | ), 203 | app.plugins["slack"].api.query( 204 | url=methods.CHAT_POST_MESSAGE, data=response 205 | ), 206 | ) 207 | else: 208 | response = Message() 209 | response["text"] = "Sorry I'm unable to format that message" 210 | await app.plugins["slack"].api.query(url=action["response_url"], data=response) 211 | 212 | 213 | async def user_cleanup_cancel(action, app): 214 | response = Message() 215 | response["channel"] = action["channel"]["id"] 216 | response["ts"] = action["message_ts"] 217 | response["attachments"] = action["original_message"]["attachments"] 218 | response["attachments"][0]["color"] = "good" 219 | response["attachments"][0]["text"] = f'Cancelled by <@{action["user"]["id"]}>' 220 | del response["attachments"][0]["actions"] 221 | 222 | await app.plugins["slack"].api.query(url=action["response_url"], data=response) 223 | 224 | 225 | async def user_cleanup_confirm(action, app): 226 | response = Message() 227 | response["channel"] = action["channel"]["id"] 228 | response["ts"] = action["message_ts"] 229 | response["attachments"] = action["original_message"]["attachments"] 230 | response["attachments"][0]["color"] = "good" 231 | response["attachments"][0][ 232 | "text" 233 | ] = f'Cleanup confirmed by <@{action["user"]["id"]}>' 234 | del response["attachments"][0]["actions"] 235 | 236 | await app.plugins["slack"].api.query(url=action["response_url"], data=response) 237 | 238 | user_id = action["actions"][0]["value"] 239 | asyncio.create_task(_cleanup_user(app, user_id)) 240 | 241 | 242 | async def _cleanup_user(app, user): 243 | try: 244 | async with app["plugins"]["pg"].connection() as pg_con: 245 | messages = await pg_con.fetch( 246 | """SELECT id, channel FROM slack.messages WHERE "user" = $1""", user 247 | ) 248 | 249 | api = SlackAPI( 250 | session=app["http_session"], token=os.environ["SLACK_ADMIN_TOKEN"] 251 | ) 252 | for message in messages: 253 | try: 254 | data = {"channel": message["channel"], "ts": message["id"]} 255 | await api.query(url=methods.CHAT_DELETE, data=data) 256 | except SlackAPIError as e: 257 | if e.error == "message_not_found": 258 | continue 259 | else: 260 | LOG.exception( 261 | "Failed to cleanup message %s in channel %s", 262 | message["id"], 263 | message["channel"], 264 | ) 265 | except Exception: 266 | LOG.exception( 267 | "Failed to cleanup message %s in channel %s", 268 | message["id"], 269 | message["channel"], 270 | ) 271 | except Exception: 272 | LOG.exception("Unexpected exception cleaning up user %s", user) 273 | -------------------------------------------------------------------------------- /sirbot_pyslackers/endpoints/slack/commands.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | from slack import methods 5 | from slack.events import Message 6 | 7 | from .utils import HELP_FIELD_DESCRIPTIONS 8 | 9 | LOG = logging.getLogger(__name__) 10 | 11 | 12 | def create_endpoints(plugin): 13 | plugin.on_command("/admin", tell_admin) 14 | plugin.on_command("/sirbot", sirbot_help) 15 | plugin.on_command("/howtoask", ask) 16 | plugin.on_command("/justask", just_ask) 17 | plugin.on_command("/pypi", pypi_search) 18 | plugin.on_command("/sponsors", sponsors) 19 | plugin.on_command("/snippet", snippet) 20 | plugin.on_command("/report", report) 21 | plugin.on_command("/resources", resources) 22 | plugin.on_command("/xpost", xpost) 23 | 24 | 25 | async def just_ask(command, app): 26 | slack = app.plugins["slack"] 27 | response = Message() 28 | response["channel"] = command["channel_id"] 29 | response["unfurl_links"] = False 30 | 31 | response["text"] = ( 32 | "If you have a question, please just ask it. Please do not ask for topic experts; " 33 | "do not DM or ping random users. We cannot begin to answer a question until we actually get a question. \n\n" 34 | "" 35 | ) 36 | 37 | await slack.api.query(url=methods.CHAT_POST_MESSAGE, data=response) 38 | 39 | 40 | async def sirbot_help(command, app): 41 | slack = app.plugins["slack"] 42 | response = Message() 43 | response["channel"] = command["channel_id"] 44 | response["unfurl_links"] = False 45 | 46 | response["text"] = "Community Slack Commands" 47 | response["attachments"] = [{"color": "good", "fields": HELP_FIELD_DESCRIPTIONS}] 48 | 49 | await slack.api.query(url=methods.CHAT_POST_MESSAGE, data=response) 50 | 51 | 52 | async def ask(command, app): 53 | slack = app.plugins["slack"] 54 | response = Message() 55 | response["channel"] = command["channel_id"] 56 | response["unfurl_links"] = True 57 | 58 | response["text"] = ( 59 | "Knowing how to ask a good question is a highly invaluable skill that " 60 | "will benefit you greatly in any career. Two good resources for " 61 | "suggestions and strategies to help you structure and phrase your " 62 | "question to make it easier for those here to understand your problem " 63 | "and help you work to a solution are:\n\n" 64 | "• \n" 65 | "• \n" 66 | ) 67 | 68 | await slack.api.query(url=methods.CHAT_POST_MESSAGE, data=response) 69 | 70 | 71 | async def sponsors(command, app): 72 | slack = app.plugins["slack"] 73 | response = Message() 74 | response["channel"] = command["channel_id"] 75 | response["unfurl_links"] = False 76 | 77 | response["text"] = ( 78 | "Thanks to our sponsors, and " 79 | " for providing hosting & services helping us " 80 | "host our and Sir Bot-a-lot.\n" 81 | "If you are planning on using please use our ." 83 | ) 84 | 85 | await slack.api.query(url=methods.CHAT_POST_MESSAGE, data=response) 86 | 87 | 88 | async def report(command, app): 89 | 90 | data = { 91 | "trigger_id": command["trigger_id"], 92 | "dialog": { 93 | "callback_id": "report", 94 | "title": "Report user", 95 | "elements": [ 96 | { 97 | "label": "Offending user", 98 | "name": "user", 99 | "type": "select", 100 | "data_source": "users", 101 | }, 102 | { 103 | "label": "Channel", 104 | "name": "channel", 105 | "type": "select", 106 | "data_source": "channels", 107 | "optional": True, 108 | }, 109 | { 110 | "label": "Comment", 111 | "name": "comment", 112 | "type": "textarea", 113 | "value": command["text"], 114 | }, 115 | ], 116 | }, 117 | } 118 | 119 | await app.plugins["slack"].api.query(url=methods.DIALOG_OPEN, data=data) 120 | 121 | 122 | async def pypi_search(command, app): 123 | response = Message() 124 | response["channel"] = command["channel_id"] 125 | 126 | if not command["text"]: 127 | response["response_type"] = "ephemeral" 128 | response["text"] = "Please enter the package name you wish to find" 129 | else: 130 | results = await app.plugins["pypi"].search(command["text"]) 131 | if results: 132 | response["response_type"] = "in_channel" 133 | response["attachments"] = [ 134 | { 135 | "title": f'<@{command["user_id"]}> Searched PyPi for `{command["text"]}`', 136 | "fallback": f'Pypi search of {command["text"]}', 137 | "fields": [], 138 | } 139 | ] 140 | 141 | for result in results[:3]: 142 | response["attachments"][0]["fields"].append( 143 | { 144 | "title": result["name"], 145 | "value": f'<{app.plugins["pypi"].PROJECT_URL.format(result["name"])}|{result["summary"]}>', 146 | } 147 | ) 148 | 149 | if len(results) == 4: 150 | response["attachments"][0]["fields"].append( 151 | { 152 | "title": results[3]["name"], 153 | "value": f'<{app.plugins["pypi"].PROJECT_URL.format(results[3]["name"])}|{results[3]["summary"]}>', 154 | } 155 | ) 156 | elif len(results) > 3: 157 | response["attachments"][0]["fields"].append( 158 | { 159 | "title": f"More results", 160 | "value": f'<{app.plugins["pypi"].RESULT_URL.format(command["text"])}|' 161 | f"{len(results) - 3} more results..>", 162 | } 163 | ) 164 | 165 | else: 166 | response["response_type"] = "ephemeral" 167 | response["text"] = f"Could not find anything on PyPi matching `{command['text']}`" 168 | 169 | await app.plugins["slack"].api.query(url=methods.CHAT_POST_MESSAGE, data=response) 170 | 171 | 172 | async def snippet(command, app): 173 | """Post a message to the current channel about using snippets and backticks to visually 174 | format code.""" 175 | response = Message() 176 | response["channel"] = command["channel_id"] 177 | response["unfurl_links"] = False 178 | 179 | response["text"] = ( 180 | "Please use the snippet feature, or backticks, when sharing code. \n" 181 | "To include a snippet, click the :paperclip: on the left and hover over " 182 | "`Create new...` then select `Code or text snippet`.\n" 183 | "By wrapping the text/code with backticks (`) you get:\n" 184 | "`text formatted like this`\n" 185 | "By wrapping a multiple line block with three backticks (```) you can get:\n" 186 | ) 187 | 188 | await app.plugins["slack"].api.query(url=methods.CHAT_POST_MESSAGE, data=response) 189 | 190 | response["text"] = ( 191 | "```\n" 192 | "A multiline codeblock\nwhich is great for short snippets!\n" 193 | "```\n" 194 | "For more information on snippets, click " 195 | ".\n" 196 | "For more information on inline code formatting with backticks click " 197 | "." 198 | ) 199 | 200 | await app.plugins["slack"].api.query(url=methods.CHAT_POST_MESSAGE, data=response) 201 | 202 | 203 | async def tell_admin(command, app): 204 | 205 | data = { 206 | "trigger_id": command["trigger_id"], 207 | "dialog": { 208 | "callback_id": "tell_admin", 209 | "title": "Message the admin team", 210 | "elements": [ 211 | { 212 | "label": "Message", 213 | "name": "message", 214 | "type": "textarea", 215 | "value": command["text"], 216 | } 217 | ], 218 | }, 219 | } 220 | 221 | await app.plugins["slack"].api.query(url=methods.DIALOG_OPEN, data=data) 222 | 223 | 224 | async def resources(command, app): 225 | """ 226 | Share resources for new developers getting started with python 227 | """ 228 | slack = app.plugins["slack"] 229 | response = Message() 230 | response["channel"] = command["channel_id"] 231 | response["unfurl_links"] = False 232 | 233 | response["text"] = ( 234 | "Listed below are some great resources to get started on learning python:\n" 235 | "*Books:*\n" 236 | "* \n" 237 | "* \n" 238 | "* \n" 239 | "* \n" 240 | "* \n" 241 | "* \n" 242 | "* \n" 243 | "*Videos:*\n" 244 | "* \n" 245 | "* \n" 246 | "* \n" 247 | "*Online Courses:*\n" 248 | "* \n" 249 | "*Cheat Sheets:*\n" 250 | "* \n" 251 | "*Project Based Learning:*\n" 252 | "* \n\n" 253 | "For the full list of resources see our curated list \n" 254 | ) 255 | 256 | await app.plugins["slack"].api.query(url=methods.CHAT_POST_MESSAGE, data=response) 257 | 258 | 259 | async def xpost(command, app): 260 | slack = app.plugins["slack"] 261 | response = Message() 262 | response["channel"] = command["channel_id"] 263 | response["unfurl_links"] = False 264 | 265 | response["text"] = ( 266 | "Please don't cross post the same question in multiple channels. " 267 | "There may not be a lot of folks online or active at any given moment, " 268 | "but please be patient. If your question fits in multiple channels, please " 269 | "pick the one you think is most suitable and post it there." 270 | ) 271 | 272 | await slack.api.query(url=methods.CHAT_POST_MESSAGE, data=response) 273 | -------------------------------------------------------------------------------- /sirbot_pyslackers/endpoints/slack/events.py: -------------------------------------------------------------------------------- 1 | import json 2 | import asyncio 3 | import logging 4 | 5 | from slack import methods 6 | from slack.events import Message 7 | 8 | from .utils import ADMIN_CHANNEL 9 | 10 | LOG = logging.getLogger(__name__) 11 | 12 | 13 | def create_endpoints(plugin): 14 | plugin.on_event("team_join", team_join, wait=False) 15 | plugin.on_event("pin_added", pin_added) 16 | 17 | 18 | async def team_join(event, app): 19 | await asyncio.sleep(60) 20 | 21 | message = Message() 22 | message["text"] = ( 23 | f"""Welcome to the community <@{event["user"]["id"]}> :tada: !\n""" 24 | """We are glad that you have decided to join us.\n\n""" 25 | """We have documented a few things in the """ 26 | """ to help """ 27 | """you along from the beginning because we are grand believers in the Don't Repeat Yourself """ 28 | """principle, and it just seems so professional!\n\n""" 29 | """If you wish you can tell us a bit about yourself in this channel.\n\n""" 30 | """May your :taco:s be plentiful!""" 31 | ) 32 | 33 | message["channel"] = "introductions" 34 | message["user"] = event["user"]["id"] 35 | 36 | await app.plugins["slack"].api.query(url=methods.CHAT_POST_EPHEMERAL, data=message) 37 | 38 | 39 | async def pin_added(event, app): 40 | 41 | if event["user"] not in app["plugins"]["slack"].admins: 42 | 43 | message = Message() 44 | message["channel"] = ADMIN_CHANNEL 45 | message["attachments"] = [ 46 | { 47 | "fallback": "Pin added notice", 48 | "title": f'Pin added in channel <#{event["channel_id"]}> by <@{event["user"]}>', 49 | "callback_id": "pin_added", 50 | } 51 | ] 52 | 53 | if event["item"]["type"] == "message": 54 | message["attachments"][0]["text"] = event["item"]["message"]["text"] 55 | item_id = event["item"]["message"]["ts"] 56 | elif event["item"]["type"] == "file": 57 | file = await app["plugins"]["slack"].api.query( 58 | url=methods.FILES_INFO, data={"file": event["item"]["file_id"]} 59 | ) 60 | message["attachments"][0]["text"] = f'File: {file["file"]["title"]}' 61 | item_id = event["item"]["file_id"] 62 | elif event["item"]["type"] == "file_comment": 63 | message["attachments"][0]["text"] = event["item"]["comment"]["comment"] 64 | item_id = event["item"]["comment"]["id"] 65 | else: 66 | message["attachments"][0]["text"] = "Unknown pin type" 67 | await app.plugins["slack"].api.query( 68 | url=methods.CHAT_POST_MESSAGE, data=message 69 | ) 70 | return 71 | 72 | message["attachments"][0]["actions"] = [ 73 | { 74 | "name": "validate", 75 | "text": "Validate", 76 | "style": "primary", 77 | "type": "button", 78 | }, 79 | { 80 | "name": "revert", 81 | "text": "Revert", 82 | "style": "danger", 83 | "value": json.dumps( 84 | { 85 | "channel": event["channel_id"], 86 | "item_type": event["item"]["type"], 87 | "item_id": item_id, 88 | } 89 | ), 90 | "type": "button", 91 | }, 92 | ] 93 | 94 | await app.plugins["slack"].api.query( 95 | url=methods.CHAT_POST_MESSAGE, data=message 96 | ) 97 | -------------------------------------------------------------------------------- /sirbot_pyslackers/endpoints/slack/messages.py: -------------------------------------------------------------------------------- 1 | import re 2 | import json 3 | import pprint 4 | import logging 5 | import datetime 6 | 7 | from slack import methods 8 | from aiohttp import ClientResponseError 9 | from slack.events import Message 10 | from slack.exceptions import SlackAPIError 11 | from asyncpg.exceptions import UniqueViolationError 12 | 13 | from .utils import ADMIN_CHANNEL, HELP_FIELD_DESCRIPTIONS 14 | 15 | LOG = logging.getLogger(__name__) 16 | STOCK_REGEX = re.compile( 17 | r"\b(?P[cs])\$(?P\^?[A-Z.]{1,5})(?:-(?P[A-Z]{3}))?\b" 18 | ) 19 | TELL_REGEX = re.compile("tell (<(#|@)(?P[A-Z0-9]*)(|.*)?>) (?P.*)") 20 | FIAT_CURRENCY = { 21 | "USD": "$", 22 | "GBP": "£", 23 | "EUR": "€", 24 | } 25 | 26 | 27 | def create_endpoints(plugin): 28 | plugin.on_message("hello", hello, flags=re.IGNORECASE, mention=True) 29 | plugin.on_message("^tell", tell, flags=re.IGNORECASE, mention=True, admin=True) 30 | plugin.on_message(".*", mention, flags=re.IGNORECASE, mention=True) 31 | plugin.on_message(".*", save_in_database, wait=False) 32 | plugin.on_message(".*", channel_topic, subtype="channel_topic") 33 | plugin.on_message( 34 | "^inspect", inspect, flags=re.IGNORECASE, mention=True, admin=True 35 | ) 36 | plugin.on_message("^help", help_message, flags=re.IGNORECASE, mention=True) 37 | # stock tickers are 1-5 capital characters, with a dot allowed. To keep 38 | # this from triggering with random text we require a leading '$' 39 | plugin.on_message(STOCK_REGEX.pattern, stock_quote, wait=False) 40 | plugin.on_message( 41 | "^channels", channels, flags=re.IGNORECASE, mention=True, admin=True 42 | ) 43 | plugin.on_message("^cleanup", cleanup, flags=re.IGNORECASE, mention=True) 44 | 45 | 46 | async def stock_quote(message, app): 47 | stocks = app["plugins"]["stocks"] 48 | match = STOCK_REGEX.search(message.get("text", "")) 49 | if not match: 50 | return 51 | 52 | asset_class, symbol, currency = ( 53 | match.group("asset_class"), 54 | match.group("symbol"), 55 | match.group("currency"), 56 | ) 57 | 58 | currency = ( 59 | match.group("currency") if match.group("currency") in FIAT_CURRENCY else "USD" 60 | ) 61 | currency_symbol = FIAT_CURRENCY[currency] 62 | LOG.debug( 63 | "Fetching stock quotes for symbol %s in asset class %s", symbol, asset_class 64 | ) 65 | 66 | if asset_class == "c": 67 | LOG.debug( 68 | f"Fetching a crypto quote, setting {currency} as the pair's quote price." 69 | ) 70 | symbol = f"{symbol}-{currency}" 71 | 72 | response = message.response() 73 | try: 74 | quote = await stocks.price(symbol) 75 | LOG.debug("Quote from API: %s", quote) 76 | except ClientResponseError as e: 77 | if e.status == 404: 78 | response["text"] = f"Unable to find ticker {symbol}" 79 | else: 80 | LOG.exception("Error retrieving stock quotes.") 81 | response["text"] = "Unable to retrieve quotes right now." 82 | else: 83 | if quote is None: 84 | response["text"] = f"Unable to find ticker '{symbol}'" 85 | else: 86 | color = "gray" 87 | if quote.change > 0: 88 | color = "good" 89 | elif quote.change < 0: 90 | color = "danger" 91 | 92 | response.update( 93 | attachments=[ 94 | { 95 | "color": color, 96 | "title": f"{quote.symbol} ({quote.company}): {currency_symbol}{quote.price:,.4f}", 97 | "title_link": f"https://finance.yahoo.com/quote/{quote.symbol}", 98 | "fields": [ 99 | { 100 | "title": "Change", 101 | "value": f"{currency_symbol}{quote.change:,.4f} ({quote.change_percent:,.4f}%)", 102 | "short": True, 103 | }, 104 | { 105 | "title": "Volume", 106 | "value": f"{quote.volume:,}", 107 | "short": True, 108 | }, 109 | { 110 | "title": "Open", 111 | "value": f"{currency_symbol}{quote.market_open:,.4f}", 112 | "short": True, 113 | }, 114 | { 115 | "title": "Close", 116 | "value": f"{currency_symbol}{quote.market_close:,.4f}", 117 | "short": True, 118 | }, 119 | { 120 | "title": "Low", 121 | "value": f"{currency_symbol}{quote.low:,.4f}", 122 | "short": True, 123 | }, 124 | { 125 | "title": "High", 126 | "value": f"{currency_symbol}{quote.high:,.4f}", 127 | "short": True, 128 | }, 129 | ], 130 | "footer_icon": quote.logo, 131 | "ts": int(quote.time.timestamp()), 132 | } 133 | ] 134 | ) 135 | 136 | await app["plugins"]["slack"].api.query( 137 | url=methods.CHAT_POST_MESSAGE, data=response 138 | ) 139 | 140 | 141 | async def hello(message, app): 142 | response = message.response() 143 | response["text"] = "Hello <@{user}>".format(user=message["user"]) 144 | await app["plugins"]["slack"].api.query( 145 | url=methods.CHAT_POST_MESSAGE, data=response 146 | ) 147 | 148 | 149 | async def help_message(message, app): 150 | response = message.response() 151 | response["text"] = "Sir Bot-a-lot help" 152 | response["attachments"] = [{"color": "good", "fields": HELP_FIELD_DESCRIPTIONS}] 153 | 154 | await app["plugins"]["slack"].api.query( 155 | url=methods.CHAT_POST_MESSAGE, data=response 156 | ) 157 | 158 | 159 | async def tell(message, app): 160 | match = TELL_REGEX.match(message["text"]) 161 | response = message.response() 162 | 163 | if match: 164 | to_id = match.group("to_id") 165 | msg = match.group("msg") 166 | 167 | if to_id.startswith(("C", "U")): 168 | response["text"] = msg 169 | response["channel"] = to_id 170 | else: 171 | response["text"] = "Sorry I can not understand the destination." 172 | else: 173 | response["text"] = "Sorry I can not understand" 174 | 175 | await app["plugins"]["slack"].api.query( 176 | url=methods.CHAT_POST_MESSAGE, data=response 177 | ) 178 | 179 | 180 | async def mention(message, app): 181 | try: 182 | if message["user"] != app["plugins"]["slack"].bot_user_id: 183 | await app["plugins"]["slack"].api.query( 184 | url=methods.REACTIONS_ADD, 185 | data={ 186 | "name": "sirbot", 187 | "channel": message["channel"], 188 | "timestamp": message["ts"], 189 | }, 190 | ) 191 | except SlackAPIError as e: 192 | if e.error != "already_reacted": 193 | raise 194 | 195 | 196 | async def save_in_database(message, app): 197 | if "pg" in app["plugins"]: 198 | LOG.debug('Saving message "%s" to database.', message["ts"]) 199 | 200 | if message["ts"]: # We sometimes receive message without a timestamp. See #45 201 | try: 202 | async with app["plugins"]["pg"].connection() as pg_con: 203 | await pg_con.execute( 204 | """INSERT INTO slack.messages (id, text, "user", channel, raw, time) 205 | VALUES ($1, $2, $3, $4, $5, $6)""", 206 | message["ts"], 207 | message.get("text"), 208 | message.get("user"), 209 | message.get("channel"), 210 | dict(message), 211 | datetime.datetime.fromtimestamp( 212 | int(message["ts"].split(".")[0]) 213 | ), 214 | ) 215 | except UniqueViolationError: 216 | LOG.debug('Message "%s" already in database.', message["ts"]) 217 | 218 | 219 | async def channel_topic(message, app): 220 | 221 | if ( 222 | message["user"] not in app["plugins"]["slack"].admins 223 | and message["user"] != app["plugins"]["slack"].bot_user_id 224 | ): 225 | 226 | async with app["plugins"]["pg"].connection() as pg_con: 227 | channel = await pg_con.fetchrow( 228 | """SELECT raw FROM slack.channels WHERE id = $1""", message["channel"] 229 | ) 230 | LOG.debug(channel) 231 | if channel: 232 | old_topic = channel["raw"]["topic"]["value"] 233 | else: 234 | old_topic = "Original topic not found" 235 | 236 | response = Message() 237 | response["channel"] = ADMIN_CHANNEL 238 | response["attachments"] = [ 239 | { 240 | "fallback": "Channel topic changed notice: old topic", 241 | "title": f'<@{message["user"]}> changed <#{message["channel"]}> topic.', 242 | "fields": [ 243 | {"title": "Previous topic", "value": old_topic}, 244 | {"title": "New topic", "value": message["topic"]}, 245 | ], 246 | } 247 | ] 248 | 249 | if channel: 250 | response["attachments"][0]["callback_id"] = "topic_change" 251 | response["attachments"][0]["actions"] = [ 252 | { 253 | "name": "validate", 254 | "text": "Validate", 255 | "style": "primary", 256 | "type": "button", 257 | }, 258 | { 259 | "name": "revert", 260 | "text": "Revert", 261 | "style": "danger", 262 | "value": json.dumps( 263 | {"channel": message["channel"], "old_topic": old_topic} 264 | ), 265 | "type": "button", 266 | }, 267 | ] 268 | 269 | await app["plugins"]["slack"].api.query( 270 | url=methods.CHAT_POST_MESSAGE, data=response 271 | ) 272 | 273 | 274 | async def inspect(message, app): 275 | if ( 276 | message["channel"] != ADMIN_CHANNEL 277 | or "text" not in message 278 | or not message["text"] 279 | ): 280 | return 281 | 282 | response = message.response() 283 | match = re.search("<@(.*)>", message["text"]) 284 | 285 | if match: 286 | user_id = match.group(1) 287 | 288 | async with app["plugins"]["pg"].connection() as pg_con: 289 | data = await pg_con.fetchrow( 290 | """SELECT raw, join_date FROM slack.users WHERE id = $1""", user_id 291 | ) 292 | 293 | if data: 294 | user = data["raw"] 295 | user["join_date"] = data["join_date"].isoformat() 296 | else: 297 | data = await app["plugins"]["slack"].api.query( 298 | url=methods.USERS_INFO, data={"user": user_id} 299 | ) 300 | user = data["user"] 301 | 302 | response[ 303 | "text" 304 | ] = f"<@{user_id}> profile information \n```{pprint.pformat(user)}```" 305 | else: 306 | response["text"] = f"Sorry I couldn't figure out which user to inspect" 307 | 308 | await app["plugins"]["slack"].api.query( 309 | url=methods.CHAT_POST_MESSAGE, data=response 310 | ) 311 | 312 | 313 | async def channels(message, app): 314 | if message["channel"] == ADMIN_CHANNEL and "text" in message and message["text"]: 315 | async with app["plugins"]["pg"].connection() as pg_con: 316 | rows = await pg_con.fetch( 317 | """with channels as ( 318 | SELECT DISTINCT ON (channels.id) channels.id, 319 | channels.raw ->> 'name' as name, 320 | messages.time, 321 | age(messages.time) as age 322 | FROM slack.channels 323 | LEFT JOIN slack.messages ON messages.channel = slack.channels.id 324 | WHERE (channels.raw ->> 'is_archived')::boolean is FALSE 325 | ORDER BY channels.id, messages.time DESC 326 | ) 327 | SELECT * FROM channels WHERE age > interval '31 days' 328 | """ 329 | ) 330 | 331 | if rows: 332 | text = f"""```{pprint.pformat([dict(row) for row in rows])}```""" 333 | else: 334 | text = f"""There is no channel without messages in the last 31 days""" 335 | 336 | response = message.response() 337 | response["text"] = text 338 | 339 | await app["plugins"]["slack"].api.query( 340 | url=methods.CHAT_POST_MESSAGE, data=response 341 | ) 342 | 343 | 344 | async def cleanup(message, app): 345 | 346 | if ( 347 | message["channel"] != ADMIN_CHANNEL 348 | or "text" not in message 349 | or not message["text"] 350 | ): 351 | return 352 | 353 | response = message.response() 354 | match = re.search("<@(.*)>", message["text"]) 355 | 356 | if match: 357 | user_id = match.group(1) 358 | 359 | async with app["plugins"]["pg"].connection() as pg_con: 360 | messages = await pg_con.fetchrow( 361 | """SELECT count(id) FROM slack.messages WHERE "user" = $1""", user_id 362 | ) 363 | 364 | response["channel"] = ADMIN_CHANNEL 365 | response["attachments"] = [ 366 | { 367 | "fallback": "User cleanup", 368 | "title": f'Confirm cleanup of <@{user_id}> {messages["count"]} messages.', 369 | "callback_id": "user_cleanup", 370 | "actions": [ 371 | { 372 | "name": "cancel", 373 | "text": "Cancel", 374 | "style": "primary", 375 | "type": "button", 376 | }, 377 | { 378 | "name": "confirm", 379 | "text": "Burn baby burn !", 380 | "style": "danger", 381 | "type": "button", 382 | "value": user_id, 383 | }, 384 | ], 385 | } 386 | ] 387 | 388 | await app["plugins"]["slack"].api.query( 389 | url=methods.CHAT_POST_MESSAGE, data=response 390 | ) 391 | -------------------------------------------------------------------------------- /sirbot_pyslackers/endpoints/slack/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | ANNOUCEMENTS_CHANNEL = os.environ.get("SLACK_ANNOUCEMENTS_CHANNEL") or "annoucements" 4 | ADMIN_CHANNEL = os.environ.get("SLACK_ADMIN_CHANNEL") or "G1DRT62UC" 5 | 6 | HELP_FIELD_DESCRIPTIONS = [ 7 | { 8 | "title": "@sir_botalot hello", 9 | "value": f"Say hello to sir_botalot.", 10 | "short": True, 11 | }, 12 | { 13 | "title": "/report", 14 | "value": "Report an offending user to the admin team.", 15 | "short": True, 16 | }, 17 | { 18 | "title": "/gif search terms", 19 | "value": "Search for a gif on giphy.com .", 20 | "short": True, 21 | }, 22 | { 23 | "title": "/pypi search terms", 24 | "value": "Search for packages on pypi.org .", 25 | "short": True, 26 | }, 27 | {"title": "/sponsors", "value": "Referal links from our sponsors.", "short": True}, 28 | { 29 | "title": "/snippet", 30 | "value": "Instruction on creating a slack code snippet.", 31 | "short": True, 32 | }, 33 | { 34 | "title": "/howtoask", 35 | "value": "Prompt and referrals for how to ask a good question", 36 | "short": True, 37 | }, 38 | { 39 | "title": "/justask", 40 | "value": "Prompt to tell just to ask a question", 41 | "short": True, 42 | }, 43 | { 44 | "title": "/resources", 45 | "value": "Share resources for new python developers", 46 | "short": True, 47 | }, 48 | { 49 | "title": "g#user/repo", 50 | "value": "Share the link to that github repo. User default to `pyslackers`.", 51 | }, 52 | { 53 | "title": "s$TICKER", 54 | "value": "Retrieve today's prices for the provided stock ticker.", 55 | }, 56 | { 57 | "title": "s$^INDEX", 58 | "value": "Retrieve today's prices for the provided stock market index (as supported by Yahoo!).", 59 | }, 60 | { 61 | "title": "c$CRYPTO_SYMBOL", 62 | "value": "Retrieve today's value for the provided cryptocurrency.", 63 | }, 64 | ] 65 | -------------------------------------------------------------------------------- /sirbot_pyslackers/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | from .pypi import PypiPlugin # noQa F401 2 | from .stocks import StocksPlugin # noQa F401 3 | -------------------------------------------------------------------------------- /sirbot_pyslackers/plugins/pypi.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from operator import itemgetter 3 | 4 | from distance import levenshtein 5 | from aiohttp_xmlrpc.client import ServerProxy 6 | 7 | LOG = logging.getLogger(__name__) 8 | 9 | 10 | class PypiPlugin: 11 | __name__ = "pypi" 12 | SEARCH_URL = "https://pypi.python.org/pypi" 13 | ROOT_URL = "https://pypi.org" 14 | PROJECT_URL = ROOT_URL + "/project/{0}" 15 | RESULT_URL = ROOT_URL + "/search/?q={0}" 16 | 17 | def __init__(self): 18 | self.api = None 19 | 20 | def load(self, sirbot): 21 | self.api = ServerProxy(self.SEARCH_URL, client=sirbot.http_session) 22 | 23 | async def search(self, search): 24 | results = await self.api.search({"name": search}) 25 | for item in results: 26 | item["distance"] = levenshtein(str(search), item["name"]) 27 | results.sort(key=itemgetter("distance")) 28 | return results 29 | -------------------------------------------------------------------------------- /sirbot_pyslackers/plugins/stocks.py: -------------------------------------------------------------------------------- 1 | import os 2 | import datetime 3 | import dataclasses 4 | from typing import Optional 5 | from decimal import Decimal 6 | 7 | 8 | @dataclasses.dataclass(frozen=True) 9 | class StockQuote: 10 | symbol: str 11 | company: str 12 | price: Decimal 13 | change: Decimal 14 | change_percent: Decimal 15 | market_open: Decimal 16 | market_close: Decimal 17 | high: Decimal 18 | low: Decimal 19 | volume: Decimal 20 | time: datetime.datetime 21 | logo: Optional[str] = None 22 | 23 | 24 | class StocksPlugin: 25 | __name__ = "stocks" 26 | 27 | def __init__(self): 28 | self.session = None # set lazily on plugin load 29 | 30 | def load(self, sirbot): 31 | self.session = sirbot.http_session 32 | 33 | async def price(self, symbol: str) -> StockQuote: 34 | async with self.session.get( 35 | "https://query1.finance.yahoo.com/v7/finance/quote", 36 | params={"symbols": symbol}, 37 | ) as r: 38 | r.raise_for_status() 39 | body = (await r.json())["quoteResponse"]["result"] 40 | if len(body) < 1: 41 | return None 42 | 43 | quote = body[0] 44 | 45 | return StockQuote( 46 | symbol=quote["symbol"], 47 | company=quote.get("longName", quote.get("shortName", "")), 48 | price=Decimal(quote.get("regularMarketPrice", 0)), 49 | change=Decimal(quote.get("regularMarketChange", 0)), 50 | change_percent=Decimal(quote.get("regularMarketChangePercent", 0)), 51 | market_open=Decimal(quote.get("regularMarketOpen", 0)), 52 | market_close=Decimal(quote.get("regularMarketPreviousClose", 0)), 53 | high=Decimal(quote.get("regularMarketDayHigh", 0)), 54 | low=Decimal(quote.get("regularMarketDayLow", 0)), 55 | volume=Decimal(quote.get("regularMarketVolume", 0)), 56 | time=datetime.datetime.fromtimestamp(quote.get("regularMarketTime", 0)), 57 | logo=quote.get("coinImageUrl"), 58 | ) 59 | -------------------------------------------------------------------------------- /sql/0.0.10.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS slack.recordings; 2 | 3 | CREATE TABLE slack.recordings ( 4 | id SERIAL PRIMARY KEY, 5 | start TIMESTAMP WITH TIME ZONE NOT NULL, 6 | "end" timestamp WITH TIME ZONE NOT NULL, 7 | "user" TEXT NOT NULL, 8 | channel TEXT NOT NULL, 9 | comment TEXT, 10 | created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() 11 | ); 12 | -------------------------------------------------------------------------------- /sql/0.0.11.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS slack.recordings; 2 | 3 | CREATE TABLE slack.recordings ( 4 | id SERIAL PRIMARY KEY, 5 | start TIMESTAMP WITH TIME ZONE NOT NULL, 6 | "end" timestamp WITH TIME ZONE NOT NULL, 7 | "user" TEXT NOT NULL, 8 | channel TEXT NOT NULL, 9 | title TEXT NOT NULL, 10 | comment TEXT, 11 | created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() 12 | ); 13 | -------------------------------------------------------------------------------- /sql/0.0.2.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE slack.channels ( 2 | id TEXT PRIMARY KEY NOT NULL, 3 | raw JSONB 4 | ); 5 | -------------------------------------------------------------------------------- /sql/0.0.3.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE slack.messages ADD COLUMN time TIMESTAMP; 2 | UPDATE slack.messages SET time = to_timestamp(left(id, 10)::INT); 3 | -------------------------------------------------------------------------------- /sql/0.0.4.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE slack.users ( 2 | id TEXT PRIMARY KEY NOT NULL, 3 | name TEXT, 4 | deleted BOOLEAN DEFAULT FALSE, 5 | admin BOOLEAN DEFAULT FALSE, 6 | bot BOOLEAN DEFAULT FALSE, 7 | raw JSONB 8 | ); 9 | -------------------------------------------------------------------------------- /sql/0.0.5.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE slack.recordings ( 2 | id SERIAL PRIMARY KEY, 3 | start TEXT NOT NULL, 4 | "end" TEXT DEFAULT NULL, 5 | "user" TEXT, 6 | "channel" TEXT, 7 | created TIMESTAMP WITH TIME ZONE DEFAULT now() 8 | ) 9 | -------------------------------------------------------------------------------- /sql/0.0.6.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE slack.recordings ADD COLUMN commit BOOLEAN DEFAULT FALSE; 2 | -------------------------------------------------------------------------------- /sql/0.0.7.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE slack.users ADD COLUMN join_date DATE DEFAULT now(); 2 | -------------------------------------------------------------------------------- /sql/0.0.8.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE slack.reports ( 2 | id SERIAL PRIMARY KEY, 3 | timestamp TIMESTAMP WITH TIME ZONE DEFAULT now(), 4 | "user" TEXT NOT NULL, 5 | channel TEXT, 6 | comment TEXT NOT NULL 7 | ) 8 | -------------------------------------------------------------------------------- /sql/0.0.9.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE slack.reports ADD COLUMN by TEXT NOT NULL; -------------------------------------------------------------------------------- /sql/init.sql: -------------------------------------------------------------------------------- 1 | CREATE SCHEMA slack; 2 | 3 | CREATE TABLE slack.messages ( 4 | id TEXT PRIMARY KEY NOT NULL, 5 | text TEXT, 6 | "user" TEXT, 7 | channel TEXT, 8 | raw JSONB 9 | ); 10 | -------------------------------------------------------------------------------- /tests/endpoints/slack/test_messages.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sirbot_pyslackers.endpoints.slack import messages 3 | 4 | 5 | @pytest.mark.parametrize( 6 | ["text", "result"], 7 | [ 8 | ("check out s$TSLA", {"symbol": "TSLA", "asset_class": "s", "currency": None}), 9 | ( 10 | "what do you think about s$GOOG?", 11 | {"symbol": "GOOG", "asset_class": "s", "currency": None}, 12 | ), 13 | ( 14 | "Hey, the s$^DJI is up today!", 15 | {"symbol": "^DJI", "asset_class": "s", "currency": None}, 16 | ), 17 | ( 18 | "Another wild day for c$BTC, huh?", 19 | {"symbol": "BTC", "asset_class": "c", "currency": None}, 20 | ), 21 | ( 22 | "Show me c$BTC-EUR in Euros!!", 23 | {"symbol": "BTC", "asset_class": "c", "currency": "EUR"}, 24 | ), 25 | ], 26 | ) 27 | def test_stock_regex(text, result): 28 | match = messages.STOCK_REGEX.search(text) 29 | if result is None: 30 | assert match is None 31 | else: 32 | assert match.groupdict() == result 33 | --------------------------------------------------------------------------------