├── .envrc ├── .gitignore ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── PULL_REQUEST_TEMPLATE.md ├── Pipfile ├── README.md ├── artwork ├── Github Social.sketch ├── tangerine.sketch ├── tangerine.svg └── tangerine@2x.png ├── instance └── config.yml.sample ├── samples ├── example_bot.py ├── image_me.py ├── reddit_rando.py └── tangerinebot.conf.sample ├── setup.cfg ├── setup.py └── tangerine ├── __init__.py ├── bot.py └── scheduler.py /.envrc: -------------------------------------------------------------------------------- 1 | layout pipenv 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | config.yml 10 | dist 11 | build 12 | eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | develop-eggs 18 | .installed.cfg 19 | lib 20 | lib64 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | .cache 29 | nosetests.xml 30 | 31 | # Translations 32 | *.mo 33 | 34 | # Mr Developer 35 | .mr.developer.cfg 36 | .project 37 | .pydevproject 38 | 39 | # Sphinx 40 | docs/_build 41 | 42 | # Coverage 43 | htmlcov/ 44 | 45 | # TextMate 46 | .tm_properties 47 | 48 | # Local env 49 | .envs 50 | 51 | config.yaml 52 | tangerinebot.conf 53 | .coveralls.yml 54 | devbot.py 55 | templates/ 56 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: 'setup.cfg' 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v1.2.1-1 5 | hooks: 6 | - id: pretty-format-json 7 | name: Pretty format JSON 8 | args: 9 | - --no-sort-keys 10 | 11 | - id: trailing-whitespace 12 | name: Fix trailing whitespace 13 | 14 | - id: end-of-file-fixer 15 | name: Fix missing EOF 16 | 17 | - id: check-executables-have-shebangs 18 | name: Check exeutables for shebangs 19 | 20 | - id: check-added-large-files 21 | name: Check for any large files added 22 | 23 | - id: check-merge-conflict 24 | name: Check for merge conflict fragments 25 | 26 | - id: check-case-conflict 27 | name: Check for filesystem character case conflicts 28 | 29 | - id: detect-private-key 30 | name: Check for cleartext private keys stored 31 | 32 | - id: flake8 33 | name: Check for Python style guideline violations 34 | 35 | - id: check-yaml 36 | name: Validate YAML 37 | 38 | id: fix-encoding-pragma 39 | name: Check for utf-8 encoding 40 | 41 | - id: check-json 42 | name: Validate JSON 43 | 44 | - id: check-ast 45 | name: Check Python abstract syntax tree 46 | 47 | - id: double-quote-string-fixer 48 | name: Replace Python " with ' 49 | 50 | - id: autopep8-wrapper 51 | args: 52 | - --in-place 53 | - --aggressive 54 | - --aggressive 55 | - --experimental 56 | name: Pretty format Python 57 | 58 | - repo: https://github.com/asottile/reorder_python_imports 59 | sha: v1.1.1 60 | hooks: 61 | - id: reorder-python-imports 62 | name: Reorder Python imports 63 | 64 | - repo: https://github.com/asottile/add-trailing-comma 65 | sha: v0.7.0 66 | hooks: 67 | - id: add-trailing-comma 68 | name: Fix Python trailing commas 69 | args: 70 | - --py36-plus 71 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at nficano@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We love contributions from everyone. By participating in this project, you agree to abide by our code of conduct. 4 | 5 | ## Contributing Code 6 | 7 | 1. Fork the repo 8 | 2. Install the dev dependencies and setup the pre-commit hook. 9 | 10 | ```bash 11 | $ pipenv install --dev 12 | $ pre-commit install 13 | ``` 14 | 15 | 3. Push to your fork. 16 | 4. Submit a pull request. 17 | 18 | Others will give constructive feedback. This is a time for discussion and improvements, and making the necessary 19 | changes will be required before we can merge the contribution. 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Nick Ficano 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sub-license, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice, and every other copyright notice found in this 11 | software, and all the attributions in every file, and this permission notice 12 | shall be included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | help: 2 | @echo "clean - remove all build, test, coverage and Python artifacts" 3 | @echo "clean-build - remove build artifacts" 4 | @echo "clean-pyc - remove Python file artifacts" 5 | @echo "install - install the package to the active Python's site-packages" 6 | 7 | ci: 8 | pip install pipenv 9 | pipenv install --dev --skip-lock 10 | pipenv run flake8 11 | pipenv run pytest --cov-report term-missing --cov=tangerine 12 | 13 | clean: clean-build clean-pyc 14 | 15 | clean-build: 16 | rm -fr build/ 17 | rm -fr dist/ 18 | rm -fr .eggs/ 19 | find . -name '*.egg-info' -exec rm -fr {} + 20 | find . -name '*.egg' -exec rm -f {} + 21 | find . -name '*.DS_Store' -exec rm -f {} + 22 | 23 | clean-pyc: 24 | find . -name '*.pyc' -exec rm -f {} + 25 | find . -name '*.pyo' -exec rm -f {} + 26 | find . -name '*~' -exec rm -f {} + 27 | find . -name '__pycache__' -exec rm -fr {} + 28 | 29 | install: clean 30 | python setup.py install 31 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Status 2 | **READY/IN DEVELOPMENT/HOLD** 3 | 4 | ## Description 5 | A few sentences describing the overall goals of the pull request's commits. 6 | 7 | ## Related PRs 8 | List related PRs against other branches: 9 | 10 | branch | PR 11 | ------ | ------ 12 | other_pr_production | [link]() 13 | other_pr_master | [link]() 14 | 15 | 16 | ## Todos 17 | - [ ] Tests 18 | - [ ] Documentation 19 | 20 | 21 | ## Deploy Notes 22 | Notes regarding deployment the contained body of work. These should note any 23 | db migrations, etc. 24 | 25 | ## Steps to Test or Reproduce 26 | Outline the steps to test or reproduce the PR here. 27 | 28 | ```sh 29 | git pull --prune 30 | git checkout 31 | pytest 32 | ``` 33 | 34 | 1. 35 | 36 | ## Impacted Areas in Application 37 | List general components of the application that this PR will affect: 38 | 39 | * 40 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | crontab = "*" 8 | "jinja2" = "*" 9 | python-box = "*" 10 | pyyaml = "*" 11 | slackclient = "==2.9.3" 12 | 13 | [dev-packages] 14 | bumpversion = "*" 15 | twine = "*" 16 | pre-commit = "*" 17 | 18 | [requires] 19 | python_version = "3.7" 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Tangerine logo 3 |

4 | pypi 5 | 6 |
7 |

8 | 9 | A Flask inspired, decorator based API wrapper for Python-Slack. 10 | 11 | ## About 12 | 13 | Tangerine is a lightweight Slackbot framework that abstracts away all the boilerplate code required to write bots, allowing you to focus on the problem at hand. 14 | 15 | ## Installation 16 | 17 | 1. To install tangerine, simply use pipenv (or pip, of course): 18 | 19 | ```bash 20 | $ pipenv install slack-tangerine 21 | ``` 22 | 23 | 2. Create a new file with the following contents: 24 | 25 | ```python 26 | # mybot.py 27 | from tangerine import Tangerine 28 | tangerine = Tangerine("xoxb-1234567890-replace-this-with-token-from-slack") 29 | 30 | 31 | @tangerine.listen_for('morning') 32 | def morning(user, message): 33 | return "mornin' @{user.username}" 34 | 35 | if __name__ == '__main__': 36 | tangerine.run() 37 | ``` 38 | 39 | 3. Now try running it, run the following command then say "morning" in Slack. 40 | 41 | ```bash 42 | python mybot.py 43 | ``` 44 | 45 | ## Usage 46 | To start your project, you'll first need to import tangerine by adding from tangerine import Tangerine to the top of your file. 47 | 48 | Next you'll need to create an instance of Tangerine and configure your Slack token. This can be done using a yaml config file or passing it explicitly to the initialization. 49 | 50 | ```python 51 | # Option 1: YAML config: 52 | import os 53 | from tangerine import Tangerine 54 | 55 | path = os.path.dirname(os.path.abspath(__file__)) 56 | path_to_yaml = os.path.join(path, 'config.yaml') 57 | tangerine = Tangerine.config_from_yaml(path_to_yaml) 58 | 59 | # Option 2: Hardcoded slack token 60 | from tangerine import Tangerine 61 | tangerine = Tangerine("xoxb-1234567890-replace-this-with-token-from-slack") 62 | ``` 63 | 64 | Now its time to write your response functions, these functions get wrapped with the listen_for decorator, which registers a pattern to watch the slack conversation for and which python method should handle it once its said. 65 | 66 | In the following example, the method is setup to listen for the word "cookies". Notice that the decorator passes two arguments to the function, first the user object which contains information about the user who triggered the event (in this case the Slack user who said the word cookies) and message, which is a string of the complete message. 67 | 68 | ```python 69 | @tangerine.listen_for('cookies') 70 | def cookies(user, message): 71 | # do something when someone say's "cookies" here. 72 | ``` 73 | 74 | ## Crontab 75 | 76 | Sometimes you'll run into situations where you want Slack messages to be sent periodically rather than in direct response to a keyword, for this Tangerine ships with a single-threaded Python implementation of Cron. 77 | 78 | Let's pretend we want to send a message to everyone in a channel every five minutes, simply add the following to your mybot.py file: 79 | 80 | ```python 81 | @tangerine.cron('*/5 * * * *') 82 | def some_task(): 83 | tangerine.speak("Hay Ride!", "#general") 84 | ``` 85 | -------------------------------------------------------------------------------- /artwork/Github Social.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nficano/tangerine/85c131b787ea7f7215c9ca9e796b2a775f06f19e/artwork/Github Social.sketch -------------------------------------------------------------------------------- /artwork/tangerine.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nficano/tangerine/85c131b787ea7f7215c9ca9e796b2a775f06f19e/artwork/tangerine.sketch -------------------------------------------------------------------------------- /artwork/tangerine.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Artboard 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /artwork/tangerine@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nficano/tangerine/85c131b787ea7f7215c9ca9e796b2a775f06f19e/artwork/tangerine@2x.png -------------------------------------------------------------------------------- /instance/config.yml.sample: -------------------------------------------------------------------------------- 1 | tangerine: 2 | channel: "#general" 3 | auth_token: "xxx" 4 | -------------------------------------------------------------------------------- /samples/example_bot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | 5 | from tangerine import Tangerine 6 | 7 | path = os.path.dirname(os.path.abspath(__file__)) 8 | path_to_yaml = os.path.join(path, 'config.yaml') 9 | tangerine = Tangerine.config_from_yaml(path_to_yaml) 10 | 11 | 12 | @tangerine.listen_for('cookies') 13 | def cookies(user, message): 14 | return 'I *LOVE* COOOOOOOOKIES!!!!' 15 | 16 | 17 | @tangerine.listen_for('morning') 18 | def morning(user, message): 19 | # make sure message is "morning" and doesn't just contain it. 20 | if message.strip() == 'morning': 21 | return "mornin' @{user.username}" 22 | 23 | 24 | if __name__ == '__main__': 25 | tangerine.run() 26 | -------------------------------------------------------------------------------- /samples/image_me.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | google image me 4 | ~~~~~~~~~~~~~~~ 5 | 6 | image me -- ask me for an image of anything 7 | 8 | # Sample usage: 9 | from tangerine import Tangerine 10 | import image_me as google 11 | 12 | tangerine = Tangerine('xoxb-your-key-here') 13 | 14 | @tangerine.listen_for('image me') 15 | def image_me(user, message): 16 | query = message.replace('image me', '') 17 | return google.random_google_image_search(query) 18 | 19 | if __name__ == '__main__': 20 | tangerine.run() 21 | """ 22 | import random 23 | 24 | import requests 25 | 26 | URL = 'https://www.googleapis.com/customsearch/v1' 27 | CSE_KEY = '' 28 | CSE_ID = '' 29 | 30 | 31 | def google_image_search(query): 32 | resp = requests.get( 33 | URL, params={ 34 | 'v': '1.0', 35 | 'searchType': 36 | 'image', 'q': query.strip(), 37 | 'cx': CSE_ID, 38 | 'key': CSE_KEY, 39 | }, 40 | ) 41 | if not resp.ok: 42 | yield 43 | for i in resp.json().get('items', []): 44 | yield i.get('link') 45 | 46 | 47 | def random_google_image_search(query): 48 | if query: 49 | return random.choice([r for r in google_image_search(query)]) 50 | 51 | 52 | if __name__ == '__main__': 53 | print(random_google_image_search('nick ficano')) 54 | -------------------------------------------------------------------------------- /samples/reddit_rando.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | reddit rando (comment) 4 | ~~~~~~~~~~~~~~~~~~~~~~ 5 | 6 | Get a random comment from a given subreddit. 7 | 8 | # Sample usage: 9 | 10 | import reddit_rando as reddit 11 | from tangerine import Tangerine 12 | 13 | tangerine = Tangerine('xoxb-your-key-here') 14 | 15 | @tangerine.listen_for('rando comment') 16 | def rando(user, message): 17 | subreddit = message.replace('rando comment', '').strip() 18 | ok, comment = reddit.get_random_comment_in_subreddit(subreddit) 19 | if not ok: 20 | return 'oh hamburgers. {0}'.format(comment) 21 | return comment 22 | 23 | if __name__ == '__main__': 24 | tangerine.run() 25 | """ 26 | import random 27 | 28 | import requests 29 | 30 | 31 | def get_posts_in_subreddit(subreddit): 32 | url = 'https://www.reddit.com/r/{0}/.json'.format(subreddit) 33 | ok, res = reddit_request(url) 34 | if not ok: 35 | yield 36 | for child in res.get('data', {}).get('children', []): 37 | if child['data']['num_comments'] == 0: 38 | continue 39 | permalink = child.get('data', {}).get('permalink') 40 | url = 'https://www.reddit.com{0}.json'.format(permalink) 41 | yield url 42 | 43 | 44 | def get_comments_in_post(url): 45 | ok, res = reddit_request(url) 46 | if not ok: 47 | yield 48 | for comment in res[1].get('data', {}).get('children', []): 49 | body = comment.get('data', {}).get('body', '').strip() 50 | if len(body) < 800 and 'http' not in body: 51 | yield body 52 | 53 | 54 | def get_random_comment_in_subreddit(subreddit): 55 | urls = [url for url in get_posts_in_subreddit(subreddit)] 56 | if not urls: 57 | return False, 'no posts.' 58 | url = random.choice(urls) 59 | comments = [c for c in get_comments_in_post(url)] 60 | if not comments: 61 | return get_random_comment_in_subreddit(subreddit) 62 | return True, random.choice(comments) 63 | 64 | 65 | def reddit_request(url): 66 | resp = requests.get( 67 | url, headers={ 68 | 'User-agent': 'darwin:slackbot:v1.2.3 (by /u/nficano)', 69 | }, 70 | ) 71 | if not resp.ok: 72 | return False, resp 73 | return True, resp.json() 74 | 75 | 76 | if __name__ == '__main__': 77 | subreddit = 'mildlyinteresting' 78 | ok, comment = get_random_comment_in_subreddit(subreddit) 79 | if ok: 80 | print(comment) 81 | -------------------------------------------------------------------------------- /samples/tangerinebot.conf.sample: -------------------------------------------------------------------------------- 1 | [program:tangerinebot] 2 | command=/home/ubuntu/.virtualenvs/tangerine/bin/python /home/ubuntu/tangerine/tangerine.py 3 | autostart=true 4 | autorestart=true 5 | stopsignal=QUIT 6 | stopasgroup=true 7 | killasgroup=true 8 | stdout_logfile=/var/log/tangerinebot/tangerinebot.log 9 | exitcodes=0 10 | stopwaitsecs = 60 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | commit = True 3 | tag = True 4 | current_version = 5.0.2 5 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\-(?P[a-z]+))? 6 | serialize = 7 | {major}.{minor}.{patch} 8 | 9 | [metadata] 10 | description-file = README.md 11 | 12 | [bumpversion:file:setup.py] 13 | 14 | [bumpversion:file:tangerine/__init__.py] 15 | 16 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """This module contains setup instructions for tangerine.""" 4 | import codecs 5 | import os 6 | import sys 7 | from shutil import rmtree 8 | 9 | from setuptools import Command 10 | from setuptools import setup 11 | 12 | here = os.path.abspath(os.path.dirname(__file__)) 13 | 14 | with codecs.open(os.path.join(here, 'README.md'), encoding='utf-8') as fh: 15 | long_description = '\n' + fh.read() 16 | 17 | 18 | class UploadCommand(Command): 19 | """Support setup.py publish.""" 20 | 21 | description = 'Build and publish the package.' 22 | user_options = [] 23 | 24 | @staticmethod 25 | def status(s): 26 | """Prints things in bold.""" 27 | print('\033[1m{0}\033[0m'.format(s)) 28 | 29 | def initialize_options(self): 30 | pass 31 | 32 | def finalize_options(self): 33 | pass 34 | 35 | def run(self): 36 | try: 37 | self.status('Removing previous builds ...') 38 | rmtree(os.path.join(here, 'dist')) 39 | except Exception: 40 | pass 41 | self.status('Building Source distribution ...') 42 | os.system('{0} setup.py sdist bdist_wheel'.format(sys.executable)) 43 | self.status('Uploading the package to PyPI via Twine ...') 44 | os.system('twine upload dist/*') 45 | sys.exit() 46 | 47 | 48 | setup( 49 | name='slack-tangerine', 50 | version='5.0.2', 51 | author='Nick Ficano', 52 | author_email='nficano@gmail.com', 53 | packages=['tangerine'], 54 | url='https://github.com/nficano/tangerine', 55 | license='MIT', 56 | package_data={ 57 | '': ['LICENSE'], 58 | }, 59 | classifiers=[ 60 | 'License :: OSI Approved :: MIT License', 61 | 'Programming Language :: Python', 62 | 'Programming Language :: Python :: 2.7', 63 | 'Programming Language :: Python :: 3', 64 | 'Programming Language :: Python :: 3.4', 65 | 'Programming Language :: Python :: 3.5', 66 | 'Programming Language :: Python :: 3.6', 67 | 'Programming Language :: Python :: 3.7', 68 | 'Programming Language :: Python :: Implementation :: CPython', 69 | 'Programming Language :: Python :: Implementation :: PyPy', 70 | ], 71 | description=( 72 | 'tangerine is a lightweight Slackbot framework that abstracts away ' 73 | 'all the boilerplate code required to write bots, allowing you to ' 74 | 'focus on the problem at hand.' 75 | ), 76 | include_package_data=True, 77 | long_description_content_type='text/markdown', 78 | long_description=long_description, 79 | zip_safe=True, 80 | cmdclass={'upload': UploadCommand}, 81 | ) 82 | -------------------------------------------------------------------------------- /tangerine/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # flake8: noqa 4 | __title__ = 'tangerine' 5 | __version__ = '5.0.2' 6 | __author__ = 'Nick Ficano' 7 | __license__ = 'MIT License' 8 | __copyright__ = 'Copyright 2019 Nick Ficano' 9 | 10 | from .bot import Tangerine 11 | 12 | 13 | # Set default logging handler to avoid "No handler found" warnings. 14 | import logging 15 | try: # Python 2.7+ 16 | from logging import NullHandler 17 | except ImportError: 18 | class NullHandler(logging.Handler): 19 | def emit(self, record): 20 | pass 21 | 22 | logging.getLogger(__name__).addHandler(NullHandler()) 23 | -------------------------------------------------------------------------------- /tangerine/bot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import datetime 4 | import inspect 5 | import logging 6 | import os 7 | import sys 8 | import time 9 | from collections import namedtuple 10 | 11 | import six 12 | import yaml 13 | from box import Box 14 | from jinja2 import Environment 15 | from jinja2 import FileSystemLoader 16 | from jinja2 import select_autoescape 17 | from slackclient import SlackClient 18 | 19 | from .scheduler import Task 20 | 21 | log = logging.getLogger(__name__) 22 | 23 | Listener = namedtuple( 24 | 'Listener', ( 25 | 'rule', 26 | 'view_func', 27 | 'trigger', 28 | 'doc', 29 | 'options', 30 | ), 31 | ) 32 | 33 | 34 | class Tangerine(object): 35 | jinja_environment = Environment 36 | 37 | def __init__(self, slack_token=None, settings=None): 38 | settings = settings or {} 39 | settings.setdefault('tangerine', {}) 40 | settings['tangerine'].setdefault('sleep', 0.5) 41 | settings['tangerine'].setdefault('template_folder', 'templates') 42 | 43 | self.settings = Box(settings, frozen_box=True, default_box=True) 44 | self.listeners = [] 45 | self.scheduled_tasks = [] 46 | 47 | self.client = SlackClient( 48 | slack_token or self.settings.tangerine.auth_token, 49 | ) 50 | self.sleep = self.settings.tangerine.sleep 51 | 52 | @classmethod 53 | def config_from_yaml(cls, path_to_yaml): 54 | with open(path_to_yaml, 'r') as ymlfile: 55 | settings = yaml.load(ymlfile) 56 | log.info('settings from %s loaded successfully', path_to_yaml) 57 | return cls(settings=settings) 58 | 59 | def _verify_rule(self, supplied_rule): 60 | """Rules must be callable with (user, message) in the signature. 61 | Strings are automatically converted to callables that match. 62 | """ 63 | # If string, make a simple match callable 64 | if isinstance(supplied_rule, six.string_types): 65 | return lambda user, message: supplied_rule in message.lower() 66 | 67 | if not six.callable(supplied_rule): 68 | raise ValueError('Bot rules must be callable or strings') 69 | 70 | expected = ('user', 'message') 71 | signature = tuple(inspect.getargspec(supplied_rule).args) 72 | try: 73 | # Support class- and instance-methods where first arg is 74 | # something like `self` or `cls`. 75 | assert len(signature) in (2, 3) 76 | assert expected == signature or expected == signature[-2:] 77 | except AssertionError: 78 | msg = 'Rule signuture must have only 2 arguments: user, message' 79 | raise ValueError(msg) 80 | 81 | return supplied_rule 82 | 83 | def listen_for(self, rule, **options): 84 | """Decorator for adding a Rule. See guidelines for rules.""" 85 | trigger = None 86 | if isinstance(rule, six.string_types): 87 | trigger = rule 88 | rule = self._verify_rule(rule) 89 | 90 | def decorator(f): 91 | self.add_listener(rule, f, trigger, f.__doc__, **options) 92 | return f 93 | 94 | return decorator 95 | 96 | def cron(self, schedule, **options): 97 | def decorator(f): 98 | self.add_cron(schedule, f, **options) 99 | return f 100 | return decorator 101 | 102 | def run(self): 103 | self.running = True 104 | if self.client.rtm_connect(): 105 | try: 106 | self.event_loop() 107 | except (KeyboardInterrupt, SystemExit): 108 | log.info('attempting graceful shutdown...') 109 | self.running = False 110 | try: 111 | sys.exit(0) 112 | except SystemExit: 113 | os._exit(0) 114 | 115 | def event_loop(self): 116 | while self.running: 117 | time.sleep(self.sleep) 118 | self.process_stream() 119 | self.process_scheduled_tasks() 120 | 121 | def read_stream(self): 122 | data = self.client.rtm_read() 123 | if not data: 124 | return data 125 | return [Box(d) for d in data][0] 126 | 127 | def process_stream(self): 128 | data = self.read_stream() 129 | if not data or data.type != 'message' or 'user' not in data: 130 | return 131 | self.respond(data.user, data.text, data.channel) 132 | 133 | def process_scheduled_tasks(self): 134 | now = datetime.datetime.now() 135 | for idx, task in enumerate(self.scheduled_tasks): 136 | if now > task.next_run: 137 | t = self.scheduled_tasks.pop(idx) 138 | t.run() 139 | self.add_cron(t.schedule, t.fn, **t.options) 140 | 141 | def respond(self, user, message, channel): 142 | sendable = { 143 | 'user': user, 144 | 'message': message, 145 | 'channel': channel, 146 | } 147 | if not message: 148 | return 149 | for rule, view_func, _, _, options in self.listeners: 150 | if rule(user, message): 151 | args = inspect.getargspec(view_func).args 152 | kwargs = {k: v for k, v in sendable.items() if k in args} 153 | response = view_func(**kwargs) 154 | if response: 155 | if 'hide_typing' not in options: 156 | # TODO(nficano): this should be configurable 157 | time.sleep(.2) 158 | self.client.server.send_to_websocket({ 159 | 'type': 'typing', 160 | 'channel': channel, 161 | }) 162 | time.sleep(.5) 163 | if '{user.username}' in response: 164 | response = response.replace( 165 | '{user.username}', 166 | self.get_user_name(user), 167 | ) 168 | self.speak(response, channel) 169 | 170 | def add_listener(self, rule, view_func, trigger, docs, **options): 171 | """Adds a listener to the listeners container; verifies that 172 | `rule` and `view_func` are callable. 173 | """ 174 | if not six.callable(rule): 175 | raise TypeError('rule should be callable') 176 | if not six.callable(view_func): 177 | raise TypeError('view_func should be callable') 178 | self.listeners.append( 179 | Listener(rule, view_func, trigger, docs, options), 180 | ) 181 | 182 | def add_cron(self, schedule, f, **options): 183 | self.scheduled_tasks.append(Task(schedule, f, **options)) 184 | 185 | def speak(self, message, channel, **kwargs): 186 | self.client.api_call( 187 | 'chat.postMessage', as_user=True, 188 | channel=channel, text=message, **kwargs, 189 | ) 190 | 191 | def get_user_info(self, user_id): 192 | return self.client.api_call('users.info', user=user_id) 193 | 194 | def get_user_name(self, user_id): 195 | user = self.get_user_info(user_id) 196 | return user.get('user', {}).get('name') 197 | 198 | def get_user_id_from_username(self, username): 199 | for m in self.client.api_call('users.list')['members']: 200 | if username.lower() == m.get('name', '').lower(): 201 | return m['id'] 202 | 203 | def get_channel_id_from_name(self, channel): 204 | channel = channel.lower().replace('#', '') 205 | types = ','.join(['public_channel', 'private_channel']) 206 | 207 | response = self.client.api_call( 208 | 'conversations.list', types=types, limit=1000, 209 | ) 210 | for c in response['channels']: 211 | if channel == c['name'].lower(): 212 | return c['id'] 213 | 214 | response = self.client.api_call('channels.list', limit=1000) 215 | for c in response['channels']: 216 | if channel == c['name'].lower(): 217 | return c['id'] 218 | 219 | def get_channel_name_from_channel_id(self, channel_id): 220 | types = ','.join(['public_channel', 'private_channel']) 221 | 222 | response = self.client.api_call( 223 | 'conversations.list', types=types, limit=1000, 224 | ) 225 | for c in response['channels']: 226 | if channel_id == c['id']: 227 | return c['name'] 228 | 229 | response = self.client.api_call('channels.list', limit=1000) 230 | for c in response['channels']: 231 | if channel_id == c['id']: 232 | return c['name'] 233 | 234 | def get_template_path(self): 235 | if os.path.isabs(self.settings.tangerine.template_folder): 236 | return self.settings.tangerine.template_folder 237 | else: 238 | return os.path.join( 239 | os.getcwd(), 240 | self.settings.tangerine.template_folder, 241 | ) 242 | 243 | def get_jinja_environment(self): 244 | return self.jinja_environment( 245 | loader=FileSystemLoader(self.get_template_path()), 246 | autoescape=select_autoescape(['txt']), 247 | ) 248 | 249 | def render_template(self, template_name, **context): 250 | env = self.get_jinja_environment() 251 | template = env.get_template(template_name) 252 | return template.render(**context) 253 | -------------------------------------------------------------------------------- /tangerine/scheduler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import datetime 4 | 5 | from crontab import CronTab 6 | 7 | 8 | class Task(object): 9 | def __init__(self, schedule, fn, **options): 10 | self.schedule = schedule 11 | self.fn = fn 12 | self.options = options 13 | self.next_run = self.get_next_run(schedule) 14 | 15 | def get_next_run(self, schedule): 16 | now = datetime.datetime.now() 17 | entry = CronTab(schedule) 18 | delta = datetime.timedelta(0, entry.next()) 19 | return now + delta 20 | 21 | def run(self): 22 | self.fn(**self.options) 23 | 24 | def __repr__(self): 25 | return str(self.next_run) 26 | --------------------------------------------------------------------------------