├── .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 |
3 |
4 |
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 |
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 |
--------------------------------------------------------------------------------